From 28ac77e5f82626a9f98074c5e383231728776b59 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 23 Jan 2026 00:26:10 +0100 Subject: [PATCH 01/32] fs: add virtual file system support Add a read-only virtual file system (VFS) that can be mounted at a specific path prefix, enabling standard fs APIs to work transparently with in-memory files. Key features: - fs.createVirtual() to create VFS instances - Support for files, directories, and symbolic links - Full async/sync/promise API support (readFile, stat, readdir, etc.) - File descriptor operations (open, read, close) - createReadStream() support - fs.glob() integration - CJS require() and ESM import() support via module hooks - Virtual process.chdir() for relative path resolution - SEA integration via sea.getVfs() and sea.hasAssets() - Test runner mock.fs() for file system mocking The VFS is read-only by design and uses virtual file descriptors (10000+) to avoid conflicts with real file descriptors. --- doc/api/fs.md | 590 ++++++++ doc/api/single-executable-applications.md | 89 ++ doc/api/test.md | 88 ++ lib/fs.js | 14 + lib/internal/test_runner/mock/mock.js | 109 ++ lib/internal/vfs/entries.js | 350 +++++ lib/internal/vfs/errors.js | 157 ++ lib/internal/vfs/fd.js | 166 ++ lib/internal/vfs/module_hooks.js | 600 ++++++++ lib/internal/vfs/router.js | 134 ++ lib/internal/vfs/sea.js | 94 ++ lib/internal/vfs/stats.js | 196 +++ lib/internal/vfs/streams.js | 161 ++ lib/internal/vfs/virtual_fs.js | 1340 +++++++++++++++++ lib/sea.js | 7 + test/parallel/test-permission-fs-supported.js | 2 + test/parallel/test-runner-mock-fs.js | 236 +++ test/parallel/test-vfs-basic.js | 214 +++ test/parallel/test-vfs-chdir-worker.js | 105 ++ test/parallel/test-vfs-chdir.js | 234 +++ test/parallel/test-vfs-fd.js | 318 ++++ test/parallel/test-vfs-glob.js | 196 +++ test/parallel/test-vfs-import.mjs | 147 ++ test/parallel/test-vfs-promises.js | 299 ++++ test/parallel/test-vfs-require.js | 204 +++ test/parallel/test-vfs-sea.js | 48 + test/parallel/test-vfs-streams.js | 234 +++ test/parallel/test-vfs-symlinks.js | 346 +++++ .../test-single-executable-application-vfs.js | 144 ++ tools/doc/type-parser.mjs | 2 + 30 files changed, 6824 insertions(+) create mode 100644 lib/internal/vfs/entries.js create mode 100644 lib/internal/vfs/errors.js create mode 100644 lib/internal/vfs/fd.js create mode 100644 lib/internal/vfs/module_hooks.js create mode 100644 lib/internal/vfs/router.js create mode 100644 lib/internal/vfs/sea.js create mode 100644 lib/internal/vfs/stats.js create mode 100644 lib/internal/vfs/streams.js create mode 100644 lib/internal/vfs/virtual_fs.js create mode 100644 test/parallel/test-runner-mock-fs.js create mode 100644 test/parallel/test-vfs-basic.js create mode 100644 test/parallel/test-vfs-chdir-worker.js create mode 100644 test/parallel/test-vfs-chdir.js create mode 100644 test/parallel/test-vfs-fd.js create mode 100644 test/parallel/test-vfs-glob.js create mode 100644 test/parallel/test-vfs-import.mjs create mode 100644 test/parallel/test-vfs-promises.js create mode 100644 test/parallel/test-vfs-require.js create mode 100644 test/parallel/test-vfs-sea.js create mode 100644 test/parallel/test-vfs-streams.js create mode 100644 test/parallel/test-vfs-symlinks.js create mode 100644 test/sea/test-single-executable-application-vfs.js diff --git a/doc/api/fs.md b/doc/api/fs.md index 6ea9fa9fdde0f2..8a2e405dc852ac 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -8293,6 +8293,596 @@ The following constants are meant for use with the {fs.Stats} object's On Windows, only `S_IRUSR` and `S_IWUSR` are available. +## Virtual file system + + + +> Stability: 1 - Experimental + +The virtual file system (VFS) allows creating in-memory file system overlays +that integrate seamlessly with the Node.js `fs` module and module loader. Virtual +files and directories can be accessed using standard `fs` operations and can be +`require()`d or `import`ed like regular files. + +### Creating a virtual file system + +Use `fs.createVirtual()` to create a new VFS instance: + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); + +// Add files to the VFS +vfs.addFile('/config.json', JSON.stringify({ debug: true })); +vfs.addFile('/data.txt', 'Hello, World!'); + +// Mount the VFS at a specific path +vfs.mount('/app'); + +// Now files are accessible via standard fs APIs +const config = JSON.parse(fs.readFileSync('/app/config.json', 'utf8')); +console.log(config.debug); // true +``` + +```mjs +import fs from 'node:fs'; + +const vfs = fs.createVirtual(); + +// Add files to the VFS +vfs.addFile('/config.json', JSON.stringify({ debug: true })); +vfs.addFile('/data.txt', 'Hello, World!'); + +// Mount the VFS at a specific path +vfs.mount('/app'); + +// Now files are accessible via standard fs APIs +const config = JSON.parse(fs.readFileSync('/app/config.json', 'utf8')); +console.log(config.debug); // true +``` + +### `fs.createVirtual([options])` + + + +* `options` {Object} + * `fallthrough` {boolean} When `true`, operations on paths not in the VFS + fall through to the real file system. **Default:** `true`. + * `moduleHooks` {boolean} When `true`, enables hooks for `require()` and + `import` to load modules from the VFS. **Default:** `true`. + * `virtualCwd` {boolean} When `true`, enables virtual working directory + support via `vfs.chdir()` and `vfs.cwd()`. **Default:** `false`. +* Returns: {VirtualFileSystem} + +Creates a new virtual file system instance. + +```cjs +const fs = require('node:fs'); + +// Create a VFS that falls through to real fs for unmatched paths +const vfs = fs.createVirtual({ fallthrough: true }); + +// Create a VFS that only serves virtual files +const isolatedVfs = fs.createVirtual({ fallthrough: false }); + +// Create a VFS without module loading hooks (fs operations only) +const fsOnlyVfs = fs.createVirtual({ moduleHooks: false }); +``` + +### Class: `VirtualFileSystem` + + + +A `VirtualFileSystem` instance manages virtual files and directories and +provides methods to mount them into the file system namespace. + +#### `vfs.addFile(path, content)` + + + +* `path` {string} The virtual path for the file. +* `content` {string|Buffer|Function} The file content, or a function that + returns the content. + +Adds a virtual file. The `content` can be: + +* A `string` or `Buffer` for static content +* A synchronous function `() => string|Buffer` for dynamic content +* An async function `async () => string|Buffer` for async dynamic content + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); + +// Static content +vfs.addFile('/config.json', '{"debug": true}'); + +// Dynamic content (evaluated on each read) +vfs.addFile('/timestamp.txt', () => Date.now().toString()); + +// Async dynamic content +vfs.addFile('/data.json', async () => { + const data = await fetchData(); + return JSON.stringify(data); +}); +``` + +#### `vfs.addDirectory(path[, populate])` + + + +* `path` {string} The virtual path for the directory. +* `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. + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); + +// Empty directory +vfs.addDirectory('/empty'); + +// Directory with static contents +vfs.addDirectory('/lib'); +vfs.addFile('/lib/utils.js', 'module.exports = {}'); + +// Dynamic directory (populated on first access) +vfs.addDirectory('/plugins', (dir) => { + dir.addFile('a.js', 'module.exports = "plugin a"'); + dir.addFile('b.js', 'module.exports = "plugin b"'); +}); +``` + +#### `vfs.mount(prefix)` + + + +* `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. + +```cjs +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.overlay()` + + + +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). + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); +vfs.addFile('/etc/myapp/config.json', '{"virtual": true}'); +vfs.overlay(); + +// Virtual file is returned +fs.readFileSync('/etc/myapp/config.json', 'utf8'); // '{"virtual": true}' + +// Real file system used for non-virtual paths +fs.readFileSync('/etc/hosts', 'utf8'); // Real file contents +``` + +#### `vfs.unmount()` + + + +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 +``` + +#### `vfs.has(path)` + + + +* `path` {string} The path to check. +* Returns: {boolean} + +Returns `true` if the VFS contains a file or directory at the given path. + +#### `vfs.remove(path)` + + + +* `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. + +#### `vfs.virtualCwdEnabled` + + + +* {boolean} + +Returns `true` if virtual working directory support is enabled for this VFS +instance. This is determined by the `virtualCwd` option passed to +`fs.createVirtual()`. + +#### `vfs.cwd()` + + + +* Returns: {string|null} The current virtual working directory, or `null` if + not set. + +Gets the virtual current working directory. Throws `ERR_INVALID_STATE` if +`virtualCwd` option was not enabled when creating the VFS. + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual({ virtualCwd: true }); +vfs.addDirectory('/project'); +vfs.mount('/app'); + +console.log(vfs.cwd()); // null (not set yet) + +vfs.chdir('/app/project'); +console.log(vfs.cwd()); // '/app/project' +``` + +#### `vfs.chdir(path)` + + + +* `path` {string} The directory path to set as the current working directory. + +Sets the virtual current working directory. The path must exist in the VFS and +must be a directory. Throws `ENOENT` if the path does not exist, `ENOTDIR` if +the path is not a directory, or `ERR_INVALID_STATE` if `virtualCwd` option was +not enabled. + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual({ virtualCwd: true }); +vfs.addDirectory('/project'); +vfs.addDirectory('/project/src'); +vfs.addFile('/project/src/index.js', 'module.exports = "hello";'); +vfs.mount('/app'); + +vfs.chdir('/app/project'); +console.log(vfs.cwd()); // '/app/project' + +vfs.chdir('/app/project/src'); +console.log(vfs.cwd()); // '/app/project/src' +``` + +##### `process.chdir()` and `process.cwd()` interception + +When `virtualCwd` is enabled and the VFS is mounted or in overlay mode, +`process.chdir()` and `process.cwd()` are intercepted to support transparent +virtual working directory operations: + +* `process.chdir(path)` - When called with a path that resolves to the VFS, + the virtual cwd is updated instead of changing the real process working + directory. Paths outside the VFS fall through to the real `process.chdir()`. + +* `process.cwd()` - When a virtual cwd is set, returns the virtual cwd. + Otherwise, returns the real process working directory. + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual({ virtualCwd: true }); +vfs.addDirectory('/project'); +vfs.mount('/virtual'); + +const originalCwd = process.cwd(); + +// Change to a VFS directory using process.chdir +process.chdir('/virtual/project'); +console.log(process.cwd()); // '/virtual/project' +console.log(vfs.cwd()); // '/virtual/project' + +// Change to a real directory (falls through) +process.chdir('/tmp'); +console.log(process.cwd()); // '/tmp' (real cwd) + +// Restore and unmount +process.chdir(originalCwd); +vfs.unmount(); +``` + +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 +> worker thread has its own `process` object and must set up its own VFS +> instance if virtual cwd support is needed. + +#### `vfs.resolvePath(path)` + + + +* `path` {string} The path to resolve. +* Returns: {string} The resolved absolute path. + +Resolves a path relative to the virtual current working directory. If the path +is absolute, it is returned as-is (normalized). If `virtualCwd` is enabled and +a virtual cwd is set, relative paths are resolved against it. Otherwise, +relative paths are resolved using the real process working directory. + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual({ virtualCwd: true }); +vfs.addDirectory('/project'); +vfs.addDirectory('/project/src'); +vfs.mount('/app'); + +vfs.chdir('/app/project'); + +// Absolute paths returned as-is +console.log(vfs.resolvePath('/other/path')); // '/other/path' + +// Relative paths resolved against virtual cwd +console.log(vfs.resolvePath('src/index.js')); // '/app/project/src/index.js' +console.log(vfs.resolvePath('./src/index.js')); // '/app/project/src/index.js' +``` + +### VFS file system operations + +The `VirtualFileSystem` instance provides direct access to file system +operations that bypass the real file system entirely. These methods have the +same signatures as their `fs` module counterparts. + +#### Synchronous methods + +* `vfs.readFileSync(path[, options])` - Read file contents +* `vfs.statSync(path[, options])` - Get file stats +* `vfs.lstatSync(path[, options])` - Get file stats (same as statSync for VFS) +* `vfs.readdirSync(path[, options])` - List directory contents +* `vfs.existsSync(path)` - Check if path exists +* `vfs.realpathSync(path[, options])` - Resolve path (normalizes `.` and `..`) +* `vfs.accessSync(path[, mode])` - Check file accessibility +* `vfs.openSync(path[, flags[, mode]])` - Open file and return file descriptor +* `vfs.closeSync(fd)` - Close file descriptor +* `vfs.readSync(fd, buffer, offset, length, position)` - Read from file descriptor +* `vfs.fstatSync(fd[, options])` - Get stats from file descriptor + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); +vfs.addFile('/data.txt', 'Hello, World!'); + +// Direct VFS operations (no mounting required) +const content = vfs.readFileSync('/data.txt', 'utf8'); +const stats = vfs.statSync('/data.txt'); +console.log(content); // 'Hello, World!' +console.log(stats.size); // 13 +``` + +#### Callback methods + +* `vfs.readFile(path[, options], callback)` - Read file contents +* `vfs.stat(path[, options], callback)` - Get file stats +* `vfs.lstat(path[, options], callback)` - Get file stats +* `vfs.readdir(path[, options], callback)` - List directory contents +* `vfs.realpath(path[, options], callback)` - Resolve path +* `vfs.access(path[, mode], callback)` - Check file accessibility +* `vfs.open(path[, flags[, mode]], callback)` - Open file +* `vfs.close(fd, callback)` - Close file descriptor +* `vfs.read(fd, buffer, offset, length, position, callback)` - Read from fd +* `vfs.fstat(fd[, options], callback)` - Get stats from file descriptor + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); +vfs.addFile('/async.txt', 'Async content'); + +vfs.readFile('/async.txt', 'utf8', (err, data) => { + if (err) throw err; + console.log(data); // 'Async content' +}); +``` + +#### Promise methods + +The `vfs.promises` object provides promise-based versions of the file system +methods: + +* `vfs.promises.readFile(path[, options])` - Read file contents +* `vfs.promises.stat(path[, options])` - Get file stats +* `vfs.promises.lstat(path[, options])` - Get file stats +* `vfs.promises.readdir(path[, options])` - List directory contents +* `vfs.promises.realpath(path[, options])` - Resolve path +* `vfs.promises.access(path[, mode])` - Check file accessibility + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); +vfs.addFile('/promise.txt', 'Promise content'); + +(async () => { + const data = await vfs.promises.readFile('/promise.txt', 'utf8'); + console.log(data); // 'Promise content' +})(); +``` + +#### Streams + +* `vfs.createReadStream(path[, options])` - Create a readable stream + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); +vfs.addFile('/stream.txt', 'Streaming content'); + +const stream = vfs.createReadStream('/stream.txt', { encoding: 'utf8' }); +stream.on('data', (chunk) => console.log(chunk)); +stream.on('end', () => console.log('Done')); +``` + +The readable stream supports the following options: + +* `encoding` {string} Character encoding for string output. +* `start` {integer} Byte position to start reading from. +* `end` {integer} Byte position to stop reading at (inclusive). +* `highWaterMark` {integer} Maximum number of bytes to buffer. +* `autoClose` {boolean} Automatically close the stream on end. **Default:** `true`. + +### Module loading from VFS + +Virtual files can be loaded as modules using `require()` or `import`. The VFS +integrates with the Node.js module loaders automatically when mounted or in +overlay mode. + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); + +// Add a CommonJS module +vfs.addFile('/app/math.js', ` + module.exports = { + add: (a, b) => a + b, + multiply: (a, b) => a * b + }; +`); + +// Add a package.json +vfs.addFile('/app/package.json', '{"name": "virtual-app", "main": "math.js"}'); + +vfs.mount('/app'); + +// Require the virtual module +const math = require('/app/math.js'); +console.log(math.add(2, 3)); // 5 + +// Require the package +const pkg = require('/app'); +console.log(pkg.multiply(4, 5)); // 20 +``` + +```mjs +import fs from 'node:fs'; + +const vfs = fs.createVirtual(); + +// Add an ES module +vfs.addFile('/esm/module.mjs', ` + export const value = 42; + export default function greet() { return 'Hello'; } +`); + +vfs.mount('/esm'); + +// Dynamic import of virtual ES module +const mod = await import('/esm/module.mjs'); +console.log(mod.value); // 42 +console.log(mod.default()); // 'Hello' +``` + +### Glob support + +The VFS integrates with `fs.glob()`, `fs.globSync()`, and `fs/promises.glob()` +when mounted or in overlay mode: + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); +vfs.addFile('/src/index.js', 'export default 1;'); +vfs.addFile('/src/utils.js', 'export const util = 1;'); +vfs.addFile('/src/lib/helper.js', 'export const helper = 1;'); +vfs.mount('/virtual'); + +// Sync glob +const files = fs.globSync('/virtual/src/**/*.js'); +console.log(files); +// ['/virtual/src/index.js', '/virtual/src/utils.js', '/virtual/src/lib/helper.js'] + +// Async glob with callback +fs.glob('/virtual/src/*.js', (err, matches) => { + console.log(matches); // ['/virtual/src/index.js', '/virtual/src/utils.js'] +}); + +// Async glob with promises (returns async iterator) +const { glob } = require('node:fs/promises'); +(async () => { + for await (const file of glob('/virtual/src/**/*.js')) { + console.log(file); + } +})(); +``` + +### Limitations + +The current VFS implementation has the following limitations: + +* **Read-only**: Files can only be set via `addFile()`. Write operations + (`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 + separately from real file descriptors. + ## Notes ### Ordering of callback and promise-based operations diff --git a/doc/api/single-executable-applications.md b/doc/api/single-executable-applications.md index 611700a7a4bf1e..6afc64e895dceb 100644 --- a/doc/api/single-executable-applications.md +++ b/doc/api/single-executable-applications.md @@ -174,6 +174,94 @@ const raw = getRawAsset('a.jpg'); See documentation of the [`sea.getAsset()`][], [`sea.getAssetAsBlob()`][], [`sea.getRawAsset()`][] and [`sea.getAssetKeys()`][] APIs for more information. +### Virtual File System (VFS) for assets + +> Stability: 1 - Experimental + +Instead of using the `node:sea` API to access individual assets, you can use +the Virtual File System (VFS) to access bundled assets through standard `fs` +APIs. The VFS automatically populates itself with all assets defined in the +SEA configuration and mounts them at a virtual path (default: `/sea`). + +To use the VFS with SEA: + +```cjs +const fs = require('node:fs'); +const sea = require('node:sea'); + +// Check if SEA assets are available +if (sea.hasAssets()) { + // Initialize and mount the SEA VFS + const vfs = sea.getVfs(); + + // Now you can use standard fs APIs to read bundled assets + const config = JSON.parse(fs.readFileSync('/sea/config.json', 'utf8')); + const data = fs.readFileSync('/sea/data/file.txt'); + + // Directory operations work too + const files = fs.readdirSync('/sea/assets'); + + // Check if a bundled file exists + if (fs.existsSync('/sea/optional.json')) { + // ... + } +} +``` + +The VFS supports the following `fs` operations on bundled assets: + +* `readFileSync()` / `readFile()` / `promises.readFile()` +* `statSync()` / `stat()` / `promises.stat()` +* `lstatSync()` / `lstat()` / `promises.lstat()` +* `readdirSync()` / `readdir()` / `promises.readdir()` +* `existsSync()` +* `realpathSync()` / `realpath()` / `promises.realpath()` +* `accessSync()` / `access()` / `promises.access()` +* `openSync()` / `open()` - for reading +* `createReadStream()` + +#### Loading modules from VFS in SEA + +The default `require()` function in a SEA only supports loading Node.js +built-in modules. To load JavaScript modules bundled as assets, you must use +[`module.createRequire()`][]: + +```cjs +const { createRequire } = require('node:module'); +const sea = require('node:sea'); + +// Initialize VFS +sea.getVfs(); + +// Create a require function that works with VFS +const seaRequire = createRequire('/sea/'); + +// Now you can require bundled modules +const myModule = seaRequire('/sea/lib/mymodule.js'); +const utils = seaRequire('/sea/utils/helpers.js'); +``` + +This is necessary because SEA uses a special embedder require that doesn't go +through the standard module resolution hooks that VFS registers. + +#### Custom mount prefix + +By default, the VFS is mounted at `/sea`. You can specify a custom prefix +when initializing the VFS: + +```cjs +const fs = require('node:fs'); +const sea = require('node:sea'); + +const vfs = sea.getSeaVfs({ prefix: '/app' }); + +// Assets are now accessible under /app +const config = fs.readFileSync('/app/config.json', 'utf8'); +``` + +Note: `sea.getVfs()` returns a singleton. The `prefix` option is only used +on the first call; subsequent calls return the same cached instance. + ### Startup snapshot support The `useSnapshot` field can be used to enable startup snapshot support. In this @@ -604,6 +692,7 @@ to help us document them. [Mach-O]: https://en.wikipedia.org/wiki/Mach-O [PE]: https://en.wikipedia.org/wiki/Portable_Executable [Windows SDK]: https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/ +[`module.createRequire()`]: module.md#modulecreaterequirefilename [`process.execPath`]: process.md#processexecpath [`require()`]: modules.md#requireid [`require.main`]: modules.md#accessing-the-main-module diff --git a/doc/api/test.md b/doc/api/test.md index 927208af853d38..4fda764b3b0ddb 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -2334,6 +2334,94 @@ test('mocks a counting function', (t) => { }); ``` +### `mock.fs([options])` + + + +> Stability: 1.0 - Early development + +* `options` {Object} Optional configuration options for the mock file system. + The following properties are supported: + * `prefix` {string} The mount point prefix for the virtual file system. + **Default:** `'/mock'`. + * `files` {Object} An optional object where keys are file paths (relative to + the VFS root) and values are the file contents. Contents can be strings, + Buffers, or functions that return strings/Buffers. +* Returns: {MockFSContext} An object that can be used to manage the mock file + system. + +This function creates a mock file system using the Virtual File System (VFS). +The mock file system is automatically cleaned up when the test completes. + +## Class: `MockFSContext` + +The `MockFSContext` object is returned by `mock.fs()` and provides the +following methods and properties: + +* `vfs` {VirtualFileSystem} The underlying VFS instance. +* `prefix` {string} The mount prefix. +* `addFile(path, content)` Adds a file to the mock file system. +* `addDirectory(path[, populate])` Adds a directory to the mock file system. +* `existsSync(path)` Checks if a path exists (path is relative to prefix). +* `restore()` Manually restores the file system to its original state. + +The following example demonstrates how to create a mock file system for testing: + +```js +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); + +test('reads configuration from mock file', (t) => { + const mockFs = t.mock.fs({ + prefix: '/app', + files: { + '/config.json': JSON.stringify({ debug: true }), + '/data/users.txt': 'user1\nuser2\nuser3', + }, + }); + + // Files are accessible via standard fs APIs + const config = JSON.parse(fs.readFileSync('/app/config.json', 'utf8')); + assert.strictEqual(config.debug, true); + + // Check file existence + assert.strictEqual(fs.existsSync('/app/config.json'), true); + assert.strictEqual(fs.existsSync('/app/missing.txt'), false); + + // Use mockFs.existsSync for paths relative to prefix + assert.strictEqual(mockFs.existsSync('/config.json'), true); +}); + +test('supports dynamic file content', (t) => { + let counter = 0; + const mockFs = t.mock.fs({ prefix: '/dynamic' }); + + mockFs.addFile('/counter.txt', () => { + counter++; + return String(counter); + }); + + // Each read calls the function + assert.strictEqual(fs.readFileSync('/dynamic/counter.txt', 'utf8'), '1'); + assert.strictEqual(fs.readFileSync('/dynamic/counter.txt', 'utf8'), '2'); +}); + +test('supports require from mock files', (t) => { + t.mock.fs({ + prefix: '/modules', + files: { + '/math.js': 'module.exports = { add: (a, b) => a + b };', + }, + }); + + const math = require('/modules/math.js'); + assert.strictEqual(math.add(2, 3), 5); +}); +``` + ### `mock.getter(object, methodName[, implementation][, options])` + + + +> Stability: 1 - Experimental + + + +The `node:vfs` module provides a virtual file system that can be mounted +alongside the real file system. Virtual files can be read using standard `fs` +operations and loaded as modules using `require()` or `import`. + +To access it: + +```mjs +import vfs from 'node:vfs'; +``` + +```cjs +const vfs = require('node:vfs'); +``` + +This module is only available under the `node:` scheme. + +## Overview + +The Virtual File System (VFS) allows you to create in-memory file systems that +integrate seamlessly with Node.js's `fs` module and module loading system. This +is useful for: + +* Bundling assets in Single Executable Applications (SEA) +* Testing file system operations without touching the disk +* Creating virtual module systems +* Embedding configuration or data files in applications + +## Basic usage + +The following example shows how to create a virtual file system, add files, +and access them through the standard `fs` API: + +```mjs +import vfs from 'node:vfs'; +import fs from 'node:fs'; + +// Create a new virtual file system +const myVfs = vfs.create(); + +// Create directories and files +myVfs.mkdirSync('/app'); +myVfs.writeFileSync('/app/config.json', JSON.stringify({ port: 3000 })); +myVfs.writeFileSync('/app/greet.js', 'module.exports = (name) => `Hello, ${name}!`;'); + +// Mount the VFS at a path prefix +myVfs.mount('/virtual'); + +// Now standard fs operations work on the virtual files +const config = JSON.parse(fs.readFileSync('/virtual/app/config.json', 'utf8')); +console.log(config.port); // 3000 + +// Modules can be required from the VFS +const greet = await import('/virtual/app/greet.js'); +console.log(greet.default('World')); // Hello, World! + +// Clean up +myVfs.unmount(); +``` + +```cjs +const vfs = require('node:vfs'); +const fs = require('node:fs'); + +// Create a new virtual file system +const myVfs = vfs.create(); + +// Create directories and files +myVfs.mkdirSync('/app'); +myVfs.writeFileSync('/app/config.json', JSON.stringify({ port: 3000 })); +myVfs.writeFileSync('/app/greet.js', 'module.exports = (name) => `Hello, ${name}!`;'); + +// Mount the VFS at a path prefix +myVfs.mount('/virtual'); + +// Now standard fs operations work on the virtual files +const config = JSON.parse(fs.readFileSync('/virtual/app/config.json', 'utf8')); +console.log(config.port); // 3000 + +// Modules can be required from the VFS +const greet = require('/virtual/app/greet.js'); +console.log(greet('World')); // Hello, World! + +// Clean up +myVfs.unmount(); +``` + +## `vfs.create([provider][, options])` + + + +* `provider` {VirtualProvider} Optional provider instance. Defaults to a new + `MemoryProvider`. +* `options` {Object} + * `moduleHooks` {boolean} Whether to enable `require()`/`import` hooks for + loading modules from the VFS. **Default:** `true`. + * `virtualCwd` {boolean} Whether to enable virtual working directory support. + **Default:** `false`. +* Returns: {VirtualFileSystem} + +Creates a new `VirtualFileSystem` instance. If no provider is specified, a +`MemoryProvider` is used, which stores files in memory. + +```cjs +const vfs = require('node:vfs'); + +// Create with default MemoryProvider +const memoryVfs = vfs.create(); + +// Create with explicit provider +const customVfs = vfs.create(new vfs.MemoryProvider()); + +// Create with options only +const vfsWithOptions = vfs.create({ moduleHooks: false }); +``` + +## `vfs.createSEA([options])` + + + +* `options` {Object} + * `mountPoint` {string} The path prefix where SEA assets will be mounted. + **Default:** `'/sea'`. + * `moduleHooks` {boolean} Whether to enable module loading hooks. + **Default:** `true`. + * `virtualCwd` {boolean} Whether to enable virtual working directory. + **Default:** `false`. +* Returns: {VirtualFileSystem | null} Returns `null` if not running as a + Single Executable Application. + +Creates a `VirtualFileSystem` pre-configured with SEA (Single Executable +Application) assets. This is a convenience method for accessing bundled assets +in SEA builds. + +```cjs +const vfs = require('node:vfs'); +const fs = require('node:fs'); + +const seaVfs = vfs.createSEA({ mountPoint: '/assets' }); +if (seaVfs) { + // Running as SEA - assets are available + const data = fs.readFileSync('/assets/config.json', 'utf8'); +} +``` + +## Class: `VirtualFileSystem` + + + +The `VirtualFileSystem` class provides a file system interface backed by a +provider. It supports standard file system operations and can be mounted to +make virtual files accessible through the `fs` module. + +### `new VirtualFileSystem([provider][, options])` + + + +* `provider` {VirtualProvider} The provider to use. **Default:** `MemoryProvider`. +* `options` {Object} + * `moduleHooks` {boolean} Enable module loading hooks. **Default:** `true`. + * `virtualCwd` {boolean} Enable virtual working directory. **Default:** `false`. + +Creates a new `VirtualFileSystem` instance. + +### `vfs.mount(prefix)` + + + +* `prefix` {string} The path prefix where the VFS will be mounted. + +Mounts the virtual file system at the specified path prefix. After mounting, +files in the VFS can be accessed via the `fs` module using paths that start +with the prefix. + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/data.txt', 'Hello'); +myVfs.mount('/virtual'); + +// Now accessible as /virtual/data.txt +require('node:fs').readFileSync('/virtual/data.txt', 'utf8'); // 'Hello' +``` + +### `vfs.unmount()` + + + +Unmounts the virtual file system. After unmounting, virtual files are no longer +accessible through the `fs` module. + +### `vfs.isMounted()` + + + +* Returns: {boolean} + +Returns `true` if the VFS is currently mounted. + +### `vfs.mountPoint` + + + +* {string | null} + +The current mount point, or `null` if not mounted. + +### `vfs.chdir(path)` + + + +* `path` {string} The new working directory path within the VFS. + +Changes the virtual working directory. This only affects path resolution within +the VFS when `virtualCwd` is enabled. + +### `vfs.cwd()` + + + +* Returns: {string} + +Returns the current virtual working directory. + +### File System Methods + +The `VirtualFileSystem` class provides methods that mirror the `fs` module API. +All paths are relative to the VFS root (not the mount point). + +#### Synchronous Methods + +* `vfs.readFileSync(path[, options])` - Read a file +* `vfs.writeFileSync(path, data[, options])` - Write a file +* `vfs.appendFileSync(path, data[, options])` - Append to a file +* `vfs.statSync(path[, options])` - Get file stats +* `vfs.lstatSync(path[, options])` - Get file stats (no symlink follow) +* `vfs.readdirSync(path[, options])` - Read directory contents +* `vfs.mkdirSync(path[, options])` - Create a directory +* `vfs.rmdirSync(path)` - Remove a directory +* `vfs.unlinkSync(path)` - Remove a file +* `vfs.renameSync(oldPath, newPath)` - Rename a file or directory +* `vfs.copyFileSync(src, dest[, mode])` - Copy a file +* `vfs.existsSync(path)` - Check if path exists +* `vfs.accessSync(path[, mode])` - Check file accessibility +* `vfs.openSync(path, flags[, mode])` - Open a file +* `vfs.closeSync(fd)` - Close a file descriptor +* `vfs.readSync(fd, buffer, offset, length, position)` - Read from fd +* `vfs.writeSync(fd, buffer, offset, length, position)` - Write to fd +* `vfs.realpathSync(path[, options])` - Resolve symlinks +* `vfs.readlinkSync(path[, options])` - Read symlink target +* `vfs.symlinkSync(target, path[, type])` - Create a symlink + +#### Promise Methods + +All synchronous methods have promise-based equivalents available through +`vfs.promises`: + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +async function example() { + await myVfs.promises.writeFile('/data.txt', 'Hello'); + const content = await myVfs.promises.readFile('/data.txt', 'utf8'); + console.log(content); // 'Hello' +} +``` + +### Backward Compatibility Methods + +These methods are provided for backward compatibility and convenience: + +#### `vfs.addFile(path, content[, options])` + + + +* `path` {string} The file path. +* `content` {string | Buffer | Function} The file content or a function that + returns content. +* `options` {Object} Optional configuration. + +Adds a file to the VFS. If `content` is a function, it will be called each time +the file is read (dynamic content). + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Static content +myVfs.addFile('/static.txt', 'Static content'); + +// Dynamic content - function is called on each read +let counter = 0; +myVfs.addFile('/counter.txt', () => { + counter++; + return `Count: ${counter}`; +}); + +myVfs.mount('/v'); +const fs = require('node:fs'); +console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 1 +console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 2 +``` + +#### `vfs.addDirectory(path[, populate][, options])` + + + +* `path` {string} The directory path. +* `populate` {Function} Optional callback to lazily populate the directory. +* `options` {Object} Optional configuration. + +Adds a directory to the VFS. If `populate` is provided, it will be called +lazily when the directory is first accessed. + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Lazy directory - populated on first access +myVfs.addDirectory('/lazy', (dir) => { + dir.addFile('generated.txt', 'Generated on demand'); + dir.addDirectory('subdir', (subdir) => { + subdir.addFile('nested.txt', 'Nested content'); + }); +}); + +myVfs.mount('/v'); +const fs = require('node:fs'); + +// Directory is populated when first accessed +console.log(fs.readdirSync('/v/lazy')); // ['generated.txt', 'subdir'] +``` + +#### `vfs.addSymlink(path, target[, options])` + + + +* `path` {string} The symlink path. +* `target` {string} The symlink target (can be relative or absolute). +* `options` {Object} Optional configuration. + +Adds a symbolic link to the VFS. + +#### `vfs.has(path)` + + + +* `path` {string} The path to check. +* Returns: {boolean} + +Returns `true` if the path exists in the VFS. + +#### `vfs.remove(path)` + + + +* `path` {string} The path to remove. + +Removes a file or directory from the VFS. + +## Class: `VirtualProvider` + + + +The `VirtualProvider` class is an abstract base class for VFS providers. +Providers implement the actual file system storage and operations. + +### Properties + +#### `provider.readonly` + + + +* {boolean} + +Returns `true` if the provider is read-only. + +#### `provider.supportsSymlinks` + + + +* {boolean} + +Returns `true` if the provider supports symbolic links. + +### Creating Custom Providers + +To create a custom provider, extend `VirtualProvider` and implement the +required methods: + +```cjs +const { VirtualProvider } = require('node:vfs'); + +class MyProvider extends VirtualProvider { + get readonly() { return false; } + get supportsSymlinks() { return true; } + + openSync(path, flags, mode) { + // Implementation + } + + statSync(path, options) { + // Implementation + } + + readdirSync(path, options) { + // Implementation + } + + // ... implement other required methods +} +``` + +## Class: `MemoryProvider` + + + +The `MemoryProvider` stores files in memory. It supports full read/write +operations and symbolic links. + +```cjs +const { create, MemoryProvider } = require('node:vfs'); + +const myVfs = create(new MemoryProvider()); +``` + +## Class: `SEAProvider` + + + +The `SEAProvider` provides read-only access to assets bundled in a Single +Executable Application (SEA). It can only be used when running as a SEA. + +```cjs +const { create, SEAProvider } = require('node:vfs'); + +// Only works in SEA builds +try { + const seaVfs = create(new SEAProvider()); + seaVfs.mount('/assets'); +} catch (err) { + console.log('Not running as SEA'); +} +``` + +## Integration with `fs` module + +When a VFS is mounted, the standard `fs` module automatically routes operations +to the VFS for paths that match the mount prefix: + +```cjs +const vfs = require('node:vfs'); +const fs = require('node:fs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/hello.txt', 'Hello from VFS!'); +myVfs.mount('/virtual'); + +// These all work transparently +fs.readFileSync('/virtual/hello.txt', 'utf8'); // Sync +fs.promises.readFile('/virtual/hello.txt', 'utf8'); // Promise +fs.createReadStream('/virtual/hello.txt'); // Stream + +// Real file system is still accessible +fs.readFileSync('/etc/passwd'); // Real file +``` + +## Integration with module loading + +Virtual files can be loaded as modules using `require()` or `import`: + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/math.js', ` + exports.add = (a, b) => a + b; + exports.multiply = (a, b) => a * b; +`); +myVfs.mount('/modules'); + +const math = require('/modules/math.js'); +console.log(math.add(2, 3)); // 5 +``` + +```mjs +import vfs from 'node:vfs'; + +const myVfs = vfs.create(); +myVfs.writeFileSync('/greet.mjs', ` + export default function greet(name) { + return \`Hello, \${name}!\`; + } +`); +myVfs.mount('/modules'); + +const { default: greet } = await import('/modules/greet.mjs'); +console.log(greet('World')); // Hello, World! +``` + +## Use with Single Executable Applications + +The VFS integrates with Node.js Single Executable Applications to provide +access to bundled assets: + +```cjs +// In your SEA entry script +const vfs = require('node:vfs'); +const fs = require('node:fs'); + +const seaVfs = vfs.createSEA(); +if (seaVfs) { + // Access bundled assets + const config = JSON.parse(fs.readFileSync('/sea/config.json', 'utf8')); + const template = fs.readFileSync('/sea/templates/index.html', 'utf8'); +} +``` + +See the [Single Executable Applications][] documentation for more information +on creating SEA builds with assets. + +[Single Executable Applications]: single-executable-applications.md diff --git a/lib/fs.js b/lib/fs.js index 3e2d6e8ce7c000..8e7bf9804a7082 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -3207,7 +3207,7 @@ function globSync(pattern, options) { return new Glob(pattern, options).globSync(); } -const lazyVfs = getLazy(() => require('internal/vfs/virtual_fs').VirtualFileSystem); +const lazyVfs = getLazy(() => require('internal/vfs/file_system').VirtualFileSystem); /** * Creates a new virtual file system instance. diff --git a/lib/internal/bootstrap/realm.js b/lib/internal/bootstrap/realm.js index f49f0814bbc687..6e25416d6db5ef 100644 --- a/lib/internal/bootstrap/realm.js +++ b/lib/internal/bootstrap/realm.js @@ -129,6 +129,7 @@ const schemelessBlockList = new SafeSet([ 'quic', 'test', 'test/reporters', + 'vfs', ]); // Modules that will only be enabled at run time. const experimentalModuleList = new SafeSet(['sqlite', 'quic']); diff --git a/lib/internal/vfs/fd.js b/lib/internal/vfs/fd.js index 93975ad0fc9173..3bc5811416459b 100644 --- a/lib/internal/vfs/fd.js +++ b/lib/internal/vfs/fd.js @@ -8,9 +8,7 @@ const { // Private symbols const kFd = Symbol('kFd'); const kEntry = Symbol('kEntry'); -const kPosition = Symbol('kPosition'); const kFlags = Symbol('kFlags'); -const kContent = Symbol('kContent'); const kPath = Symbol('kPath'); // FD range: 10000+ to avoid conflicts with real fds @@ -22,21 +20,20 @@ const openFDs = new SafeMap(); /** * Represents an open virtual file descriptor. + * Wraps a VirtualFileHandle from the provider. */ class VirtualFD { /** * @param {number} fd The file descriptor number - * @param {VirtualFile} entry The virtual file entry + * @param {VirtualFileHandle} entry The virtual file handle * @param {string} flags The open flags (r, r+, w, w+, a, a+) * @param {string} path The path used to open the file */ constructor(fd, entry, flags, path) { this[kFd] = fd; this[kEntry] = entry; - this[kPosition] = 0; this[kFlags] = flags; this[kPath] = path; - this[kContent] = null; // Cached content buffer } /** @@ -48,8 +45,8 @@ class VirtualFD { } /** - * Gets the file entry. - * @returns {VirtualFile} + * Gets the file handle. + * @returns {VirtualFileHandle} */ get entry() { return this[kEntry]; @@ -60,7 +57,7 @@ class VirtualFD { * @returns {number} */ get position() { - return this[kPosition]; + return this[kEntry].position; } /** @@ -68,7 +65,7 @@ class VirtualFD { * @param {number} pos The new position */ set position(pos) { - this[kPosition] = pos; + this[kEntry].position = pos; } /** @@ -88,27 +85,25 @@ class VirtualFD { } /** - * Gets or loads the cached content buffer. + * Gets the content buffer synchronously. * @returns {Buffer} */ getContentSync() { - this[kContent] ??= this[kEntry].getContentSync(); - return this[kContent]; + return this[kEntry].readFileSync(); } /** - * Gets or loads the cached content buffer asynchronously. + * Gets the content buffer asynchronously. * @returns {Promise} */ async getContent() { - this[kContent] ??= await this[kEntry].getContent(); - return this[kContent]; + return this[kEntry].readFile(); } } /** * Opens a virtual file and returns its file descriptor. - * @param {VirtualFile} entry The virtual file entry + * @param {VirtualFileHandle} entry The virtual file handle * @param {string} flags The open flags * @param {string} path The path used to open the file * @returns {number} The file descriptor diff --git a/lib/internal/vfs/file_handle.js b/lib/internal/vfs/file_handle.js new file mode 100644 index 00000000000000..a014de7263e94d --- /dev/null +++ b/lib/internal/vfs/file_handle.js @@ -0,0 +1,527 @@ +'use strict'; + +const { + MathMin, + Symbol, +} = primordials; + +const { Buffer } = require('buffer'); +const { + createEBADF, +} = require('internal/vfs/errors'); + +// Private symbols +const kPath = Symbol('kPath'); +const kFlags = Symbol('kFlags'); +const kMode = Symbol('kMode'); +const kPosition = Symbol('kPosition'); +const kClosed = Symbol('kClosed'); + +/** + * Base class for virtual file handles. + * Provides the interface that file handles must implement. + */ +class VirtualFileHandle { + /** + * @param {string} path The file path + * @param {string} flags The open flags + * @param {number} [mode] The file mode + */ + constructor(path, flags, mode) { + this[kPath] = path; + this[kFlags] = flags; + this[kMode] = mode ?? 0o644; + this[kPosition] = 0; + this[kClosed] = false; + } + + /** + * Gets the file path. + * @returns {string} + */ + get path() { + return this[kPath]; + } + + /** + * Gets the open flags. + * @returns {string} + */ + get flags() { + return this[kFlags]; + } + + /** + * Gets the file mode. + * @returns {number} + */ + get mode() { + return this[kMode]; + } + + /** + * Gets the current position. + * @returns {number} + */ + get position() { + return this[kPosition]; + } + + /** + * Sets the current position. + * @param {number} pos The new position + */ + set position(pos) { + this[kPosition] = pos; + } + + /** + * Returns true if the handle is closed. + * @returns {boolean} + */ + get closed() { + return this[kClosed]; + } + + /** + * Throws if the handle is closed. + * @private + */ + _checkClosed() { + if (this[kClosed]) { + throw createEBADF('read'); + } + } + + /** + * Reads data from the file. + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer to start writing + * @param {number} length The number of bytes to read + * @param {number|null} position The position to read from (null uses current position) + * @returns {Promise<{ bytesRead: number, buffer: Buffer }>} + */ + async read(buffer, offset, length, position) { + this._checkClosed(); + throw new Error('read not implemented'); + } + + /** + * Reads data from the file synchronously. + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer to start writing + * @param {number} length The number of bytes to read + * @param {number|null} position The position to read from (null uses current position) + * @returns {number} The number of bytes read + */ + readSync(buffer, offset, length, position) { + this._checkClosed(); + throw new Error('readSync not implemented'); + } + + /** + * Writes data to the file. + * @param {Buffer} buffer The buffer to write from + * @param {number} offset The offset in the buffer to start reading + * @param {number} length The number of bytes to write + * @param {number|null} position The position to write to (null uses current position) + * @returns {Promise<{ bytesWritten: number, buffer: Buffer }>} + */ + async write(buffer, offset, length, position) { + this._checkClosed(); + throw new Error('write not implemented'); + } + + /** + * Writes data to the file synchronously. + * @param {Buffer} buffer The buffer to write from + * @param {number} offset The offset in the buffer to start reading + * @param {number} length The number of bytes to write + * @param {number|null} position The position to write to (null uses current position) + * @returns {number} The number of bytes written + */ + writeSync(buffer, offset, length, position) { + this._checkClosed(); + throw new Error('writeSync not implemented'); + } + + /** + * Reads the entire file. + * @param {object|string} [options] Options or encoding + * @returns {Promise} + */ + async readFile(options) { + this._checkClosed(); + throw new Error('readFile not implemented'); + } + + /** + * Reads the entire file synchronously. + * @param {object|string} [options] Options or encoding + * @returns {Buffer|string} + */ + readFileSync(options) { + this._checkClosed(); + throw new Error('readFileSync not implemented'); + } + + /** + * Writes data to the file (replacing content). + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + * @returns {Promise} + */ + async writeFile(data, options) { + this._checkClosed(); + throw new Error('writeFile not implemented'); + } + + /** + * Writes data to the file synchronously (replacing content). + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + */ + writeFileSync(data, options) { + this._checkClosed(); + throw new Error('writeFileSync not implemented'); + } + + /** + * Gets file stats. + * @param {object} [options] Options + * @returns {Promise} + */ + async stat(options) { + this._checkClosed(); + throw new Error('stat not implemented'); + } + + /** + * Gets file stats synchronously. + * @param {object} [options] Options + * @returns {Stats} + */ + statSync(options) { + this._checkClosed(); + throw new Error('statSync not implemented'); + } + + /** + * Truncates the file. + * @param {number} [len] The new length + * @returns {Promise} + */ + async truncate(len) { + this._checkClosed(); + throw new Error('truncate not implemented'); + } + + /** + * Truncates the file synchronously. + * @param {number} [len] The new length + */ + truncateSync(len) { + this._checkClosed(); + throw new Error('truncateSync not implemented'); + } + + /** + * Closes the file handle. + * @returns {Promise} + */ + async close() { + this[kClosed] = true; + } + + /** + * Closes the file handle synchronously. + */ + closeSync() { + this[kClosed] = true; + } +} + +/** + * A file handle for in-memory file content. + * Used by MemoryProvider and similar providers. + */ +class MemoryFileHandle extends VirtualFileHandle { + #content; + #entry; + #getStats; + + /** + * @param {string} path The file path + * @param {string} flags The open flags + * @param {number} [mode] The file mode + * @param {Buffer} content The initial file content + * @param {object} entry The entry object (for updating content) + * @param {Function} getStats Function to get updated stats + */ + constructor(path, flags, mode, content, entry, getStats) { + super(path, flags, mode); + this.#content = content; + this.#entry = entry; + this.#getStats = getStats; + + // Handle different open modes + if (flags === 'w' || flags === 'w+') { + // Write mode: truncate + this.#content = Buffer.alloc(0); + if (entry) { + entry.content = this.#content; + } + } else if (flags === 'a' || flags === 'a+') { + // Append mode: position at end + this.position = this.#content.length; + } + } + + /** + * Gets the current content synchronously. + * For dynamic content providers, this gets fresh content from the entry. + * @returns {Buffer} + */ + get content() { + // If entry has a dynamic content provider, get fresh content sync + if (this.#entry && this.#entry.isDynamic && this.#entry.isDynamic()) { + return this.#entry.getContentSync(); + } + return this.#content; + } + + /** + * Gets the current content asynchronously. + * For dynamic content providers, this gets fresh content from the entry. + * @returns {Promise} + */ + async getContentAsync() { + // If entry has a dynamic content provider, get fresh content async + if (this.#entry && this.#entry.getContentAsync) { + return this.#entry.getContentAsync(); + } + return this.#content; + } + + /** + * Gets the raw stored content (without dynamic resolution). + * @returns {Buffer} + */ + get _rawContent() { + return this.#content; + } + + /** + * Reads data from the file synchronously. + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer to start writing + * @param {number} length The number of bytes to read + * @param {number|null} position The position to read from (null uses current position) + * @returns {number} The number of bytes read + */ + readSync(buffer, offset, length, position) { + this._checkClosed(); + + // Get content (resolves dynamic content providers) + const content = this.content; + const readPos = position !== null && position !== undefined ? position : this.position; + const available = content.length - readPos; + + if (available <= 0) { + return 0; + } + + const bytesToRead = MathMin(length, available); + content.copy(buffer, offset, readPos, readPos + bytesToRead); + + // Update position if not using explicit position + if (position === null || position === undefined) { + this.position = readPos + bytesToRead; + } + + return bytesToRead; + } + + /** + * Reads data from the file. + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer to start writing + * @param {number} length The number of bytes to read + * @param {number|null} position The position to read from (null uses current position) + * @returns {Promise<{ bytesRead: number, buffer: Buffer }>} + */ + async read(buffer, offset, length, position) { + const bytesRead = this.readSync(buffer, offset, length, position); + return { bytesRead, buffer }; + } + + /** + * Writes data to the file synchronously. + * @param {Buffer} buffer The buffer to write from + * @param {number} offset The offset in the buffer to start reading + * @param {number} length The number of bytes to write + * @param {number|null} position The position to write to (null uses current position) + * @returns {number} The number of bytes written + */ + writeSync(buffer, offset, length, position) { + this._checkClosed(); + + const writePos = position !== null && position !== undefined ? position : this.position; + const data = buffer.subarray(offset, offset + length); + + // Expand content if needed + if (writePos + length > this.#content.length) { + const newContent = Buffer.alloc(writePos + length); + this.#content.copy(newContent, 0, 0, this.#content.length); + this.#content = newContent; + } + + // Write the data + data.copy(this.#content, writePos); + + // Update the entry's content + if (this.#entry) { + this.#entry.content = this.#content; + } + + // Update position if not using explicit position + if (position === null || position === undefined) { + this.position = writePos + length; + } + + return length; + } + + /** + * Writes data to the file. + * @param {Buffer} buffer The buffer to write from + * @param {number} offset The offset in the buffer to start reading + * @param {number} length The number of bytes to write + * @param {number|null} position The position to write to (null uses current position) + * @returns {Promise<{ bytesWritten: number, buffer: Buffer }>} + */ + async write(buffer, offset, length, position) { + const bytesWritten = this.writeSync(buffer, offset, length, position); + return { bytesWritten, buffer }; + } + + /** + * Reads the entire file synchronously. + * @param {object|string} [options] Options or encoding + * @returns {Buffer|string} + */ + readFileSync(options) { + this._checkClosed(); + + // Get content (resolves dynamic content providers) + const content = this.content; + const encoding = typeof options === 'string' ? options : options?.encoding; + if (encoding) { + return content.toString(encoding); + } + return Buffer.from(content); + } + + /** + * Reads the entire file. + * @param {object|string} [options] Options or encoding + * @returns {Promise} + */ + async readFile(options) { + this._checkClosed(); + + // Get content asynchronously (supports async content providers) + const content = await this.getContentAsync(); + const encoding = typeof options === 'string' ? options : options?.encoding; + if (encoding) { + return content.toString(encoding); + } + return Buffer.from(content); + } + + /** + * Writes data to the file synchronously (replacing content). + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + */ + writeFileSync(data, options) { + this._checkClosed(); + + const buffer = typeof data === 'string' ? Buffer.from(data, options?.encoding) : data; + this.#content = Buffer.from(buffer); + + // Update the entry's content + if (this.#entry) { + this.#entry.content = this.#content; + } + + this.position = this.#content.length; + } + + /** + * Writes data to the file (replacing content). + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + * @returns {Promise} + */ + async writeFile(data, options) { + this.writeFileSync(data, options); + } + + /** + * Gets file stats synchronously. + * @param {object} [options] Options + * @returns {Stats} + */ + statSync(options) { + this._checkClosed(); + if (this.#getStats) { + return this.#getStats(this.#content.length); + } + throw new Error('stats not available'); + } + + /** + * Gets file stats. + * @param {object} [options] Options + * @returns {Promise} + */ + async stat(options) { + return this.statSync(options); + } + + /** + * Truncates the file synchronously. + * @param {number} [len] The new length + */ + truncateSync(len = 0) { + this._checkClosed(); + + if (len < this.#content.length) { + this.#content = this.#content.subarray(0, len); + } else if (len > this.#content.length) { + const newContent = Buffer.alloc(len); + this.#content.copy(newContent, 0, 0, this.#content.length); + this.#content = newContent; + } + + // Update the entry's content + if (this.#entry) { + this.#entry.content = this.#content; + } + } + + /** + * Truncates the file. + * @param {number} [len] The new length + * @returns {Promise} + */ + async truncate(len) { + this.truncateSync(len); + } +} + +module.exports = { + VirtualFileHandle, + MemoryFileHandle, +}; diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js new file mode 100644 index 00000000000000..acef3e4b9dddd8 --- /dev/null +++ b/lib/internal/vfs/file_system.js @@ -0,0 +1,1092 @@ +'use strict'; + +const { + ObjectFreeze, + Symbol, +} = primordials; + +const { + codes: { + ERR_INVALID_STATE, + }, +} = require('internal/errors'); +const { MemoryProvider } = require('internal/vfs/providers/memory'); +const { + normalizePath, + isUnderMountPoint, + getRelativePath, + joinMountPath, + isAbsolutePath, +} = require('internal/vfs/router'); +const { + openVirtualFd, + getVirtualFd, + closeVirtualFd, + isVirtualFd, + VFS_FD_BASE, +} = require('internal/vfs/fd'); +const { + createENOENT, + createEBADF, +} = require('internal/vfs/errors'); +const { createVirtualReadStream } = require('internal/vfs/streams'); +const { emitExperimentalWarning } = require('internal/util'); + +// Private symbols +const kProvider = Symbol('kProvider'); +const kMountPoint = Symbol('kMountPoint'); +const kMounted = Symbol('kMounted'); +const kOverlay = Symbol('kOverlay'); +const kFallthrough = Symbol('kFallthrough'); +const kModuleHooks = Symbol('kModuleHooks'); +const kPromises = Symbol('kPromises'); +const kVirtualCwd = Symbol('kVirtualCwd'); +const kVirtualCwdEnabled = Symbol('kVirtualCwdEnabled'); +const kOriginalChdir = Symbol('kOriginalChdir'); +const kOriginalCwd = Symbol('kOriginalCwd'); + +// Lazy-loaded module hooks +let registerVFS; +let unregisterVFS; + +function loadModuleHooks() { + if (!registerVFS) { + const hooks = require('internal/vfs/module_hooks'); + registerVFS = hooks.registerVFS; + unregisterVFS = hooks.unregisterVFS; + } +} + +/** + * Virtual File System implementation using Provider architecture. + * Wraps a Provider and provides mount point routing and virtual cwd. + */ +class VirtualFileSystem { + /** + * @param {VirtualProvider|object} [providerOrOptions] The provider to use, or options for backward compat + * @param {object} [options] Configuration options + * @param {boolean} [options.moduleHooks] Whether to enable require/import hooks (default: true) + * @param {boolean} [options.virtualCwd] Whether to enable virtual working directory + * @param {boolean} [options.fallthrough] Backward compat: Whether to fall through to real fs + */ + constructor(providerOrOptions, options = {}) { + emitExperimentalWarning('VirtualFileSystem'); + + // Handle backward compatibility: first arg can be options object + let provider = null; + if (providerOrOptions !== undefined && providerOrOptions !== null) { + if (typeof providerOrOptions.openSync === 'function') { + // It's a provider + provider = providerOrOptions; + } else if (typeof providerOrOptions === 'object') { + // It's options (backward compat) + options = providerOrOptions; + provider = null; + } + } + + this[kProvider] = provider ?? new MemoryProvider(); + this[kMountPoint] = null; + this[kMounted] = false; + this[kOverlay] = false; + this[kFallthrough] = options.fallthrough !== false; + this[kModuleHooks] = options.moduleHooks !== false; + this[kPromises] = null; // Lazy-initialized + this[kVirtualCwdEnabled] = options.virtualCwd === true; + this[kVirtualCwd] = null; // Set when chdir() is called + this[kOriginalChdir] = null; // Saved process.chdir + this[kOriginalCwd] = null; // Saved process.cwd + } + + /** + * Gets the underlying provider. + * @returns {VirtualProvider} + */ + get provider() { + return this[kProvider]; + } + + /** + * Gets the mount point path, or null if not mounted. + * @returns {string|null} + */ + get mountPoint() { + return this[kMountPoint]; + } + + /** + * Returns true if VFS is mounted. + * @returns {boolean} + */ + get isMounted() { + return this[kMounted]; + } + + /** + * Returns true if the provider is read-only. + * @returns {boolean} + */ + get readonly() { + return this[kProvider].readonly; + } + + /** + * Returns true if virtual working directory is enabled. + * @returns {boolean} + */ + get virtualCwdEnabled() { + return this[kVirtualCwdEnabled]; + } + + // ==================== Backward Compatibility Properties ==================== + + /** + * Returns true if VFS is in overlay mode. + * @returns {boolean} + */ + get isOverlay() { + return this[kOverlay]; + } + + /** + * Returns true if VFS falls through to real fs on miss. + * @returns {boolean} + */ + get fallthrough() { + return this[kFallthrough]; + } + + // ==================== Virtual Working Directory ==================== + + /** + * Gets the virtual current working directory. + * Returns null if no virtual cwd is set. + * @returns {string|null} + */ + cwd() { + if (!this[kVirtualCwdEnabled]) { + throw new ERR_INVALID_STATE('virtual cwd is not enabled'); + } + return this[kVirtualCwd]; + } + + /** + * Sets the virtual current working directory. + * The path must exist in the VFS. + * @param {string} dirPath The directory path to set as cwd + */ + chdir(dirPath) { + if (!this[kVirtualCwdEnabled]) { + throw new ERR_INVALID_STATE('virtual cwd is not enabled'); + } + + const providerPath = this._toProviderPath(dirPath); + const stats = this[kProvider].statSync(providerPath); + + if (!stats.isDirectory()) { + const { createENOTDIR } = require('internal/vfs/errors'); + throw createENOTDIR('chdir', dirPath); + } + + // Store the full path (with mount point) as virtual cwd + this[kVirtualCwd] = this._toMountedPath(providerPath); + } + + /** + * Resolves a path relative to the virtual cwd if set. + * If the path is absolute or no virtual cwd is set, returns the path as-is. + * @param {string} inputPath The path to resolve + * @returns {string} The resolved path + */ + resolvePath(inputPath) { + // If path is absolute, return as-is + if (isAbsolutePath(inputPath)) { + return normalizePath(inputPath); + } + + // If virtual cwd is enabled and set, resolve relative to it + if (this[kVirtualCwdEnabled] && this[kVirtualCwd] !== null) { + const resolved = this[kVirtualCwd] + '/' + inputPath; + return normalizePath(resolved); + } + + // Fall back to normalizing the path (will use real cwd) + return normalizePath(inputPath); + } + + // ==================== Mount ==================== + + /** + * Mounts the VFS at a specific path prefix. + * @param {string} prefix The mount point path + */ + mount(prefix) { + if (this[kMounted] || this[kOverlay]) { + throw new ERR_INVALID_STATE('VFS is already mounted or in overlay mode'); + } + this[kMountPoint] = normalizePath(prefix); + this[kMounted] = true; + if (this[kModuleHooks]) { + loadModuleHooks(); + registerVFS(this); + } + if (this[kVirtualCwdEnabled]) { + this._hookProcessCwd(); + } + } + + /** + * Enables overlay mode (intercepts all matching paths). + * Backward compatibility method. + */ + overlay() { + if (this[kMounted] || this[kOverlay]) { + throw new ERR_INVALID_STATE('VFS is already mounted or in overlay mode'); + } + this[kOverlay] = true; + if (this[kModuleHooks]) { + loadModuleHooks(); + registerVFS(this); + } + if (this[kVirtualCwdEnabled]) { + this._hookProcessCwd(); + } + } + + /** + * Unmounts the VFS. + */ + unmount() { + this._unhookProcessCwd(); + if (this[kModuleHooks]) { + loadModuleHooks(); + unregisterVFS(this); + } + this[kMountPoint] = null; + this[kMounted] = false; + this[kOverlay] = false; + this[kVirtualCwd] = null; // Reset virtual cwd on unmount + } + + /** + * Hooks process.chdir and process.cwd to support virtual cwd. + * @private + */ + _hookProcessCwd() { + if (this[kOriginalChdir] !== null) { + return; + } + + const vfs = this; + + this[kOriginalChdir] = process.chdir; + this[kOriginalCwd] = process.cwd; + + process.chdir = function chdir(directory) { + const normalized = normalizePath(directory); + + if (vfs.shouldHandle(normalized)) { + vfs.chdir(normalized); + return; + } + + return vfs[kOriginalChdir].call(process, directory); + }; + + process.cwd = function cwd() { + if (vfs[kVirtualCwd] !== null) { + return vfs[kVirtualCwd]; + } + + return vfs[kOriginalCwd].call(process); + }; + } + + /** + * Restores original process.chdir and process.cwd. + * @private + */ + _unhookProcessCwd() { + if (this[kOriginalChdir] === null) { + return; + } + + process.chdir = this[kOriginalChdir]; + process.cwd = this[kOriginalCwd]; + + this[kOriginalChdir] = null; + this[kOriginalCwd] = null; + } + + // ==================== Path Resolution ==================== + + /** + * Converts a mounted path to a provider-relative path. + * @param {string} inputPath The path to convert + * @returns {string} The provider-relative path + * @private + */ + _toProviderPath(inputPath) { + const resolved = this.resolvePath(inputPath); + + if (this[kMounted] && this[kMountPoint]) { + if (!isUnderMountPoint(resolved, this[kMountPoint])) { + throw createENOENT('open', inputPath); + } + return getRelativePath(resolved, this[kMountPoint]); + } + + return resolved; + } + + /** + * Converts a provider-relative path to a mounted path. + * @param {string} providerPath The provider-relative path + * @returns {string} The mounted path + * @private + */ + _toMountedPath(providerPath) { + if (this[kMounted] && this[kMountPoint]) { + return joinMountPath(this[kMountPoint], providerPath); + } + return providerPath; + } + + /** + * Checks if a path should be handled by this VFS. + * @param {string} inputPath The path to check + * @returns {boolean} + */ + shouldHandle(inputPath) { + if (!this[kMounted] && !this[kOverlay]) { + return false; + } + + const normalized = normalizePath(inputPath); + + if (this[kOverlay]) { + // In overlay mode, check if the path exists in VFS + try { + return this[kProvider].existsSync(normalized); + } catch { + return false; + } + } + + if (this[kMounted] && this[kMountPoint]) { + // In mount mode, check if path is under mount point + return isUnderMountPoint(normalized, this[kMountPoint]); + } + + return false; + } + + // ==================== FS Operations (Sync) ==================== + + /** + * Checks if a path exists synchronously. + * @param {string} filePath The path to check + * @returns {boolean} + */ + existsSync(filePath) { + try { + const providerPath = this._toProviderPath(filePath); + return this[kProvider].existsSync(providerPath); + } catch { + return false; + } + } + + /** + * Gets stats for a path synchronously. + * @param {string} filePath The path to stat + * @param {object} [options] Options + * @returns {Stats} + */ + statSync(filePath, options) { + const providerPath = this._toProviderPath(filePath); + return this[kProvider].statSync(providerPath, options); + } + + /** + * Gets stats for a path synchronously without following symlinks. + * @param {string} filePath The path to stat + * @param {object} [options] Options + * @returns {Stats} + */ + lstatSync(filePath, options) { + const providerPath = this._toProviderPath(filePath); + return this[kProvider].lstatSync(providerPath, options); + } + + /** + * Reads a file synchronously. + * @param {string} filePath The path to read + * @param {object|string} [options] Options or encoding + * @returns {Buffer|string} + */ + readFileSync(filePath, options) { + const providerPath = this._toProviderPath(filePath); + return this[kProvider].readFileSync(providerPath, options); + } + + /** + * Writes a file synchronously. + * @param {string} filePath The path to write + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + */ + writeFileSync(filePath, data, options) { + const providerPath = this._toProviderPath(filePath); + this[kProvider].writeFileSync(providerPath, data, options); + } + + /** + * Appends to a file synchronously. + * @param {string} filePath The path to append to + * @param {Buffer|string} data The data to append + * @param {object} [options] Options + */ + appendFileSync(filePath, data, options) { + const providerPath = this._toProviderPath(filePath); + this[kProvider].appendFileSync(providerPath, data, options); + } + + /** + * Reads directory contents synchronously. + * @param {string} dirPath The directory path + * @param {object} [options] Options + * @returns {string[]|Dirent[]} + */ + readdirSync(dirPath, options) { + const providerPath = this._toProviderPath(dirPath); + return this[kProvider].readdirSync(providerPath, options); + } + + /** + * Creates a directory synchronously. + * @param {string} dirPath The directory path + * @param {object} [options] Options + * @returns {string|undefined} + */ + mkdirSync(dirPath, options) { + const providerPath = this._toProviderPath(dirPath); + const result = this[kProvider].mkdirSync(providerPath, options); + if (result !== undefined) { + return this._toMountedPath(result); + } + return undefined; + } + + /** + * Removes a directory synchronously. + * @param {string} dirPath The directory path + */ + rmdirSync(dirPath) { + const providerPath = this._toProviderPath(dirPath); + this[kProvider].rmdirSync(providerPath); + } + + /** + * Removes a file synchronously. + * @param {string} filePath The file path + */ + unlinkSync(filePath) { + const providerPath = this._toProviderPath(filePath); + this[kProvider].unlinkSync(providerPath); + } + + /** + * Renames a file or directory synchronously. + * @param {string} oldPath The old path + * @param {string} newPath The new path + */ + renameSync(oldPath, newPath) { + const oldProviderPath = this._toProviderPath(oldPath); + const newProviderPath = this._toProviderPath(newPath); + this[kProvider].renameSync(oldProviderPath, newProviderPath); + } + + /** + * Copies a file synchronously. + * @param {string} src Source path + * @param {string} dest Destination path + * @param {number} [mode] Copy mode flags + */ + copyFileSync(src, dest, mode) { + const srcProviderPath = this._toProviderPath(src); + const destProviderPath = this._toProviderPath(dest); + this[kProvider].copyFileSync(srcProviderPath, destProviderPath, mode); + } + + /** + * Gets the real path by resolving all symlinks. + * @param {string} filePath The path + * @param {object} [options] Options + * @returns {string} + */ + realpathSync(filePath, options) { + const providerPath = this._toProviderPath(filePath); + const realProviderPath = this[kProvider].realpathSync(providerPath, options); + return this._toMountedPath(realProviderPath); + } + + /** + * Reads the target of a symbolic link. + * @param {string} linkPath The symlink path + * @param {object} [options] Options + * @returns {string} + */ + readlinkSync(linkPath, options) { + const providerPath = this._toProviderPath(linkPath); + return this[kProvider].readlinkSync(providerPath, options); + } + + /** + * Creates a symbolic link. + * @param {string} target The symlink target + * @param {string} path The symlink path + * @param {string} [type] The symlink type + */ + symlinkSync(target, path, type) { + const providerPath = this._toProviderPath(path); + this[kProvider].symlinkSync(target, providerPath, type); + } + + /** + * Checks file accessibility synchronously. + * @param {string} filePath The path to check + * @param {number} [mode] Access mode + */ + accessSync(filePath, mode) { + const providerPath = this._toProviderPath(filePath); + this[kProvider].accessSync(providerPath, mode); + } + + /** + * Returns the stat result code for module resolution. + * @param {string} filePath The path to check + * @returns {number} 0 for file, 1 for directory, -2 for not found + */ + internalModuleStat(filePath) { + try { + const providerPath = this._toProviderPath(filePath); + return this[kProvider].internalModuleStat(providerPath); + } catch { + return -2; + } + } + + // ==================== File Descriptor Operations ==================== + + /** + * Opens a file synchronously and returns a file descriptor. + * @param {string} filePath The path to open + * @param {string} [flags] Open flags + * @param {number} [mode] File mode + * @returns {number} The file descriptor + */ + openSync(filePath, flags = 'r', mode) { + const providerPath = this._toProviderPath(filePath); + const handle = this[kProvider].openSync(providerPath, flags, mode); + return openVirtualFd(handle, flags, this._toMountedPath(providerPath)); + } + + /** + * Closes a file descriptor synchronously. + * @param {number} fd The file descriptor + */ + closeSync(fd) { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF('close'); + } + vfd.entry.closeSync(); + closeVirtualFd(fd); + } + + /** + * Reads from a file descriptor synchronously. + * @param {number} fd The file descriptor + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer + * @param {number} length The number of bytes to read + * @param {number|null} position The position in the file + * @returns {number} The number of bytes read + */ + readSync(fd, buffer, offset, length, position) { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF('read'); + } + return vfd.entry.readSync(buffer, offset, length, position); + } + + /** + * Gets file stats from a file descriptor synchronously. + * @param {number} fd The file descriptor + * @param {object} [options] Options + * @returns {Stats} + */ + fstatSync(fd, options) { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF('fstat'); + } + return vfd.entry.statSync(options); + } + + // ==================== FS Operations (Async with Callbacks) ==================== + + /** + * Reads a file asynchronously. + * @param {string} filePath The path to read + * @param {object|string|Function} [options] Options, encoding, or callback + * @param {Function} [callback] Callback (err, data) + */ + readFile(filePath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].readFile(this._toProviderPath(filePath), options) + .then((data) => callback(null, data)) + .catch((err) => callback(err)); + } + + /** + * Writes a file asynchronously. + * @param {string} filePath The path to write + * @param {Buffer|string} data The data to write + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err) + */ + writeFile(filePath, data, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].writeFile(this._toProviderPath(filePath), data, options) + .then(() => callback(null)) + .catch((err) => callback(err)); + } + + /** + * Gets stats for a path asynchronously. + * @param {string} filePath The path to stat + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, stats) + */ + stat(filePath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].stat(this._toProviderPath(filePath), options) + .then((stats) => callback(null, stats)) + .catch((err) => callback(err)); + } + + /** + * Gets stats without following symlinks asynchronously. + * @param {string} filePath The path to stat + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, stats) + */ + lstat(filePath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].lstat(this._toProviderPath(filePath), options) + .then((stats) => callback(null, stats)) + .catch((err) => callback(err)); + } + + /** + * Reads directory contents asynchronously. + * @param {string} dirPath The directory path + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, entries) + */ + readdir(dirPath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].readdir(this._toProviderPath(dirPath), options) + .then((entries) => callback(null, entries)) + .catch((err) => callback(err)); + } + + /** + * Gets the real path asynchronously. + * @param {string} filePath The path + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, resolvedPath) + */ + realpath(filePath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].realpath(this._toProviderPath(filePath), options) + .then((realPath) => callback(null, this._toMountedPath(realPath))) + .catch((err) => callback(err)); + } + + /** + * Reads symlink target asynchronously. + * @param {string} linkPath The symlink path + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, target) + */ + readlink(linkPath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].readlink(this._toProviderPath(linkPath), options) + .then((target) => callback(null, target)) + .catch((err) => callback(err)); + } + + /** + * Checks file accessibility asynchronously. + * @param {string} filePath The path to check + * @param {number|Function} [mode] Access mode or callback + * @param {Function} [callback] Callback (err) + */ + access(filePath, mode, callback) { + if (typeof mode === 'function') { + callback = mode; + mode = undefined; + } + + this[kProvider].access(this._toProviderPath(filePath), mode) + .then(() => callback(null)) + .catch((err) => callback(err)); + } + + /** + * Opens a file asynchronously. + * @param {string} filePath The path to open + * @param {string|Function} [flags] Open flags or callback + * @param {number|Function} [mode] File mode or callback + * @param {Function} [callback] Callback (err, fd) + */ + open(filePath, flags, mode, callback) { + if (typeof flags === 'function') { + callback = flags; + flags = 'r'; + mode = undefined; + } else if (typeof mode === 'function') { + callback = mode; + mode = undefined; + } + + const providerPath = this._toProviderPath(filePath); + this[kProvider].open(providerPath, flags, mode) + .then((handle) => { + const fd = openVirtualFd(handle, flags, this._toMountedPath(providerPath)); + callback(null, fd); + }) + .catch((err) => callback(err)); + } + + /** + * Closes a file descriptor asynchronously. + * @param {number} fd The file descriptor + * @param {Function} callback Callback (err) + */ + close(fd, callback) { + const vfd = getVirtualFd(fd); + if (!vfd) { + process.nextTick(callback, createEBADF('close')); + return; + } + + vfd.entry.close() + .then(() => { + closeVirtualFd(fd); + callback(null); + }) + .catch((err) => callback(err)); + } + + /** + * Reads from a file descriptor asynchronously. + * @param {number} fd The file descriptor + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer + * @param {number} length The number of bytes to read + * @param {number|null} position The position in the file + * @param {Function} callback Callback (err, bytesRead, buffer) + */ + read(fd, buffer, offset, length, position, callback) { + const vfd = getVirtualFd(fd); + if (!vfd) { + process.nextTick(callback, createEBADF('read')); + return; + } + + vfd.entry.read(buffer, offset, length, position) + .then(({ bytesRead }) => callback(null, bytesRead, buffer)) + .catch((err) => callback(err)); + } + + /** + * Gets file stats from a file descriptor asynchronously. + * @param {number} fd The file descriptor + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, stats) + */ + fstat(fd, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + const vfd = getVirtualFd(fd); + if (!vfd) { + process.nextTick(callback, createEBADF('fstat')); + return; + } + + vfd.entry.stat(options) + .then((stats) => callback(null, stats)) + .catch((err) => callback(err)); + } + + // ==================== Stream Operations ==================== + + /** + * Creates a readable stream for a virtual file. + * @param {string} filePath The path to the file + * @param {object} [options] Stream options + * @returns {ReadStream} + */ + createReadStream(filePath, options) { + return createVirtualReadStream(this, filePath, options); + } + + // ==================== Backward Compatibility Methods ==================== + + /** + * Adds a file to the VFS. + * Backward compatibility method - use writeFileSync instead. + * @param {string} filePath The absolute path for the file + * @param {Buffer|string|Function} content The file content or content provider + * @param {object} [options] Optional configuration + */ + addFile(filePath, content, options) { + // Handle dynamic content providers + if (typeof content === 'function') { + // Check if provider supports dynamic content + if (typeof this[kProvider].setContentProvider === 'function') { + this[kProvider].setContentProvider(filePath, content); + } else { + // Fallback: call function once and store result + const result = content(); + this[kProvider].writeFileSync(filePath, result, options); + } + } else { + this[kProvider].writeFileSync(filePath, content, options); + } + } + + /** + * Adds a directory to the VFS. + * Backward compatibility method - use mkdirSync instead. + * @param {string} dirPath The absolute path for the directory + * @param {Function} [populate] Optional callback to populate directory contents + * @param {object} [options] Optional configuration + */ + addDirectory(dirPath, populate, options) { + // Handle dynamic directory population + if (typeof populate === 'function') { + // Check if provider supports lazy population + if (typeof this[kProvider].setPopulateCallback === 'function') { + this[kProvider].setPopulateCallback(dirPath, populate); + } else { + // Fallback: create directory and call populate immediately + this[kProvider].mkdirSync(dirPath, { recursive: true, ...options }); + const scopedVfs = { + addFile: (name, content, opts) => { + const fullPath = dirPath + '/' + name; + this.addFile(fullPath, content, opts); + }, + addDirectory: (name, pop, opts) => { + const fullPath = dirPath + '/' + name; + this.addDirectory(fullPath, pop, opts); + }, + addSymlink: (name, target, opts) => { + const fullPath = dirPath + '/' + name; + this.addSymlink(fullPath, target, opts); + }, + }; + populate(scopedVfs); + } + } else { + this[kProvider].mkdirSync(dirPath, { recursive: true, ...options }); + } + } + + /** + * Adds a symbolic link to the VFS. + * Backward compatibility method - use symlinkSync instead. + * @param {string} linkPath The absolute path for the symlink + * @param {string} target The symlink target (can be relative or absolute) + * @param {object} [options] Optional configuration + */ + addSymlink(linkPath, target, options) { + this[kProvider].symlinkSync(target, linkPath); + } + + /** + * Removes an entry from the VFS. + * Backward compatibility method - use unlinkSync or rmdirSync instead. + * @param {string} entryPath The absolute path to remove + * @returns {boolean} True if the entry was removed + */ + remove(entryPath) { + try { + const stats = this[kProvider].statSync(entryPath); + if (stats.isDirectory()) { + this[kProvider].rmdirSync(entryPath); + } else { + this[kProvider].unlinkSync(entryPath); + } + return true; + } catch { + return false; + } + } + + /** + * Checks if a path exists in the VFS. + * Backward compatibility method - use existsSync instead. + * @param {string} entryPath The absolute path to check + * @returns {boolean} + */ + has(entryPath) { + return this[kProvider].existsSync(entryPath); + } + + // ==================== Promise API ==================== + + /** + * Gets the promises API for this VFS instance. + * @returns {object} Promise-based fs methods + */ + get promises() { + if (this[kPromises] === null) { + this[kPromises] = createPromisesAPI(this); + } + return this[kPromises]; + } +} + +/** + * Creates the promises API object for a VFS instance. + * @param {VirtualFileSystem} vfs The VFS instance + * @returns {object} Promise-based fs methods + */ +function createPromisesAPI(vfs) { + const provider = vfs[kProvider]; + + return ObjectFreeze({ + async readFile(filePath, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.readFile(providerPath, options); + }, + + async writeFile(filePath, data, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.writeFile(providerPath, data, options); + }, + + async appendFile(filePath, data, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.appendFile(providerPath, data, options); + }, + + async stat(filePath, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.stat(providerPath, options); + }, + + async lstat(filePath, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.lstat(providerPath, options); + }, + + async readdir(dirPath, options) { + const providerPath = vfs._toProviderPath(dirPath); + return provider.readdir(providerPath, options); + }, + + async mkdir(dirPath, options) { + const providerPath = vfs._toProviderPath(dirPath); + const result = await provider.mkdir(providerPath, options); + if (result !== undefined) { + return vfs._toMountedPath(result); + } + return undefined; + }, + + async rmdir(dirPath) { + const providerPath = vfs._toProviderPath(dirPath); + return provider.rmdir(providerPath); + }, + + async unlink(filePath) { + const providerPath = vfs._toProviderPath(filePath); + return provider.unlink(providerPath); + }, + + async rename(oldPath, newPath) { + const oldProviderPath = vfs._toProviderPath(oldPath); + const newProviderPath = vfs._toProviderPath(newPath); + return provider.rename(oldProviderPath, newProviderPath); + }, + + async copyFile(src, dest, mode) { + const srcProviderPath = vfs._toProviderPath(src); + const destProviderPath = vfs._toProviderPath(dest); + return provider.copyFile(srcProviderPath, destProviderPath, mode); + }, + + async realpath(filePath, options) { + const providerPath = vfs._toProviderPath(filePath); + const realPath = await provider.realpath(providerPath, options); + return vfs._toMountedPath(realPath); + }, + + async readlink(linkPath, options) { + const providerPath = vfs._toProviderPath(linkPath); + return provider.readlink(providerPath, options); + }, + + async symlink(target, path, type) { + const providerPath = vfs._toProviderPath(path); + return provider.symlink(target, providerPath, type); + }, + + async access(filePath, mode) { + const providerPath = vfs._toProviderPath(filePath); + return provider.access(providerPath, mode); + }, + }); +} + +module.exports = { + VirtualFileSystem, +}; diff --git a/lib/internal/vfs/provider.js b/lib/internal/vfs/provider.js new file mode 100644 index 00000000000000..bc713c412221c0 --- /dev/null +++ b/lib/internal/vfs/provider.js @@ -0,0 +1,499 @@ +'use strict'; + +const { + ERR_METHOD_NOT_IMPLEMENTED, +} = require('internal/errors').codes; + +const { + createEROFS, +} = require('internal/vfs/errors'); + +/** + * Base class for VFS providers. + * Providers implement the essential primitives that the VFS delegates to. + * + * Implementations must override the essential primitives (open, stat, readdir, etc.) + * Default implementations for derived methods (readFile, writeFile, etc.) are provided. + */ +class VirtualProvider { + // === CAPABILITY FLAGS === + + /** + * Returns true if this provider is read-only. + * @returns {boolean} + */ + get readonly() { + return false; + } + + /** + * Returns true if this provider supports symbolic links. + * @returns {boolean} + */ + get supportsSymlinks() { + return false; + } + + // === ESSENTIAL PRIMITIVES (must be implemented by subclasses) === + + /** + * Opens a file and returns a file handle. + * @param {string} path The file path (relative to provider root) + * @param {string} flags The open flags ('r', 'r+', 'w', 'w+', 'a', 'a+') + * @param {number} [mode] The file mode (for creating files) + * @returns {Promise} + */ + async open(path, flags, mode) { + throw new ERR_METHOD_NOT_IMPLEMENTED('open'); + } + + /** + * Opens a file synchronously and returns a file handle. + * @param {string} path The file path (relative to provider root) + * @param {string} flags The open flags ('r', 'r+', 'w', 'w+', 'a', 'a+') + * @param {number} [mode] The file mode (for creating files) + * @returns {VirtualFileHandle} + */ + openSync(path, flags, mode) { + throw new ERR_METHOD_NOT_IMPLEMENTED('openSync'); + } + + /** + * Gets stats for a path. + * @param {string} path The path to stat + * @param {object} [options] Options + * @returns {Promise} + */ + async stat(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('stat'); + } + + /** + * Gets stats for a path synchronously. + * @param {string} path The path to stat + * @param {object} [options] Options + * @returns {Stats} + */ + statSync(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('statSync'); + } + + /** + * Gets stats for a path without following symlinks. + * @param {string} path The path to stat + * @param {object} [options] Options + * @returns {Promise} + */ + async lstat(path, options) { + // Default: same as stat (for providers that don't support symlinks) + return this.stat(path, options); + } + + /** + * Gets stats for a path synchronously without following symlinks. + * @param {string} path The path to stat + * @param {object} [options] Options + * @returns {Stats} + */ + lstatSync(path, options) { + // Default: same as statSync (for providers that don't support symlinks) + return this.statSync(path, options); + } + + /** + * Reads directory contents. + * @param {string} path The directory path + * @param {object} [options] Options + * @returns {Promise} + */ + async readdir(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('readdir'); + } + + /** + * Reads directory contents synchronously. + * @param {string} path The directory path + * @param {object} [options] Options + * @returns {string[]|Dirent[]} + */ + readdirSync(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('readdirSync'); + } + + /** + * Creates a directory. + * @param {string} path The directory path + * @param {object} [options] Options + * @returns {Promise} + */ + async mkdir(path, options) { + if (this.readonly) { + throw createEROFS('mkdir', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('mkdir'); + } + + /** + * Creates a directory synchronously. + * @param {string} path The directory path + * @param {object} [options] Options + */ + mkdirSync(path, options) { + if (this.readonly) { + throw createEROFS('mkdir', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('mkdirSync'); + } + + /** + * Removes a directory. + * @param {string} path The directory path + * @returns {Promise} + */ + async rmdir(path) { + if (this.readonly) { + throw createEROFS('rmdir', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('rmdir'); + } + + /** + * Removes a directory synchronously. + * @param {string} path The directory path + */ + rmdirSync(path) { + if (this.readonly) { + throw createEROFS('rmdir', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('rmdirSync'); + } + + /** + * Removes a file. + * @param {string} path The file path + * @returns {Promise} + */ + async unlink(path) { + if (this.readonly) { + throw createEROFS('unlink', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('unlink'); + } + + /** + * Removes a file synchronously. + * @param {string} path The file path + */ + unlinkSync(path) { + if (this.readonly) { + throw createEROFS('unlink', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('unlinkSync'); + } + + /** + * Renames a file or directory. + * @param {string} oldPath The old path + * @param {string} newPath The new path + * @returns {Promise} + */ + async rename(oldPath, newPath) { + if (this.readonly) { + throw createEROFS('rename', oldPath); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('rename'); + } + + /** + * Renames a file or directory synchronously. + * @param {string} oldPath The old path + * @param {string} newPath The new path + */ + renameSync(oldPath, newPath) { + if (this.readonly) { + throw createEROFS('rename', oldPath); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('renameSync'); + } + + // === DEFAULT IMPLEMENTATIONS (built on primitives) === + + /** + * Reads a file. + * @param {string} path The file path + * @param {object|string} [options] Options or encoding + * @returns {Promise} + */ + async readFile(path, options) { + const handle = await this.open(path, 'r'); + try { + return await handle.readFile(options); + } finally { + await handle.close(); + } + } + + /** + * Reads a file synchronously. + * @param {string} path The file path + * @param {object|string} [options] Options or encoding + * @returns {Buffer|string} + */ + readFileSync(path, options) { + const handle = this.openSync(path, 'r'); + try { + return handle.readFileSync(options); + } finally { + handle.closeSync(); + } + } + + /** + * Writes a file. + * @param {string} path The file path + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + * @returns {Promise} + */ + async writeFile(path, data, options) { + if (this.readonly) { + throw createEROFS('open', path); + } + const handle = await this.open(path, 'w', options?.mode); + try { + await handle.writeFile(data, options); + } finally { + await handle.close(); + } + } + + /** + * Writes a file synchronously. + * @param {string} path The file path + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + */ + writeFileSync(path, data, options) { + if (this.readonly) { + throw createEROFS('open', path); + } + const handle = this.openSync(path, 'w', options?.mode); + try { + handle.writeFileSync(data, options); + } finally { + handle.closeSync(); + } + } + + /** + * Appends to a file. + * @param {string} path The file path + * @param {Buffer|string} data The data to append + * @param {object} [options] Options + * @returns {Promise} + */ + async appendFile(path, data, options) { + if (this.readonly) { + throw createEROFS('open', path); + } + const handle = await this.open(path, 'a', options?.mode); + try { + await handle.writeFile(data, options); + } finally { + await handle.close(); + } + } + + /** + * Appends to a file synchronously. + * @param {string} path The file path + * @param {Buffer|string} data The data to append + * @param {object} [options] Options + */ + appendFileSync(path, data, options) { + if (this.readonly) { + throw createEROFS('open', path); + } + const handle = this.openSync(path, 'a', options?.mode); + try { + handle.writeFileSync(data, options); + } finally { + handle.closeSync(); + } + } + + /** + * Checks if a path exists. + * @param {string} path The path to check + * @returns {Promise} + */ + async exists(path) { + try { + await this.stat(path); + return true; + } catch { + return false; + } + } + + /** + * Checks if a path exists synchronously. + * @param {string} path The path to check + * @returns {boolean} + */ + existsSync(path) { + try { + this.statSync(path); + return true; + } catch { + return false; + } + } + + /** + * Copies a file. + * @param {string} src Source path + * @param {string} dest Destination path + * @param {number} [mode] Copy mode flags + * @returns {Promise} + */ + async copyFile(src, dest, mode) { + if (this.readonly) { + throw createEROFS('copyfile', dest); + } + const content = await this.readFile(src); + await this.writeFile(dest, content); + } + + /** + * Copies a file synchronously. + * @param {string} src Source path + * @param {string} dest Destination path + * @param {number} [mode] Copy mode flags + */ + copyFileSync(src, dest, mode) { + if (this.readonly) { + throw createEROFS('copyfile', dest); + } + const content = this.readFileSync(src); + this.writeFileSync(dest, content); + } + + /** + * Returns the stat result code for module resolution. + * Used by Module._stat override. + * @param {string} path The path to check + * @returns {number} 0 for file, 1 for directory, -2 for not found + */ + internalModuleStat(path) { + try { + const stats = this.statSync(path); + if (stats.isDirectory()) { + return 1; + } + return 0; + } catch { + return -2; // ENOENT + } + } + + /** + * Gets the real path by resolving symlinks. + * @param {string} path The path + * @param {object} [options] Options + * @returns {Promise} + */ + async realpath(path, options) { + // Default: return the path as-is (for providers without symlinks) + // First verify the path exists + await this.stat(path); + return path; + } + + /** + * Gets the real path synchronously. + * @param {string} path The path + * @param {object} [options] Options + * @returns {string} + */ + realpathSync(path, options) { + // Default: return the path as-is (for providers without symlinks) + // First verify the path exists + this.statSync(path); + return path; + } + + /** + * Checks file accessibility. + * @param {string} path The path to check + * @param {number} [mode] Access mode + * @returns {Promise} + */ + async access(path, mode) { + // Default: just check if the path exists + await this.stat(path); + } + + /** + * Checks file accessibility synchronously. + * @param {string} path The path to check + * @param {number} [mode] Access mode + */ + accessSync(path, mode) { + // Default: just check if the path exists + this.statSync(path); + } + + // === SYMLINK OPERATIONS (optional, throw ENOENT by default) === + + /** + * Reads the target of a symbolic link. + * @param {string} path The symlink path + * @param {object} [options] Options + * @returns {Promise} + */ + async readlink(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('readlink'); + } + + /** + * Reads the target of a symbolic link synchronously. + * @param {string} path The symlink path + * @param {object} [options] Options + * @returns {string} + */ + readlinkSync(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('readlinkSync'); + } + + /** + * Creates a symbolic link. + * @param {string} target The symlink target + * @param {string} path The symlink path + * @param {string} [type] The symlink type (file, dir, junction) + * @returns {Promise} + */ + async symlink(target, path, type) { + if (this.readonly) { + throw createEROFS('symlink', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('symlink'); + } + + /** + * Creates a symbolic link synchronously. + * @param {string} target The symlink target + * @param {string} path The symlink path + * @param {string} [type] The symlink type (file, dir, junction) + */ + symlinkSync(target, path, type) { + if (this.readonly) { + throw createEROFS('symlink', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('symlinkSync'); + } +} + +module.exports = { + VirtualProvider, +}; diff --git a/lib/internal/vfs/providers/memory.js b/lib/internal/vfs/providers/memory.js new file mode 100644 index 00000000000000..487a1b4d755fbf --- /dev/null +++ b/lib/internal/vfs/providers/memory.js @@ -0,0 +1,766 @@ +'use strict'; + +const { + ArrayPrototypePush, + SafeMap, + Symbol, +} = primordials; + +const { Buffer } = require('buffer'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { MemoryFileHandle } = require('internal/vfs/file_handle'); +const { + createENOENT, + createENOTDIR, + createEISDIR, + createEEXIST, + createEINVAL, + createELOOP, +} = require('internal/vfs/errors'); +const { + createFileStats, + createDirectoryStats, + createSymlinkStats, +} = require('internal/vfs/stats'); +const { Dirent } = require('internal/fs/utils'); +const { + fs: { + UV_DIRENT_FILE, + UV_DIRENT_DIR, + UV_DIRENT_LINK, + }, +} = internalBinding('constants'); + +// Private symbols +const kEntries = Symbol('kEntries'); +const kRoot = Symbol('kRoot'); + +// Entry types +const TYPE_FILE = 0; +const TYPE_DIR = 1; +const TYPE_SYMLINK = 2; + +// Maximum symlink resolution depth +const kMaxSymlinkDepth = 40; + +/** + * Internal entry representation for MemoryProvider. + */ +class MemoryEntry { + constructor(type, options = {}) { + this.type = type; + this.mode = options.mode ?? (type === TYPE_DIR ? 0o755 : 0o644); + this.content = null; // For files - static Buffer content + this.contentProvider = null; // For files - dynamic content function + this.target = null; // For symlinks + this.children = null; // For directories + this.populate = null; // For directories - lazy population callback + this.populated = true; // For directories - has populate been called? + this.mtime = Date.now(); + this.ctime = Date.now(); + this.birthtime = Date.now(); + } + + /** + * Gets the file content synchronously. + * Throws if the content provider returns a Promise. + * @returns {Buffer} The file content + */ + getContentSync() { + if (this.contentProvider !== null) { + const result = this.contentProvider(); + if (result && typeof result.then === 'function') { + // It's a Promise - can't use sync API + const { ERR_INVALID_STATE } = require('internal/errors').codes; + throw new ERR_INVALID_STATE('cannot use sync API with async content provider'); + } + return typeof result === 'string' ? Buffer.from(result) : result; + } + return this.content; + } + + /** + * Gets the file content asynchronously. + * @returns {Promise} The file content + */ + async getContentAsync() { + if (this.contentProvider !== null) { + const result = await this.contentProvider(); + return typeof result === 'string' ? Buffer.from(result) : result; + } + return this.content; + } + + /** + * Gets the file content (sync version for backward compat). + * @returns {Buffer} The file content + */ + getContent() { + return this.getContentSync(); + } + + /** + * Returns true if this file has a dynamic content provider. + * @returns {boolean} + */ + isDynamic() { + return this.contentProvider !== null; + } + + isFile() { + return this.type === TYPE_FILE; + } + + isDirectory() { + return this.type === TYPE_DIR; + } + + isSymbolicLink() { + return this.type === TYPE_SYMLINK; + } +} + +/** + * In-memory filesystem provider. + * Supports full read/write operations. + */ +class MemoryProvider extends VirtualProvider { + constructor() { + super(); + // Root directory + this[kRoot] = new MemoryEntry(TYPE_DIR); + this[kRoot].children = new SafeMap(); + } + + get readonly() { + return false; + } + + get supportsSymlinks() { + return true; + } + + /** + * Normalizes a path to use forward slashes, removes trailing slash, + * and resolves . and .. components. + * @param {string} path The path to normalize + * @returns {string} Normalized path + */ + _normalizePath(path) { + // Normalize slashes + let normalized = path.replace(/\\/g, '/'); + if (!normalized.startsWith('/')) { + normalized = '/' + normalized; + } + + // Split into segments and resolve . and .. + const segments = normalized.split('/').filter(s => s !== '' && s !== '.'); + const resolved = []; + for (const segment of segments) { + if (segment === '..') { + // Go up one level (but don't go above root) + if (resolved.length > 0) { + resolved.pop(); + } + } else { + resolved.push(segment); + } + } + + return '/' + resolved.join('/'); + } + + /** + * Splits a path into segments. + * @param {string} path Normalized path + * @returns {string[]} Path segments + */ + _splitPath(path) { + if (path === '/') { + return []; + } + return path.slice(1).split('/'); + } + + /** + * Gets the parent path. + * @param {string} path Normalized path + * @returns {string|null} Parent path or null for root + */ + _getParentPath(path) { + if (path === '/') { + return null; + } + const lastSlash = path.lastIndexOf('/'); + if (lastSlash === 0) { + return '/'; + } + return path.slice(0, lastSlash); + } + + /** + * Gets the base name. + * @param {string} path Normalized path + * @returns {string} Base name + */ + _getBaseName(path) { + const lastSlash = path.lastIndexOf('/'); + return path.slice(lastSlash + 1); + } + + /** + * Resolves a symlink target to an absolute path. + * @param {string} symlinkPath The path of the symlink + * @param {string} target The symlink target + * @returns {string} Resolved absolute path + */ + _resolveSymlinkTarget(symlinkPath, target) { + if (target.startsWith('/')) { + return this._normalizePath(target); + } + // Relative target: resolve against symlink's parent directory + const parentPath = this._getParentPath(symlinkPath); + if (parentPath === null) { + return this._normalizePath('/' + target); + } + return this._normalizePath(parentPath + '/' + target); + } + + /** + * Looks up an entry by path, optionally following symlinks. + * @param {string} path The path to look up + * @param {boolean} followSymlinks Whether to follow symlinks + * @param {number} depth Current symlink resolution depth + * @returns {{ entry: MemoryEntry|null, resolvedPath: string|null, eloop?: boolean }} + */ + _lookupEntry(path, followSymlinks = true, depth = 0) { + const normalized = this._normalizePath(path); + + if (normalized === '/') { + return { entry: this[kRoot], resolvedPath: '/' }; + } + + const segments = this._splitPath(normalized); + let current = this[kRoot]; + let currentPath = ''; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + // Follow symlinks for intermediate path components + if (current.isSymbolicLink() && followSymlinks) { + if (depth >= kMaxSymlinkDepth) { + return { entry: null, resolvedPath: null, eloop: true }; + } + const targetPath = this._resolveSymlinkTarget(currentPath, current.target); + const result = this._lookupEntry(targetPath, true, depth + 1); + if (result.eloop) { + return result; + } + if (!result.entry) { + return { entry: null, resolvedPath: null }; + } + current = result.entry; + currentPath = result.resolvedPath; + } + + if (!current.isDirectory()) { + return { entry: null, resolvedPath: null }; + } + + // Ensure directory is populated before accessing children + this._ensurePopulated(current, currentPath || '/'); + + const entry = current.children.get(segment); + if (!entry) { + return { entry: null, resolvedPath: null }; + } + + currentPath = currentPath + '/' + segment; + current = entry; + } + + // Follow symlink at the end if requested + if (current.isSymbolicLink() && followSymlinks) { + if (depth >= kMaxSymlinkDepth) { + return { entry: null, resolvedPath: null, eloop: true }; + } + const targetPath = this._resolveSymlinkTarget(currentPath, current.target); + return this._lookupEntry(targetPath, true, depth + 1); + } + + return { entry: current, resolvedPath: currentPath }; + } + + /** + * Gets an entry by path, throwing if not found. + * @param {string} path The path + * @param {string} syscall The syscall name for error + * @param {boolean} followSymlinks Whether to follow symlinks + * @returns {MemoryEntry} + */ + _getEntry(path, syscall, followSymlinks = true) { + const result = this._lookupEntry(path, followSymlinks); + if (result.eloop) { + throw createELOOP(syscall, path); + } + if (!result.entry) { + throw createENOENT(syscall, path); + } + return result.entry; + } + + /** + * Ensures parent directories exist, optionally creating them. + * @param {string} path The full path + * @param {boolean} create Whether to create missing directories + * @param {string} syscall The syscall name for errors + * @returns {MemoryEntry} The parent directory entry + */ + _ensureParent(path, create, syscall) { + const parentPath = this._getParentPath(path); + if (parentPath === null) { + return this[kRoot]; + } + + const segments = this._splitPath(parentPath); + let current = this[kRoot]; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + // Follow symlinks in parent path + if (current.isSymbolicLink()) { + const currentPath = '/' + segments.slice(0, i).join('/'); + const targetPath = this._resolveSymlinkTarget(currentPath, current.target); + const result = this._lookupEntry(targetPath, true, 0); + if (!result.entry) { + throw createENOENT(syscall, path); + } + current = result.entry; + } + + if (!current.isDirectory()) { + throw createENOTDIR(syscall, path); + } + + // Ensure directory is populated before accessing children + const currentPath = '/' + segments.slice(0, i).join('/'); + this._ensurePopulated(current, currentPath || '/'); + + let entry = current.children.get(segment); + if (!entry) { + if (create) { + entry = new MemoryEntry(TYPE_DIR); + entry.children = new SafeMap(); + current.children.set(segment, entry); + } else { + throw createENOENT(syscall, path); + } + } + current = entry; + } + + if (!current.isDirectory()) { + throw createENOTDIR(syscall, path); + } + + // Ensure final directory is populated + const finalPath = '/' + segments.join('/'); + this._ensurePopulated(current, finalPath); + + return current; + } + + /** + * Creates stats for an entry. + * @param {MemoryEntry} entry The entry + * @param {number} [size] Override size for files + * @returns {Stats} + */ + _createStats(entry, size) { + const options = { + mode: entry.mode, + mtimeMs: entry.mtime, + ctimeMs: entry.ctime, + birthtimeMs: entry.birthtime, + }; + + if (entry.isFile()) { + return createFileStats(size !== undefined ? size : entry.content.length, options); + } else if (entry.isDirectory()) { + return createDirectoryStats(options); + } else if (entry.isSymbolicLink()) { + return createSymlinkStats(entry.target.length, options); + } + + throw new Error('Unknown entry type'); + } + + /** + * Ensures a directory is populated by calling its populate callback if needed. + * @param {MemoryEntry} entry The directory entry + * @param {string} path The directory path (for error messages and scoped VFS) + */ + _ensurePopulated(entry, path) { + if (entry.isDirectory() && !entry.populated && entry.populate) { + // Create a scoped VFS for the populate callback + const scopedVfs = { + addFile: (name, content, opts) => { + const fullPath = path + '/' + name; + if (typeof content === 'function') { + this.setContentProvider(fullPath, content); + } else { + // Create file entry directly + const fileEntry = new MemoryEntry(TYPE_FILE, opts); + fileEntry.content = typeof content === 'string' ? Buffer.from(content) : content; + entry.children.set(name, fileEntry); + } + }, + addDirectory: (name, populate, opts) => { + const fullPath = path + '/' + name; + const dirEntry = new MemoryEntry(TYPE_DIR, opts); + dirEntry.children = new SafeMap(); + if (typeof populate === 'function') { + dirEntry.populate = populate; + dirEntry.populated = false; + } + entry.children.set(name, dirEntry); + }, + addSymlink: (name, target, opts) => { + const symlinkEntry = new MemoryEntry(TYPE_SYMLINK, opts); + symlinkEntry.target = target; + entry.children.set(name, symlinkEntry); + }, + }; + entry.populate(scopedVfs); + entry.populated = true; + } + } + + // === ESSENTIAL PRIMITIVES === + + openSync(path, flags, mode) { + const normalized = this._normalizePath(path); + + // Handle create modes + const isCreate = flags === 'w' || flags === 'w+' || flags === 'a' || flags === 'a+'; + const isWrite = isCreate || flags === 'r+'; + + let entry; + try { + entry = this._getEntry(normalized, 'open'); + } catch (err) { + if (err.code === 'ENOENT' && isCreate) { + // Create the file + const parent = this._ensureParent(normalized, true, 'open'); + const name = this._getBaseName(normalized); + entry = new MemoryEntry(TYPE_FILE, { mode }); + entry.content = Buffer.alloc(0); + parent.children.set(name, entry); + } else { + throw err; + } + } + + if (entry.isDirectory()) { + throw createEISDIR('open', path); + } + + if (entry.isSymbolicLink()) { + // Should have been resolved already, but just in case + throw createEINVAL('open', path); + } + + const getStats = (size) => this._createStats(entry, size); + return new MemoryFileHandle(normalized, flags, mode ?? entry.mode, entry.content, entry, getStats); + } + + async open(path, flags, mode) { + return this.openSync(path, flags, mode); + } + + statSync(path, options) { + const entry = this._getEntry(path, 'stat', true); + return this._createStats(entry); + } + + async stat(path, options) { + return this.statSync(path, options); + } + + lstatSync(path, options) { + const entry = this._getEntry(path, 'lstat', false); + return this._createStats(entry); + } + + async lstat(path, options) { + return this.lstatSync(path, options); + } + + readdirSync(path, options) { + const entry = this._getEntry(path, 'scandir', true); + if (!entry.isDirectory()) { + throw createENOTDIR('scandir', path); + } + + // Ensure directory is populated (for lazy population) + this._ensurePopulated(entry, path); + + const names = [...entry.children.keys()]; + + if (options?.withFileTypes) { + const normalized = this._normalizePath(path); + const dirents = []; + for (const name of names) { + const childEntry = entry.children.get(name); + let type; + if (childEntry.isSymbolicLink()) { + type = UV_DIRENT_LINK; + } else if (childEntry.isDirectory()) { + type = UV_DIRENT_DIR; + } else { + type = UV_DIRENT_FILE; + } + ArrayPrototypePush(dirents, new Dirent(name, type, normalized)); + } + return dirents; + } + + return names; + } + + async readdir(path, options) { + return this.readdirSync(path, options); + } + + mkdirSync(path, options) { + const normalized = this._normalizePath(path); + const recursive = options?.recursive === true; + + // Check if already exists + const existing = this._lookupEntry(normalized, true); + if (existing.entry) { + if (existing.entry.isDirectory() && recursive) { + // Already exists, that's ok for recursive + return undefined; + } + throw createEEXIST('mkdir', path); + } + + if (recursive) { + // Create all parent directories + const segments = this._splitPath(normalized); + let current = this[kRoot]; + let currentPath = ''; + + for (const segment of segments) { + currentPath = currentPath + '/' + segment; + let entry = current.children.get(segment); + if (!entry) { + entry = new MemoryEntry(TYPE_DIR, { mode: options?.mode }); + entry.children = new SafeMap(); + current.children.set(segment, entry); + } else if (!entry.isDirectory()) { + throw createENOTDIR('mkdir', path); + } + current = entry; + } + } else { + const parent = this._ensureParent(normalized, false, 'mkdir'); + const name = this._getBaseName(normalized); + const entry = new MemoryEntry(TYPE_DIR, { mode: options?.mode }); + entry.children = new SafeMap(); + parent.children.set(name, entry); + } + + return recursive ? normalized : undefined; + } + + async mkdir(path, options) { + return this.mkdirSync(path, options); + } + + rmdirSync(path) { + const normalized = this._normalizePath(path); + const entry = this._getEntry(normalized, 'rmdir', true); + + if (!entry.isDirectory()) { + throw createENOTDIR('rmdir', path); + } + + if (entry.children.size > 0) { + const err = new Error(`ENOTEMPTY: directory not empty, rmdir '${path}'`); + err.code = 'ENOTEMPTY'; + err.syscall = 'rmdir'; + err.path = path; + throw err; + } + + const parent = this._ensureParent(normalized, false, 'rmdir'); + const name = this._getBaseName(normalized); + parent.children.delete(name); + } + + async rmdir(path) { + this.rmdirSync(path); + } + + unlinkSync(path) { + const normalized = this._normalizePath(path); + const entry = this._getEntry(normalized, 'unlink', false); + + if (entry.isDirectory()) { + throw createEISDIR('unlink', path); + } + + const parent = this._ensureParent(normalized, false, 'unlink'); + const name = this._getBaseName(normalized); + parent.children.delete(name); + } + + async unlink(path) { + this.unlinkSync(path); + } + + renameSync(oldPath, newPath) { + const normalizedOld = this._normalizePath(oldPath); + const normalizedNew = this._normalizePath(newPath); + + // Get the entry (without following symlinks for the entry itself) + const entry = this._getEntry(normalizedOld, 'rename', false); + + // Remove from old location + const oldParent = this._ensureParent(normalizedOld, false, 'rename'); + const oldName = this._getBaseName(normalizedOld); + oldParent.children.delete(oldName); + + // Add to new location + const newParent = this._ensureParent(normalizedNew, true, 'rename'); + const newName = this._getBaseName(normalizedNew); + newParent.children.set(newName, entry); + } + + async rename(oldPath, newPath) { + this.renameSync(oldPath, newPath); + } + + // === SYMLINK OPERATIONS === + + readlinkSync(path, options) { + const normalized = this._normalizePath(path); + const entry = this._getEntry(normalized, 'readlink', false); + + if (!entry.isSymbolicLink()) { + throw createEINVAL('readlink', path); + } + + return entry.target; + } + + async readlink(path, options) { + return this.readlinkSync(path, options); + } + + symlinkSync(target, path, type) { + const normalized = this._normalizePath(path); + + // Check if already exists + const existing = this._lookupEntry(normalized, false); + if (existing.entry) { + throw createEEXIST('symlink', path); + } + + const parent = this._ensureParent(normalized, true, 'symlink'); + const name = this._getBaseName(normalized); + const entry = new MemoryEntry(TYPE_SYMLINK); + entry.target = target; + parent.children.set(name, entry); + } + + async symlink(target, path, type) { + this.symlinkSync(target, path, type); + } + + // === REALPATH === + + realpathSync(path, options) { + const result = this._lookupEntry(path, true, 0); + if (result.eloop) { + throw createELOOP('realpath', path); + } + if (!result.entry) { + throw createENOENT('realpath', path); + } + return result.resolvedPath; + } + + async realpath(path, options) { + return this.realpathSync(path, options); + } + + // === DYNAMIC CONTENT === + + /** + * Sets a dynamic content provider for a file. + * The provider function will be called on each read. + * @param {string} path The file path + * @param {Function} contentProvider Function that returns Buffer or string content + */ + setContentProvider(path, contentProvider) { + const normalized = this._normalizePath(path); + + // Ensure parent directories exist and get/create the entry + const parent = this._ensureParent(normalized, true, 'setContentProvider'); + const name = this._getBaseName(normalized); + + let entry = parent.children.get(name); + if (!entry) { + // Create a new file entry + entry = new MemoryEntry(TYPE_FILE); + entry.content = Buffer.alloc(0); // Placeholder + parent.children.set(name, entry); + } + + if (!entry.isFile()) { + throw createEISDIR('setContentProvider', path); + } + + // Set the content provider + entry.contentProvider = contentProvider; + } + + /** + * Sets a lazy populate callback for a directory. + * The callback will be called on first access (readdir, stat child, etc.). + * @param {string} path The directory path + * @param {Function} populateCallback Function that takes a scoped VFS object + */ + setPopulateCallback(path, populateCallback) { + const normalized = this._normalizePath(path); + + // Ensure parent directories exist and get/create the entry + const parent = this._ensureParent(normalized, true, 'setPopulateCallback'); + const name = this._getBaseName(normalized); + + let entry = parent.children.get(name); + if (!entry) { + // Create a new directory entry + entry = new MemoryEntry(TYPE_DIR); + entry.children = new SafeMap(); + parent.children.set(name, entry); + } + + if (!entry.isDirectory()) { + throw createENOTDIR('setPopulateCallback', path); + } + + // Set the populate callback (will be called lazily) + entry.populate = populateCallback; + entry.populated = false; + } +} + +module.exports = { + MemoryProvider, +}; diff --git a/lib/internal/vfs/providers/sea.js b/lib/internal/vfs/providers/sea.js new file mode 100644 index 00000000000000..983933e7651c94 --- /dev/null +++ b/lib/internal/vfs/providers/sea.js @@ -0,0 +1,429 @@ +'use strict'; + +const { + ArrayPrototypePush, + SafeMap, + SafeSet, + StringPrototypeStartsWith, + Symbol, +} = primordials; + +const { Buffer } = require('buffer'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { VirtualFileHandle } = require('internal/vfs/file_handle'); +const { + createENOENT, + createENOTDIR, + createEISDIR, + createEROFS, +} = require('internal/vfs/errors'); +const { + createFileStats, + createDirectoryStats, +} = require('internal/vfs/stats'); +const { Dirent } = require('internal/fs/utils'); +const { + fs: { + UV_DIRENT_FILE, + UV_DIRENT_DIR, + }, +} = internalBinding('constants'); + +// Private symbols +const kAssets = Symbol('kAssets'); +const kDirectories = Symbol('kDirectories'); +const kGetAsset = Symbol('kGetAsset'); + +/** + * File handle for SEA assets (read-only). + */ +class SEAFileHandle extends VirtualFileHandle { + #content; + #getStats; + + /** + * @param {string} path The file path + * @param {Buffer} content The file content + * @param {Function} getStats Function to get stats + */ + constructor(path, content, getStats) { + super(path, 'r', 0o444); + this.#content = content; + this.#getStats = getStats; + } + + readSync(buffer, offset, length, position) { + this._checkClosed(); + + const readPos = position !== null && position !== undefined ? position : this.position; + const available = this.#content.length - readPos; + + if (available <= 0) { + return 0; + } + + const { MathMin } = primordials; + const bytesToRead = MathMin(length, available); + this.#content.copy(buffer, offset, readPos, readPos + bytesToRead); + + if (position === null || position === undefined) { + this.position = readPos + bytesToRead; + } + + return bytesToRead; + } + + async read(buffer, offset, length, position) { + const bytesRead = this.readSync(buffer, offset, length, position); + return { bytesRead, buffer }; + } + + readFileSync(options) { + this._checkClosed(); + + const encoding = typeof options === 'string' ? options : options?.encoding; + if (encoding) { + return this.#content.toString(encoding); + } + return Buffer.from(this.#content); + } + + async readFile(options) { + return this.readFileSync(options); + } + + writeSync() { + throw createEROFS('write', this.path); + } + + async write() { + throw createEROFS('write', this.path); + } + + writeFileSync() { + throw createEROFS('write', this.path); + } + + async writeFile() { + throw createEROFS('write', this.path); + } + + truncateSync() { + throw createEROFS('ftruncate', this.path); + } + + async truncate() { + throw createEROFS('ftruncate', this.path); + } + + statSync(options) { + this._checkClosed(); + return this.#getStats(); + } + + async stat(options) { + return this.statSync(options); + } +} + +/** + * Read-only provider for Single Executable Application (SEA) assets. + * Assets are accessed via sea.getAsset() binding. + */ +class SEAProvider extends VirtualProvider { + /** + * @param {object} [options] Options + */ + constructor(options = {}) { + super(); + + // Lazy-load SEA bindings + const { isSea, getAsset, getAssetKeys } = internalBinding('sea'); + + if (!isSea()) { + throw new Error('SEAProvider can only be used in a Single Executable Application'); + } + + this[kGetAsset] = getAsset; + + // Build asset map and derive directory structure + this[kAssets] = new SafeMap(); + this[kDirectories] = new SafeMap(); + + // Root directory always exists + this[kDirectories].set('/', new SafeSet()); + + const keys = getAssetKeys() || []; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + // Normalize key to path + const path = StringPrototypeStartsWith(key, '/') ? key : `/${key}`; + this[kAssets].set(path, key); + + // Derive parent directories + const parts = path.split('/').filter(Boolean); + let currentPath = ''; + for (let j = 0; j < parts.length - 1; j++) { + const parentPath = currentPath || '/'; + currentPath = currentPath + '/' + parts[j]; + + if (!this[kDirectories].has(currentPath)) { + this[kDirectories].set(currentPath, new SafeSet()); + } + + // Add this directory to parent's children + const parentChildren = this[kDirectories].get(parentPath); + if (parentChildren) { + parentChildren.add(parts[j]); + } + } + + // Add file to parent directory's children + if (parts.length > 0) { + const fileName = parts[parts.length - 1]; + const parentPath = parts.length === 1 ? '/' : '/' + parts.slice(0, -1).join('/'); + + if (!this[kDirectories].has(parentPath)) { + this[kDirectories].set(parentPath, new SafeSet()); + } + + this[kDirectories].get(parentPath).add(fileName); + } + } + } + + get readonly() { + return true; + } + + get supportsSymlinks() { + return false; + } + + /** + * Normalizes a path. + * @param {string} path The path + * @returns {string} Normalized path + */ + _normalizePath(path) { + let normalized = path.replace(/\\/g, '/'); + if (normalized !== '/' && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + if (!normalized.startsWith('/')) { + normalized = '/' + normalized; + } + return normalized; + } + + /** + * Checks if a path is a file. + * @param {string} path Normalized path + * @returns {boolean} + */ + _isFile(path) { + return this[kAssets].has(path); + } + + /** + * Checks if a path is a directory. + * @param {string} path Normalized path + * @returns {boolean} + */ + _isDirectory(path) { + return this[kDirectories].has(path); + } + + /** + * Gets the asset content. + * @param {string} path Normalized path + * @returns {Buffer} + */ + _getAssetContent(path) { + const key = this[kAssets].get(path); + if (!key) { + throw createENOENT('open', path); + } + const content = this[kGetAsset](key); + return Buffer.from(content); + } + + // === ESSENTIAL PRIMITIVES === + + openSync(path, flags, mode) { + // Only allow read modes + if (flags !== 'r') { + throw createEROFS('open', path); + } + + const normalized = this._normalizePath(path); + + if (this._isDirectory(normalized)) { + throw createEISDIR('open', path); + } + + if (!this._isFile(normalized)) { + throw createENOENT('open', path); + } + + const content = this._getAssetContent(normalized); + const getStats = () => createFileStats(content.length, { mode: 0o444 }); + + return new SEAFileHandle(normalized, content, getStats); + } + + async open(path, flags, mode) { + return this.openSync(path, flags, mode); + } + + statSync(path, options) { + const normalized = this._normalizePath(path); + + if (this._isDirectory(normalized)) { + return createDirectoryStats({ mode: 0o555 }); + } + + if (this._isFile(normalized)) { + const content = this._getAssetContent(normalized); + return createFileStats(content.length, { mode: 0o444 }); + } + + throw createENOENT('stat', path); + } + + async stat(path, options) { + return this.statSync(path, options); + } + + lstatSync(path, options) { + // No symlinks, same as stat + return this.statSync(path, options); + } + + async lstat(path, options) { + return this.lstatSync(path, options); + } + + readdirSync(path, options) { + const normalized = this._normalizePath(path); + + if (!this._isDirectory(normalized)) { + if (this._isFile(normalized)) { + throw createENOTDIR('scandir', path); + } + throw createENOENT('scandir', path); + } + + const children = this[kDirectories].get(normalized); + const names = [...children]; + + if (options?.withFileTypes) { + const dirents = []; + for (const name of names) { + const childPath = normalized === '/' ? `/${name}` : `${normalized}/${name}`; + let type; + if (this._isDirectory(childPath)) { + type = UV_DIRENT_DIR; + } else { + type = UV_DIRENT_FILE; + } + ArrayPrototypePush(dirents, new Dirent(name, type, normalized)); + } + return dirents; + } + + return names; + } + + async readdir(path, options) { + return this.readdirSync(path, options); + } + + // === WRITE OPERATIONS (all throw EROFS) === + + mkdirSync(path, options) { + throw createEROFS('mkdir', path); + } + + async mkdir(path, options) { + throw createEROFS('mkdir', path); + } + + rmdirSync(path) { + throw createEROFS('rmdir', path); + } + + async rmdir(path) { + throw createEROFS('rmdir', path); + } + + unlinkSync(path) { + throw createEROFS('unlink', path); + } + + async unlink(path) { + throw createEROFS('unlink', path); + } + + renameSync(oldPath, newPath) { + throw createEROFS('rename', oldPath); + } + + async rename(oldPath, newPath) { + throw createEROFS('rename', oldPath); + } + + // === DEFAULT IMPLEMENTATIONS (read-only overrides) === + + readFileSync(path, options) { + const normalized = this._normalizePath(path); + + if (this._isDirectory(normalized)) { + throw createEISDIR('read', path); + } + + if (!this._isFile(normalized)) { + throw createENOENT('open', path); + } + + const content = this._getAssetContent(normalized); + + const encoding = typeof options === 'string' ? options : options?.encoding; + if (encoding) { + return content.toString(encoding); + } + return content; + } + + async readFile(path, options) { + return this.readFileSync(path, options); + } + + writeFileSync(path, data, options) { + throw createEROFS('open', path); + } + + async writeFile(path, data, options) { + throw createEROFS('open', path); + } + + appendFileSync(path, data, options) { + throw createEROFS('open', path); + } + + async appendFile(path, data, options) { + throw createEROFS('open', path); + } + + copyFileSync(src, dest, mode) { + throw createEROFS('copyfile', dest); + } + + async copyFile(src, dest, mode) { + throw createEROFS('copyfile', dest); + } +} + +module.exports = { + SEAProvider, +}; diff --git a/lib/internal/vfs/sea.js b/lib/internal/vfs/sea.js index f99da97fd8a417..a5a2c80f3c14e5 100644 --- a/lib/internal/vfs/sea.js +++ b/lib/internal/vfs/sea.js @@ -1,31 +1,17 @@ 'use strict'; -const { - StringPrototypeStartsWith, -} = primordials; - -const { Buffer } = require('buffer'); -const { isSea, getAsset: getAssetInternal, getAssetKeys: getAssetKeysInternal } = internalBinding('sea'); +const { isSea } = internalBinding('sea'); const { kEmptyObject } = require('internal/util'); -// Wrapper to get asset as ArrayBuffer (same as public sea.getAsset without encoding) -function getAsset(key) { - return getAssetInternal(key); -} - -// Wrapper to get asset keys -function getAssetKeys() { - return getAssetKeysInternal() || []; -} - // Lazy-loaded VFS let cachedSeaVfs = null; -// Lazy-load VirtualFileSystem to avoid loading VFS code if not needed +// Lazy-load VirtualFileSystem and SEAProvider to avoid loading VFS code if not needed let VirtualFileSystem; +let SEAProvider; /** - * Creates a VirtualFileSystem populated with SEA assets. + * Creates a VirtualFileSystem populated with SEA assets using the new Provider architecture. * Assets are mounted at the specified prefix (default: '/sea'). * @param {object} [options] Configuration options * @param {string} [options.prefix] Mount point prefix for SEA assets @@ -37,24 +23,14 @@ function createSeaVfs(options = kEmptyObject) { return null; } - VirtualFileSystem ??= require('internal/vfs/virtual_fs').VirtualFileSystem; + VirtualFileSystem ??= require('internal/vfs/file_system').VirtualFileSystem; + SEAProvider ??= require('internal/vfs/providers/sea').SEAProvider; + const prefix = options.prefix ?? '/sea'; const moduleHooks = options.moduleHooks !== false; - const vfs = new VirtualFileSystem({ moduleHooks }); - - // Get all asset keys and populate VFS - const keys = getAssetKeys(); - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - // Get asset content as ArrayBuffer and convert to Buffer - const content = getAsset(key); - const buffer = Buffer.from(content); - - // Determine the path - if key starts with /, use as-is, otherwise prepend / - const path = StringPrototypeStartsWith(key, '/') ? key : `/${key}`; - vfs.addFile(path, buffer); - } + const provider = new SEAProvider(); + const vfs = new VirtualFileSystem(provider, { moduleHooks }); // Mount at the specified prefix vfs.mount(prefix); @@ -83,7 +59,8 @@ function hasSeaAssets() { if (!isSea()) { return false; } - const keys = getAssetKeys(); + const { getAssetKeys } = internalBinding('sea'); + const keys = getAssetKeys() || []; return keys.length > 0; } diff --git a/lib/internal/vfs/streams.js b/lib/internal/vfs/streams.js index 71f6c234c6b31d..a07fd04bceb92a 100644 --- a/lib/internal/vfs/streams.js +++ b/lib/internal/vfs/streams.js @@ -83,7 +83,8 @@ class VirtualReadStream extends Readable { this.destroy(createEBADF('read')); return; } - this._content = vfd.getContentSync(); + // Use the file handle's readFileSync to get content + this._content = vfd.entry.readFileSync(); } catch (err) { this.destroy(err); return; diff --git a/lib/vfs.js b/lib/vfs.js new file mode 100644 index 00000000000000..4110fc983461f9 --- /dev/null +++ b/lib/vfs.js @@ -0,0 +1,78 @@ +'use strict'; + +const { VirtualFileSystem } = require('internal/vfs/file_system'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { MemoryProvider } = require('internal/vfs/providers/memory'); + +// SEAProvider is lazy-loaded to avoid loading SEA bindings when not needed +let SEAProvider = null; + +function getSEAProvider() { + if (SEAProvider === null) { + try { + SEAProvider = require('internal/vfs/providers/sea').SEAProvider; + } catch { + // SEA bindings not available (not running in SEA) + SEAProvider = class SEAProviderUnavailable { + constructor() { + throw new Error('SEAProvider can only be used in a Single Executable Application'); + } + }; + } + } + return SEAProvider; +} + +/** + * Creates a new VirtualFileSystem instance. + * @param {VirtualProvider} [provider] The provider to use (defaults to MemoryProvider) + * @param {object} [options] Configuration options + * @param {boolean} [options.moduleHooks] Whether to enable require/import hooks (default: true) + * @param {boolean} [options.virtualCwd] Whether to enable virtual working directory + * @returns {VirtualFileSystem} + */ +function create(provider, options) { + // Handle case where first arg is options (no provider) + if (provider !== undefined && provider !== null && + !(provider instanceof VirtualProvider) && + typeof provider === 'object') { + options = provider; + provider = undefined; + } + return new VirtualFileSystem(provider, options); +} + +/** + * Creates a VirtualFileSystem with SEA assets mounted. + * Only works when running as a Single Executable Application. + * @param {object} [options] Configuration options + * @param {string} [options.mountPoint] Mount point path (default: '/sea') + * @param {boolean} [options.moduleHooks] Whether to enable require/import hooks (default: true) + * @param {boolean} [options.virtualCwd] Whether to enable virtual working directory + * @returns {VirtualFileSystem|null} The VFS instance, or null if not running as SEA + */ +function createSEA(options = {}) { + const SEAProviderClass = getSEAProvider(); + try { + const provider = new SEAProviderClass(); + const vfs = new VirtualFileSystem(provider, { + moduleHooks: options.moduleHooks, + virtualCwd: options.virtualCwd, + }); + vfs.mount(options.mountPoint ?? '/sea'); + return vfs; + } catch { + return null; + } +} + +module.exports = { + create, + createSEA, + VirtualFileSystem, + VirtualProvider, + MemoryProvider, + get SEAProvider() { + return getSEAProvider(); + }, +}; From d2074414465bd0edc1575283cec0cdf00822135c Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 27 Jan 2026 09:32:12 +0100 Subject: [PATCH 07/32] vfs: remove backward compat methods, use standard fs API 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 --- doc/api/vfs.md | 251 ++++++++++++++----------- lib/internal/vfs/file_system.js | 174 +---------------- lib/internal/vfs/providers/memory.js | 13 +- lib/internal/vfs/router.js | 12 ++ test/parallel/test-vfs-basic.js | 69 +++---- test/parallel/test-vfs-chdir-worker.js | 9 +- test/parallel/test-vfs-chdir.js | 41 ++-- test/parallel/test-vfs-fd.js | 28 +-- test/parallel/test-vfs-glob.js | 69 +++---- test/parallel/test-vfs-import.mjs | 42 ++--- test/parallel/test-vfs-promises.js | 50 ++--- test/parallel/test-vfs-require.js | 50 ++--- test/parallel/test-vfs-streams.js | 24 +-- test/parallel/test-vfs-symlinks.js | 97 +++++----- 14 files changed, 377 insertions(+), 552 deletions(-) diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 5e9106abd071b1..332dcc369341f3 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -181,6 +181,29 @@ added: v26.0.0 Creates a new `VirtualFileSystem` instance. +### `vfs.provider` + + + +* {VirtualProvider} + +The underlying provider for this VFS instance. Can be used to access +provider-specific methods like `setContentProvider()` and `setPopulateCallback()` +for `MemoryProvider`. + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Access the provider for advanced features +myVfs.provider.setContentProvider('/dynamic.txt', () => { + return `Time: ${Date.now()}`; +}); +``` + ### `vfs.mount(prefix)` -* Returns: {boolean} +* {boolean} Returns `true` if the VFS is currently mounted. @@ -233,6 +256,16 @@ added: v26.0.0 The current mount point, or `null` if not mounted. +### `vfs.readonly` + + + +* {boolean} + +Returns `true` if the underlying provider is read-only. + ### `vfs.chdir(path)` - -* `path` {string} The file path. -* `content` {string | Buffer | Function} The file content or a function that - returns content. -* `options` {Object} Optional configuration. - -Adds a file to the VFS. If `content` is a function, it will be called each time -the file is read (dynamic content). - -```cjs -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); - -// Static content -myVfs.addFile('/static.txt', 'Static content'); - -// Dynamic content - function is called on each read -let counter = 0; -myVfs.addFile('/counter.txt', () => { - counter++; - return `Count: ${counter}`; -}); - -myVfs.mount('/v'); -const fs = require('node:fs'); -console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 1 -console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 2 -``` - -#### `vfs.addDirectory(path[, populate][, options])` - - - -* `path` {string} The directory path. -* `populate` {Function} Optional callback to lazily populate the directory. -* `options` {Object} Optional configuration. - -Adds a directory to the VFS. If `populate` is provided, it will be called -lazily when the directory is first accessed. - -```cjs -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); - -// Lazy directory - populated on first access -myVfs.addDirectory('/lazy', (dir) => { - dir.addFile('generated.txt', 'Generated on demand'); - dir.addDirectory('subdir', (subdir) => { - subdir.addFile('nested.txt', 'Nested content'); - }); -}); - -myVfs.mount('/v'); -const fs = require('node:fs'); - -// Directory is populated when first accessed -console.log(fs.readdirSync('/v/lazy')); // ['generated.txt', 'subdir'] -``` - -#### `vfs.addSymlink(path, target[, options])` - - - -* `path` {string} The symlink path. -* `target` {string} The symlink target (can be relative or absolute). -* `options` {Object} Optional configuration. - -Adds a symbolic link to the VFS. - -#### `vfs.has(path)` - - - -* `path` {string} The path to check. -* Returns: {boolean} - -Returns `true` if the path exists in the VFS. - -#### `vfs.remove(path)` - - - -* `path` {string} The path to remove. - -Removes a file or directory from the VFS. - ## Class: `VirtualProvider` + +Sets the provider to read-only mode. Once set to read-only, the provider +cannot be changed back to writable. This is useful for finalizing a VFS +after initial population. + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Populate the VFS +myVfs.mkdirSync('/app'); +myVfs.writeFileSync('/app/config.json', '{"readonly": true}'); + +// Make it read-only +myVfs.provider.setReadOnly(); + +// This would now throw an error +// myVfs.writeFileSync('/app/config.json', 'new content'); +``` + +### `memoryProvider.setContentProvider(path, provider)` + + + +* `path` {string} The file path. +* `provider` {Function} A function that returns the file content. + +Sets a dynamic content provider for a file. The provider function will be +called each time the file is read, allowing for dynamic content generation. + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Dynamic content - function is called on each read +let counter = 0; +myVfs.provider.setContentProvider('/counter.txt', () => { + counter++; + return `Count: ${counter}`; +}); + +myVfs.mount('/v'); +const fs = require('node:fs'); +console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 1 +console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 2 +``` + +The provider function can also be async: + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +myVfs.provider.setContentProvider('/async-data.txt', async () => { + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 100)); + return 'Async content'; +}); + +// Use promises API for async content providers +const content = await myVfs.promises.readFile('/async-data.txt', 'utf8'); +``` + +### `memoryProvider.setPopulateCallback(path, callback)` + + + +* `path` {string} The directory path. +* `callback` {Function} A function that populates the directory contents. + +Sets a lazy populate callback for a directory. The callback will be called +the first time the directory is accessed (e.g., via `readdirSync` or when +accessing a child path). + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Lazy directory - populated on first access +myVfs.provider.setPopulateCallback('/lazy', (dir) => { + dir.addFile('generated.txt', 'Generated on demand'); + dir.addDirectory('subdir', (subdir) => { + subdir.addFile('nested.txt', 'Nested content'); + }); +}); + +myVfs.mount('/v'); +const fs = require('node:fs'); + +// Directory is populated when first accessed +console.log(fs.readdirSync('/v/lazy')); // ['generated.txt', 'subdir'] +``` + +The callback receives a scoped VFS object with `addFile()`, `addDirectory()`, +and `addSymlink()` methods for populating the directory. + ## Class: `SEAProvider` - -* `path` {string} The file path. -* `provider` {Function} A function that returns the file content. - -Sets a dynamic content provider for a file. The provider function will be -called each time the file is read, allowing for dynamic content generation. - -```cjs -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); - -// Dynamic content - function is called on each read -let counter = 0; -myVfs.provider.setContentProvider('/counter.txt', () => { - counter++; - return `Count: ${counter}`; -}); - -myVfs.mount('/v'); -const fs = require('node:fs'); -console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 1 -console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 2 -``` - -The provider function can also be async: - -```cjs -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); - -myVfs.provider.setContentProvider('/async-data.txt', async () => { - // Simulate async operation - await new Promise(resolve => setTimeout(resolve, 100)); - return 'Async content'; -}); - -// Use promises API for async content providers -const content = await myVfs.promises.readFile('/async-data.txt', 'utf8'); -``` - -### `memoryProvider.setPopulateCallback(path, callback)` - - - -* `path` {string} The directory path. -* `callback` {Function} A function that populates the directory contents. - -Sets a lazy populate callback for a directory. The callback will be called -the first time the directory is accessed (e.g., via `readdirSync` or when -accessing a child path). - -```cjs -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); - -// Lazy directory - populated on first access -myVfs.provider.setPopulateCallback('/lazy', (dir) => { - dir.addFile('generated.txt', 'Generated on demand'); - dir.addDirectory('subdir', (subdir) => { - subdir.addFile('nested.txt', 'Nested content'); - }); -}); - -myVfs.mount('/v'); -const fs = require('node:fs'); - -// Directory is populated when first accessed -console.log(fs.readdirSync('/v/lazy')); // ['generated.txt', 'subdir'] -``` - -The callback receives a scoped VFS object with `addFile()`, `addDirectory()`, -and `addSymlink()` methods for populating the directory. - ## Class: `SEAProvider` - -* `options` {Object} - * `mountPoint` {string} The path prefix where SEA assets will be mounted. - **Default:** `'/sea'`. - * `moduleHooks` {boolean} Whether to enable module loading hooks. - **Default:** `true`. - * `virtualCwd` {boolean} Whether to enable virtual working directory. - **Default:** `false`. -* Returns: {VirtualFileSystem | null} Returns `null` if not running as a - Single Executable Application. - -Creates a `VirtualFileSystem` pre-configured with SEA (Single Executable -Application) assets. This is a convenience method for accessing bundled assets -in SEA builds. - -```cjs -const vfs = require('node:vfs'); -const fs = require('node:fs'); - -const seaVfs = vfs.createSEA({ mountPoint: '/assets' }); -if (seaVfs) { - // Running as SEA - assets are available - const data = fs.readFileSync('/assets/config.json', 'utf8'); -} -``` - ## Class: `VirtualFileSystem` Unmounts the virtual file system. After unmounting, virtual files are no longer -accessible through the `fs` module. +accessible through the `fs` module. The VFS can be remounted at the same or a +different path by calling `mount()` again. Unmounting also resets the virtual +working directory if one was set. ### `vfs.isMounted` @@ -244,7 +258,14 @@ added: v26.0.0 * `path` {string} The new working directory path within the VFS. Changes the virtual working directory. This only affects path resolution within -the VFS when `virtualCwd` is enabled. +the VFS when `virtualCwd` is enabled in the constructor options. + +Throws `ERR_INVALID_STATE` if `virtualCwd` was not enabled during construction. + +When mounted with `virtualCwd` enabled, the VFS also hooks `process.chdir()` and +`process.cwd()` to support virtual paths transparently. Since `process.chdir()` +is not available in Worker threads, virtual cwd should only be used in the main +thread. ### `vfs.cwd()` @@ -252,9 +273,12 @@ the VFS when `virtualCwd` is enabled. added: v26.0.0 --> -* Returns: {string} +* Returns: {string|null} + +Returns the current virtual working directory, or `null` if no virtual directory +has been set yet. -Returns the current virtual working directory. +Throws `ERR_INVALID_STATE` if `virtualCwd` was not enabled during construction. ### File System Methods @@ -289,6 +313,16 @@ All paths are relative to the VFS root (not the mount point). All synchronous methods have promise-based equivalents available through `vfs.promises`: +```mjs +import vfs from 'node:vfs'; + +const myVfs = vfs.create(); + +await myVfs.promises.writeFile('/data.txt', 'Hello'); +const content = await myVfs.promises.readFile('/data.txt', 'utf8'); +console.log(content); // 'Hello' +``` + ```cjs const vfs = require('node:vfs'); @@ -427,6 +461,23 @@ try { When a VFS is mounted, the standard `fs` module automatically routes operations to the VFS for paths that match the mount prefix: +```mjs +import vfs from 'node:vfs'; +import fs from 'node:fs'; + +const myVfs = vfs.create(); +myVfs.writeFileSync('/hello.txt', 'Hello from VFS!'); +myVfs.mount('/virtual'); + +// These all work transparently +fs.readFileSync('/virtual/hello.txt', 'utf8'); // Sync +await fs.promises.readFile('/virtual/hello.txt', 'utf8'); // Promise +fs.createReadStream('/virtual/hello.txt'); // Stream + +// Real file system is still accessible +fs.readFileSync('/etc/passwd'); // Real file +``` + ```cjs const vfs = require('node:vfs'); const fs = require('node:fs'); @@ -477,6 +528,25 @@ const { default: greet } = await import('/modules/greet.mjs'); console.log(greet('World')); // Hello, World! ``` +## Implementation details + +### Stats objects + +The VFS returns real `fs.Stats` objects from `stat()`, `lstat()`, and `fstat()` +operations. These Stats objects behave identically to those returned by the real +file system: + +* `stats.isFile()`, `stats.isDirectory()`, `stats.isSymbolicLink()` work correctly +* `stats.size` reflects the actual content size +* `stats.mtime`, `stats.ctime`, `stats.birthtime` are tracked per file +* `stats.mode` includes the file type bits and permissions + +### File descriptors + +Virtual file descriptors start at 10000 to avoid conflicts with real operating +system file descriptors. This allows the VFS to coexist with real file system +operations without file descriptor collisions. + ## Use with Single Executable Applications When running as a Single Executable Application (SEA), bundled assets are From b82cc48448467e5246a4aee3d50f62ab32fb9ca9 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 28 Jan 2026 23:24:41 +0100 Subject: [PATCH 14/32] doc: clarify virtualCwd behavior in Worker threads 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 --- doc/api/vfs.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 1362175619e73a..6a9f8ebd6d9b42 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -263,9 +263,9 @@ the VFS when `virtualCwd` is enabled in the constructor options. Throws `ERR_INVALID_STATE` if `virtualCwd` was not enabled during construction. When mounted with `virtualCwd` enabled, the VFS also hooks `process.chdir()` and -`process.cwd()` to support virtual paths transparently. Since `process.chdir()` -is not available in Worker threads, virtual cwd should only be used in the main -thread. +`process.cwd()` to support virtual paths transparently. In Worker threads, +`process.chdir()` to virtual paths will work, but attempting to change to real +filesystem paths will throw `ERR_WORKER_UNSUPPORTED_OPERATION`. ### `vfs.cwd()` From ad601c8ee6ee5ef1400b3bf2b16a6b4ddd1f204e Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 29 Jan 2026 00:03:32 +0100 Subject: [PATCH 15/32] vfs: add RealFSProvider for mounting real directories 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 --- doc/api/vfs.md | 87 +++++- lib/internal/vfs/providers/real.js | 376 ++++++++++++++++++++++++ lib/vfs.js | 2 + test/parallel/test-vfs-real-provider.js | 234 +++++++++++++++ 4 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 lib/internal/vfs/providers/real.js create mode 100644 test/parallel/test-vfs-real-provider.js diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 6a9f8ebd6d9b42..86c0ff85393796 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -265,7 +265,7 @@ Throws `ERR_INVALID_STATE` if `virtualCwd` was not enabled during construction. When mounted with `virtualCwd` enabled, the VFS also hooks `process.chdir()` and `process.cwd()` to support virtual paths transparently. In Worker threads, `process.chdir()` to virtual paths will work, but attempting to change to real -filesystem paths will throw `ERR_WORKER_UNSUPPORTED_OPERATION`. +file system paths will throw `ERR_WORKER_UNSUPPORTED_OPERATION`. ### `vfs.cwd()` @@ -456,6 +456,91 @@ try { } ``` +## Class: `RealFSProvider` + + + +The `RealFSProvider` wraps a real file system 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 (by mounting the real + file system through VFS) +* Creating sandboxed views of real directories + +### `new RealFSProvider(rootPath)` + + + +* `rootPath` {string} The real file system path to use as the provider root. + +Creates a new `RealFSProvider` that wraps the specified directory. All paths +accessed through this provider are resolved relative to `rootPath`. Path +traversal outside `rootPath` (via `..`) is prevented for security. + +```mjs +import vfs from 'node:vfs'; + +// Mount /home/user/project at /project +const projectVfs = vfs.create(new vfs.RealFSProvider('/home/user/project')); +projectVfs.mount('/project'); + +// Now /project/src/index.js maps to /home/user/project/src/index.js +import fs from 'node:fs'; +const content = fs.readFileSync('/project/src/index.js', 'utf8'); +``` + +```cjs +const vfs = require('node:vfs'); + +// Mount /home/user/project at /project +const projectVfs = vfs.create(new vfs.RealFSProvider('/home/user/project')); +projectVfs.mount('/project'); + +// Now /project/src/index.js maps to /home/user/project/src/index.js +const fs = require('node:fs'); +const content = fs.readFileSync('/project/src/index.js', 'utf8'); +``` + +### Using `virtualCwd` in Worker threads + +Since `process.chdir()` is not available in Worker threads, you can use +`RealFSProvider` to enable virtual working directory support: + +```cjs +const { Worker, isMainThread, parentPort } = require('node:worker_threads'); +const vfs = require('node:vfs'); + +if (isMainThread) { + new Worker(__filename); +} else { + // In worker: mount real file system with virtualCwd enabled + const realVfs = vfs.create( + new vfs.RealFSProvider('/home/user/project'), + { virtualCwd: true }, + ); + realVfs.mount('/project'); + + // Now we can use virtual chdir in the worker + realVfs.chdir('/project/src'); + console.log(realVfs.cwd()); // '/project/src' +} +``` + +### `realFSProvider.rootPath` + + + +* {string} + +The real file system path that this provider wraps. + ## Integration with `fs` module When a VFS is mounted, the standard `fs` module automatically routes operations diff --git a/lib/internal/vfs/providers/real.js b/lib/internal/vfs/providers/real.js new file mode 100644 index 00000000000000..bf45a03272653a --- /dev/null +++ b/lib/internal/vfs/providers/real.js @@ -0,0 +1,376 @@ +'use strict'; + +const { + Promise, + StringPrototypeStartsWith, +} = primordials; + +const fs = require('fs'); +const path = require('path'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { VirtualFileHandle } = require('internal/vfs/file_handle'); +const { + codes: { + ERR_INVALID_ARG_VALUE, + }, +} = require('internal/errors'); + +/** + * A file handle that wraps a real file descriptor. + */ +class RealFileHandle extends VirtualFileHandle { + #fd; + #realPath; + + /** + * @param {string} path The VFS path + * @param {string} flags The open flags + * @param {number} mode The file mode + * @param {number} fd The real file descriptor + * @param {string} realPath The real filesystem path + */ + constructor(path, flags, mode, fd, realPath) { + super(path, flags, mode); + this.#fd = fd; + this.#realPath = realPath; + } + + /** + * Gets the real file descriptor. + * @returns {number} + */ + get fd() { + return this.#fd; + } + + readSync(buffer, offset, length, position) { + this._checkClosed(); + return fs.readSync(this.#fd, buffer, offset, length, position); + } + + async read(buffer, offset, length, position) { + this._checkClosed(); + return new Promise((resolve, reject) => { + fs.read(this.#fd, buffer, offset, length, position, (err, bytesRead) => { + if (err) reject(err); + else resolve({ __proto__: null, bytesRead, buffer }); + }); + }); + } + + writeSync(buffer, offset, length, position) { + this._checkClosed(); + return fs.writeSync(this.#fd, buffer, offset, length, position); + } + + async write(buffer, offset, length, position) { + this._checkClosed(); + return new Promise((resolve, reject) => { + fs.write(this.#fd, buffer, offset, length, position, (err, bytesWritten) => { + if (err) reject(err); + else resolve({ __proto__: null, bytesWritten, buffer }); + }); + }); + } + + readFileSync(options) { + this._checkClosed(); + return fs.readFileSync(this.#realPath, options); + } + + async readFile(options) { + this._checkClosed(); + return fs.promises.readFile(this.#realPath, options); + } + + writeFileSync(data, options) { + this._checkClosed(); + fs.writeFileSync(this.#realPath, data, options); + } + + async writeFile(data, options) { + this._checkClosed(); + return fs.promises.writeFile(this.#realPath, data, options); + } + + statSync(options) { + this._checkClosed(); + return fs.fstatSync(this.#fd, options); + } + + async stat(options) { + this._checkClosed(); + return new Promise((resolve, reject) => { + fs.fstat(this.#fd, options, (err, stats) => { + if (err) reject(err); + else resolve(stats); + }); + }); + } + + truncateSync(len = 0) { + this._checkClosed(); + fs.ftruncateSync(this.#fd, len); + } + + async truncate(len = 0) { + this._checkClosed(); + return new Promise((resolve, reject) => { + fs.ftruncate(this.#fd, len, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + + closeSync() { + if (!this.closed) { + fs.closeSync(this.#fd); + super.closeSync(); + } + } + + async close() { + if (!this.closed) { + return new Promise((resolve, reject) => { + fs.close(this.#fd, (err) => { + if (err) reject(err); + else { + super.closeSync(); + resolve(); + } + }); + }); + } + } +} + +/** + * A provider that wraps a real filesystem directory. + * Allows mounting a real directory at a different VFS path. + */ +class RealFSProvider extends VirtualProvider { + #rootPath; + + /** + * @param {string} rootPath The real filesystem path to use as root + */ + constructor(rootPath) { + super(); + if (typeof rootPath !== 'string' || rootPath === '') { + throw new ERR_INVALID_ARG_VALUE('rootPath', rootPath, 'must be a non-empty string'); + } + // Resolve to absolute path and normalize + this.#rootPath = path.resolve(rootPath); + } + + /** + * Gets the root path of this provider. + * @returns {string} + */ + get rootPath() { + return this.#rootPath; + } + + get readonly() { + return false; + } + + get supportsSymlinks() { + return true; + } + + /** + * Resolves a VFS path to a real filesystem path. + * Ensures the path doesn't escape the root directory. + * @param {string} vfsPath The VFS path (relative to provider root) + * @returns {string} The real filesystem path + * @private + */ + _resolvePath(vfsPath) { + // Normalize the VFS path (remove leading slash, handle . and ..) + let normalized = vfsPath; + if (normalized.startsWith('/')) { + normalized = normalized.slice(1); + } + + // Join with root and resolve + const realPath = path.resolve(this.#rootPath, normalized); + + // Security check: ensure the resolved path is within rootPath + const rootWithSep = this.#rootPath.endsWith(path.sep) ? + this.#rootPath : + this.#rootPath + path.sep; + + if (realPath !== this.#rootPath && !StringPrototypeStartsWith(realPath, rootWithSep)) { + const { createENOENT } = require('internal/vfs/errors'); + throw createENOENT('open', vfsPath); + } + + return realPath; + } + + openSync(vfsPath, flags, mode) { + const realPath = this._resolvePath(vfsPath); + const fd = fs.openSync(realPath, flags, mode); + return new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd, realPath); + } + + async open(vfsPath, flags, mode) { + const realPath = this._resolvePath(vfsPath); + return new Promise((resolve, reject) => { + fs.open(realPath, flags, mode, (err, fd) => { + if (err) reject(err); + else resolve(new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd, realPath)); + }); + }); + } + + statSync(vfsPath, options) { + const realPath = this._resolvePath(vfsPath); + return fs.statSync(realPath, options); + } + + async stat(vfsPath, options) { + const realPath = this._resolvePath(vfsPath); + return fs.promises.stat(realPath, options); + } + + lstatSync(vfsPath, options) { + const realPath = this._resolvePath(vfsPath); + return fs.lstatSync(realPath, options); + } + + async lstat(vfsPath, options) { + const realPath = this._resolvePath(vfsPath); + return fs.promises.lstat(realPath, options); + } + + readdirSync(vfsPath, options) { + const realPath = this._resolvePath(vfsPath); + return fs.readdirSync(realPath, options); + } + + async readdir(vfsPath, options) { + const realPath = this._resolvePath(vfsPath); + return fs.promises.readdir(realPath, options); + } + + mkdirSync(vfsPath, options) { + const realPath = this._resolvePath(vfsPath); + return fs.mkdirSync(realPath, options); + } + + async mkdir(vfsPath, options) { + const realPath = this._resolvePath(vfsPath); + return fs.promises.mkdir(realPath, options); + } + + rmdirSync(vfsPath) { + const realPath = this._resolvePath(vfsPath); + fs.rmdirSync(realPath); + } + + async rmdir(vfsPath) { + const realPath = this._resolvePath(vfsPath); + return fs.promises.rmdir(realPath); + } + + unlinkSync(vfsPath) { + const realPath = this._resolvePath(vfsPath); + fs.unlinkSync(realPath); + } + + async unlink(vfsPath) { + const realPath = this._resolvePath(vfsPath); + return fs.promises.unlink(realPath); + } + + renameSync(oldVfsPath, newVfsPath) { + const oldRealPath = this._resolvePath(oldVfsPath); + const newRealPath = this._resolvePath(newVfsPath); + fs.renameSync(oldRealPath, newRealPath); + } + + async rename(oldVfsPath, newVfsPath) { + const oldRealPath = this._resolvePath(oldVfsPath); + const newRealPath = this._resolvePath(newVfsPath); + return fs.promises.rename(oldRealPath, newRealPath); + } + + readlinkSync(vfsPath, options) { + const realPath = this._resolvePath(vfsPath); + return fs.readlinkSync(realPath, options); + } + + async readlink(vfsPath, options) { + const realPath = this._resolvePath(vfsPath); + return fs.promises.readlink(realPath, options); + } + + symlinkSync(target, vfsPath, type) { + const realPath = this._resolvePath(vfsPath); + fs.symlinkSync(target, realPath, type); + } + + async symlink(target, vfsPath, type) { + const realPath = this._resolvePath(vfsPath); + return fs.promises.symlink(target, realPath, type); + } + + realpathSync(vfsPath, options) { + const realPath = this._resolvePath(vfsPath); + const resolved = fs.realpathSync(realPath, options); + // Convert back to VFS path + if (resolved === this.#rootPath) { + return '/'; + } + const rootWithSep = this.#rootPath + path.sep; + if (StringPrototypeStartsWith(resolved, rootWithSep)) { + return '/' + resolved.slice(rootWithSep.length).replace(/\\/g, '/'); + } + // Path escaped root (shouldn't happen normally) + return vfsPath; + } + + async realpath(vfsPath, options) { + const realPath = this._resolvePath(vfsPath); + const resolved = await fs.promises.realpath(realPath, options); + // Convert back to VFS path + if (resolved === this.#rootPath) { + return '/'; + } + const rootWithSep = this.#rootPath + path.sep; + if (StringPrototypeStartsWith(resolved, rootWithSep)) { + return '/' + resolved.slice(rootWithSep.length).replace(/\\/g, '/'); + } + return vfsPath; + } + + accessSync(vfsPath, mode) { + const realPath = this._resolvePath(vfsPath); + fs.accessSync(realPath, mode); + } + + async access(vfsPath, mode) { + const realPath = this._resolvePath(vfsPath); + return fs.promises.access(realPath, mode); + } + + copyFileSync(srcVfsPath, destVfsPath, mode) { + const srcRealPath = this._resolvePath(srcVfsPath); + const destRealPath = this._resolvePath(destVfsPath); + fs.copyFileSync(srcRealPath, destRealPath, mode); + } + + async copyFile(srcVfsPath, destVfsPath, mode) { + const srcRealPath = this._resolvePath(srcVfsPath); + const destRealPath = this._resolvePath(destVfsPath); + return fs.promises.copyFile(srcRealPath, destRealPath, mode); + } +} + +module.exports = { + RealFSProvider, + RealFileHandle, +}; diff --git a/lib/vfs.js b/lib/vfs.js index 69160256b106b4..b9c7b133fc865f 100644 --- a/lib/vfs.js +++ b/lib/vfs.js @@ -8,6 +8,7 @@ const { const { VirtualFileSystem } = require('internal/vfs/file_system'); const { VirtualProvider } = require('internal/vfs/provider'); const { MemoryProvider } = require('internal/vfs/providers/memory'); +const { RealFSProvider } = require('internal/vfs/providers/real'); // SEAProvider is lazy-loaded to avoid loading SEA bindings when not needed let SEAProvider = null; @@ -52,6 +53,7 @@ module.exports = { VirtualFileSystem, VirtualProvider, MemoryProvider, + RealFSProvider, get SEAProvider() { return getSEAProvider(); }, diff --git a/test/parallel/test-vfs-real-provider.js b/test/parallel/test-vfs-real-provider.js new file mode 100644 index 00000000000000..9fc1abd20dfd88 --- /dev/null +++ b/test/parallel/test-vfs-real-provider.js @@ -0,0 +1,234 @@ +'use strict'; + +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +tmpdir.refresh(); + +const testDir = path.join(tmpdir.path, 'vfs-real-provider'); +fs.mkdirSync(testDir, { recursive: true }); + +// Test basic RealFSProvider creation +{ + const provider = new vfs.RealFSProvider(testDir); + assert.ok(provider); + assert.strictEqual(provider.rootPath, testDir); + assert.strictEqual(provider.readonly, false); + assert.strictEqual(provider.supportsSymlinks, true); +} + +// Test invalid rootPath +{ + assert.throws(() => { + new vfs.RealFSProvider(''); + }, { code: 'ERR_INVALID_ARG_VALUE' }); + + assert.throws(() => { + new vfs.RealFSProvider(123); + }, { code: 'ERR_INVALID_ARG_VALUE' }); +} + +// Test creating VFS with RealFSProvider +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + assert.ok(realVfs); + assert.strictEqual(realVfs.readonly, false); +} + +// Test reading and writing files through RealFSProvider +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + // Write a file through VFS + realVfs.writeFileSync('/hello.txt', 'Hello from VFS!'); + + // Verify it exists on the real file system + const realPath = path.join(testDir, 'hello.txt'); + assert.strictEqual(fs.existsSync(realPath), true); + assert.strictEqual(fs.readFileSync(realPath, 'utf8'), 'Hello from VFS!'); + + // Read it back through VFS + assert.strictEqual(realVfs.readFileSync('/hello.txt', 'utf8'), 'Hello from VFS!'); + + // Clean up + fs.unlinkSync(realPath); +} + +// Test stat operations +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + // Create a file and directory + fs.writeFileSync(path.join(testDir, 'stat-test.txt'), 'content'); + fs.mkdirSync(path.join(testDir, 'stat-dir'), { recursive: true }); + + const fileStat = realVfs.statSync('/stat-test.txt'); + assert.strictEqual(fileStat.isFile(), true); + assert.strictEqual(fileStat.isDirectory(), false); + + const dirStat = realVfs.statSync('/stat-dir'); + assert.strictEqual(dirStat.isFile(), false); + assert.strictEqual(dirStat.isDirectory(), true); + + // Test ENOENT + assert.throws(() => { + realVfs.statSync('/nonexistent'); + }, { code: 'ENOENT' }); + + // Clean up + fs.unlinkSync(path.join(testDir, 'stat-test.txt')); + fs.rmdirSync(path.join(testDir, 'stat-dir')); +} + +// Test readdirSync +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.mkdirSync(path.join(testDir, 'readdir-test', 'subdir'), { recursive: true }); + fs.writeFileSync(path.join(testDir, 'readdir-test', 'a.txt'), 'a'); + fs.writeFileSync(path.join(testDir, 'readdir-test', 'b.txt'), 'b'); + + const entries = realVfs.readdirSync('/readdir-test'); + assert.deepStrictEqual(entries.sort(), ['a.txt', 'b.txt', 'subdir']); + + // With file types + const dirents = realVfs.readdirSync('/readdir-test', { withFileTypes: true }); + assert.strictEqual(dirents.length, 3); + + const fileEntry = dirents.find((d) => d.name === 'a.txt'); + assert.ok(fileEntry); + assert.strictEqual(fileEntry.isFile(), true); + + const dirEntry = dirents.find((d) => d.name === 'subdir'); + assert.ok(dirEntry); + assert.strictEqual(dirEntry.isDirectory(), true); + + // Clean up + fs.unlinkSync(path.join(testDir, 'readdir-test', 'a.txt')); + fs.unlinkSync(path.join(testDir, 'readdir-test', 'b.txt')); + fs.rmdirSync(path.join(testDir, 'readdir-test', 'subdir')); + fs.rmdirSync(path.join(testDir, 'readdir-test')); +} + +// Test mkdir and rmdir +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + realVfs.mkdirSync('/new-dir'); + assert.strictEqual(fs.existsSync(path.join(testDir, 'new-dir')), true); + assert.strictEqual(fs.statSync(path.join(testDir, 'new-dir')).isDirectory(), true); + + realVfs.rmdirSync('/new-dir'); + assert.strictEqual(fs.existsSync(path.join(testDir, 'new-dir')), false); +} + +// Test unlink +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'to-delete.txt'), 'delete me'); + assert.strictEqual(realVfs.existsSync('/to-delete.txt'), true); + + realVfs.unlinkSync('/to-delete.txt'); + assert.strictEqual(fs.existsSync(path.join(testDir, 'to-delete.txt')), false); +} + +// Test rename +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'old-name.txt'), 'rename me'); + realVfs.renameSync('/old-name.txt', '/new-name.txt'); + + assert.strictEqual(fs.existsSync(path.join(testDir, 'old-name.txt')), false); + assert.strictEqual(fs.existsSync(path.join(testDir, 'new-name.txt')), true); + assert.strictEqual(fs.readFileSync(path.join(testDir, 'new-name.txt'), 'utf8'), 'rename me'); + + // Clean up + fs.unlinkSync(path.join(testDir, 'new-name.txt')); +} + +// Test path traversal prevention +{ + const subDir = path.join(testDir, 'sandbox'); + fs.mkdirSync(subDir, { recursive: true }); + + const realVfs = vfs.create(new vfs.RealFSProvider(subDir)); + + // Trying to access parent via .. should fail + assert.throws(() => { + realVfs.statSync('/../hello.txt'); + }, { code: 'ENOENT' }); + + assert.throws(() => { + realVfs.readFileSync('/../../../etc/passwd'); + }, { code: 'ENOENT' }); + + // Clean up + fs.rmdirSync(subDir); +} + +// Test mounting RealFSProvider +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'mounted.txt'), 'mounted content'); + + realVfs.mount('/virtual'); + + // Now should be able to read through standard fs + const content = fs.readFileSync('/virtual/mounted.txt', 'utf8'); + assert.strictEqual(content, 'mounted content'); + + realVfs.unmount(); + + // Clean up + fs.unlinkSync(path.join(testDir, 'mounted.txt')); +} + +// Test async operations +(async () => { + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + await realVfs.promises.writeFile('/async-test.txt', 'async content'); + const content = await realVfs.promises.readFile('/async-test.txt', 'utf8'); + assert.strictEqual(content, 'async content'); + + const stat = await realVfs.promises.stat('/async-test.txt'); + assert.strictEqual(stat.isFile(), true); + + await realVfs.promises.unlink('/async-test.txt'); + assert.strictEqual(fs.existsSync(path.join(testDir, 'async-test.txt')), false); +})().then(common.mustCall()); + +// Test copyFile +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'source.txt'), 'copy me'); + realVfs.copyFileSync('/source.txt', '/dest.txt'); + + assert.strictEqual(fs.existsSync(path.join(testDir, 'dest.txt')), true); + assert.strictEqual(fs.readFileSync(path.join(testDir, 'dest.txt'), 'utf8'), 'copy me'); + + // Clean up + fs.unlinkSync(path.join(testDir, 'source.txt')); + fs.unlinkSync(path.join(testDir, 'dest.txt')); +} + +// Test realpathSync +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'real.txt'), 'content'); + + const resolved = realVfs.realpathSync('/real.txt'); + assert.strictEqual(resolved, '/real.txt'); + + // Clean up + fs.unlinkSync(path.join(testDir, 'real.txt')); +} From b9ab34809d0339ac7c527d2575c256ba6b0b49bd Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 29 Jan 2026 00:09:46 +0100 Subject: [PATCH 16/32] tools: add VFS types to doc type-parser Add VirtualFileSystem, VirtualProvider, MemoryProvider, SEAProvider, and RealFSProvider to the type-parser for documentation generation. --- tools/doc/type-parser.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/doc/type-parser.mjs b/tools/doc/type-parser.mjs index de913c98534a09..75ce7cfbb5ff51 100644 --- a/tools/doc/type-parser.mjs +++ b/tools/doc/type-parser.mjs @@ -165,7 +165,12 @@ const customTypesMap = { 'fs.StatFs': 'fs.html#class-fsstatfs', 'fs.StatWatcher': 'fs.html#class-fsstatwatcher', 'fs.WriteStream': 'fs.html#class-fswritestream', - 'VirtualFileSystem': 'fs.html#class-virtualfilesystem', + + 'VirtualFileSystem': 'vfs.html#class-virtualfilesystem', + 'VirtualProvider': 'vfs.html#class-virtualprovider', + 'MemoryProvider': 'vfs.html#class-memoryprovider', + 'SEAProvider': 'vfs.html#class-seaprovider', + 'RealFSProvider': 'vfs.html#class-realfsprovider', 'http.Agent': 'http.html#class-httpagent', 'http.ClientRequest': 'http.html#class-httpclientrequest', From d930079dc42e2f7d25633d4884a166bb98916c5b Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 29 Jan 2026 08:13:00 +0100 Subject: [PATCH 17/32] doc: use REPLACEME for version placeholders in vfs.md --- doc/api/vfs.md | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 86c0ff85393796..4a8c8f79674340 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -1,9 +1,9 @@ # Virtual File System - + > Stability: 1 - Experimental @@ -99,7 +99,7 @@ myVfs.unmount(); ## `vfs.create([provider][, options])` * `provider` {VirtualProvider} Optional provider instance. Defaults to a new @@ -143,7 +143,7 @@ const vfsWithOptions = vfs.create({ moduleHooks: false }); ## Class: `VirtualFileSystem` The `VirtualFileSystem` class provides a file system interface backed by a @@ -153,7 +153,7 @@ make virtual files accessible through the `fs` module. ### `new VirtualFileSystem([provider][, options])` * `provider` {VirtualProvider} The provider to use. **Default:** `MemoryProvider`. @@ -166,7 +166,7 @@ Creates a new `VirtualFileSystem` instance. ### `vfs.provider` * {VirtualProvider} @@ -188,7 +188,7 @@ console.log(myVfs.provider.readonly); // true ### `vfs.mount(prefix)` * `prefix` {string} The path prefix where the VFS will be mounted. @@ -211,7 +211,7 @@ require('node:fs').readFileSync('/virtual/data.txt', 'utf8'); // 'Hello' ### `vfs.unmount()` Unmounts the virtual file system. After unmounting, virtual files are no longer @@ -222,7 +222,7 @@ working directory if one was set. ### `vfs.isMounted` * {boolean} @@ -232,7 +232,7 @@ Returns `true` if the VFS is currently mounted. ### `vfs.mountPoint` * {string | null} @@ -242,7 +242,7 @@ The current mount point, or `null` if not mounted. ### `vfs.readonly` * {boolean} @@ -252,7 +252,7 @@ Returns `true` if the underlying provider is read-only. ### `vfs.chdir(path)` * `path` {string} The new working directory path within the VFS. @@ -270,7 +270,7 @@ file system paths will throw `ERR_WORKER_UNSUPPORTED_OPERATION`. ### `vfs.cwd()` * Returns: {string|null} @@ -338,7 +338,7 @@ async function example() { ## Class: `VirtualProvider` The `VirtualProvider` class is an abstract base class for VFS providers. @@ -349,7 +349,7 @@ Providers implement the actual file system storage and operations. #### `provider.readonly` * {boolean} @@ -359,7 +359,7 @@ Returns `true` if the provider is read-only. #### `provider.supportsSymlinks` * {boolean} @@ -397,7 +397,7 @@ class MyProvider extends VirtualProvider { ## Class: `MemoryProvider` The `MemoryProvider` stores files in memory. It supports full read/write @@ -412,7 +412,7 @@ const myVfs = create(new MemoryProvider()); ### `memoryProvider.setReadOnly()` Sets the provider to read-only mode. Once set to read-only, the provider @@ -438,7 +438,7 @@ myVfs.provider.setReadOnly(); ## Class: `SEAProvider` The `SEAProvider` provides read-only access to assets bundled in a Single @@ -459,7 +459,7 @@ try { ## Class: `RealFSProvider` The `RealFSProvider` wraps a real file system directory, allowing it to be @@ -473,7 +473,7 @@ mounted at a different VFS path. This is useful for: ### `new RealFSProvider(rootPath)` * `rootPath` {string} The real file system path to use as the provider root. @@ -534,7 +534,7 @@ if (isMainThread) { ### `realFSProvider.rootPath` * {string} From 3388e9d821c5b4faf1cbc3fc90355a07b6e51f56 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 29 Jan 2026 10:57:47 +0100 Subject: [PATCH 18/32] doc: add security warnings and symlink documentation to vfs.md - Add security considerations section warning about path shadowing risks - Document that VFS shadows real paths when mounted - Add symlink documentation explaining VFS-internal-only behavior - Clarify that only mount mode exists (no overlay mode) - Reorder synchronous methods alphabetically per doc conventions Addresses review comments from @jasnell regarding security documentation, overlay mode clarification, alphabetical ordering, and symlink behavior. --- doc/api/vfs.md | 111 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 98 insertions(+), 13 deletions(-) diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 4a8c8f79674340..6bb1501ca5bc92 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -37,6 +37,13 @@ is useful for: * Creating virtual module systems * Embedding configuration or data files in applications +## Mount mode + +The VFS operates in **mount mode only**. When mounted at a path prefix (e.g., +`/virtual`), the VFS handles all operations for paths starting with that +prefix. There is no overlay mode that would merge virtual and real file system +contents at the same paths. + ## Basic usage The following example shows how to create a virtual file system, add files, @@ -197,6 +204,12 @@ Mounts the virtual file system at the specified path prefix. After mounting, files in the VFS can be accessed via the `fs` module using paths that start with the prefix. +If a real file system path already exists at the mount prefix, the VFS +**shadows** that path. All operations to paths under the mount prefix will be +directed to the VFS, making the real files inaccessible until the VFS is +unmounted. See [Security considerations][] for important warnings about this +behavior. + ```cjs const vfs = require('node:vfs'); @@ -287,26 +300,26 @@ All paths are relative to the VFS root (not the mount point). #### Synchronous Methods -* `vfs.readFileSync(path[, options])` - Read a file -* `vfs.writeFileSync(path, data[, options])` - Write a file +* `vfs.accessSync(path[, mode])` - Check file accessibility * `vfs.appendFileSync(path, data[, options])` - Append to a file -* `vfs.statSync(path[, options])` - Get file stats -* `vfs.lstatSync(path[, options])` - Get file stats (no symlink follow) -* `vfs.readdirSync(path[, options])` - Read directory contents -* `vfs.mkdirSync(path[, options])` - Create a directory -* `vfs.rmdirSync(path)` - Remove a directory -* `vfs.unlinkSync(path)` - Remove a file -* `vfs.renameSync(oldPath, newPath)` - Rename a file or directory +* `vfs.closeSync(fd)` - Close a file descriptor * `vfs.copyFileSync(src, dest[, mode])` - Copy a file * `vfs.existsSync(path)` - Check if path exists -* `vfs.accessSync(path[, mode])` - Check file accessibility +* `vfs.lstatSync(path[, options])` - Get file stats (no symlink follow) +* `vfs.mkdirSync(path[, options])` - Create a directory * `vfs.openSync(path, flags[, mode])` - Open a file -* `vfs.closeSync(fd)` - Close a file descriptor +* `vfs.readFileSync(path[, options])` - Read a file * `vfs.readSync(fd, buffer, offset, length, position)` - Read from fd -* `vfs.writeSync(fd, buffer, offset, length, position)` - Write to fd -* `vfs.realpathSync(path[, options])` - Resolve symlinks * `vfs.readlinkSync(path[, options])` - Read symlink target +* `vfs.readdirSync(path[, options])` - Read directory contents +* `vfs.realpathSync(path[, options])` - Resolve symlinks +* `vfs.renameSync(oldPath, newPath)` - Rename a file or directory +* `vfs.rmdirSync(path)` - Remove a directory +* `vfs.statSync(path[, options])` - Get file stats * `vfs.symlinkSync(target, path[, type])` - Create a symlink +* `vfs.unlinkSync(path)` - Remove a file +* `vfs.writeFileSync(path, data[, options])` - Write a file +* `vfs.writeSync(fd, buffer, offset, length, position)` - Write to fd #### Promise Methods @@ -649,4 +662,76 @@ const template = fs.readFileSync('/sea/templates/index.html', 'utf8'); See the [Single Executable Applications][] documentation for more information on creating SEA builds with assets. +## Symbolic links + +The VFS supports symbolic links within the virtual file system. Symlinks are +created using `vfs.symlinkSync()` or `vfs.promises.symlink()` and can point +to files or directories within the same VFS. + +### Cross-boundary symlinks + +Symbolic links in the VFS are **VFS-internal only**. They cannot: + +* Point from a VFS path to a real file system path +* Point from a real file system path to a VFS path +* Be followed across VFS mount boundaries + +When resolving symlinks, the VFS only follows links that target paths within +the same VFS instance. Attempts to create symlinks with absolute paths that +would resolve outside the VFS are allowed but will result in dangling symlinks. + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.mkdirSync('/data'); +myVfs.writeFileSync('/data/config.json', '{}'); + +// This works - symlink within VFS +myVfs.symlinkSync('/data/config.json', '/config'); +myVfs.readFileSync('/config', 'utf8'); // '{}' + +// This creates a dangling symlink - target doesn't exist in VFS +myVfs.symlinkSync('/etc/passwd', '/passwd-link'); +// myVfs.readFileSync('/passwd-link'); // Throws ENOENT +``` + +## Security considerations + +### Path shadowing + +When a VFS is mounted, it **shadows** any real file system paths under the +mount prefix. This means: + +* Real files at the mount path become inaccessible +* All operations are redirected to the VFS +* Modules loaded from shadowed paths will use VFS content + +This behavior can be exploited maliciously. A module could mount a VFS over +critical system paths (like `/etc` on Unix or `C:\Windows` on Windows) and +intercept sensitive operations: + +```cjs +// WARNING: Example of dangerous behavior - DO NOT DO THIS +const vfs = require('node:vfs'); + +const maliciousVfs = vfs.create(); +maliciousVfs.writeFileSync('/passwd', 'malicious content'); +maliciousVfs.mount('/etc'); // Shadows /etc/passwd! + +// Now fs.readFileSync('/etc/passwd') returns 'malicious content' +``` + +### Recommendations + +* **Audit dependencies**: Be cautious of third-party modules that use VFS, as + they could shadow important paths. +* **Use unique mount points**: Mount VFS at paths that don't conflict with + real file system paths, such as `/@virtual` or `/vfs-{unique-id}`. +* **Verify mount points**: Before trusting file content from paths that could + be shadowed, verify the mount state. +* **Limit VFS usage**: Only use VFS in controlled environments where you trust + all loaded modules. + +[Security considerations]: #security-considerations [Single Executable Applications]: single-executable-applications.md From 22c38426dd839919dd2f5ebf86d383bd03dd0be8 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 29 Jan 2026 11:53:20 +0100 Subject: [PATCH 19/32] vfs: address code review feedback from @jasnell - Use undefined instead of null for lazy-loaded SEAProvider - Add validateBoolean for moduleHooks and virtualCwd options - Use template literal for path concatenation - Convert VirtualReadStream to use private class fields - Cache DateNow() result in MemoryEntry constructor Addresses review comments #18, #19, #21, #23, #24, #29. --- lib/internal/vfs/file_system.js | 11 ++++- lib/internal/vfs/providers/memory.js | 7 +-- lib/internal/vfs/streams.js | 73 ++++++++++++++-------------- lib/vfs.js | 4 +- 4 files changed, 53 insertions(+), 42 deletions(-) diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js index 0c68545f1b6eac..ca72ce5857f9e9 100644 --- a/lib/internal/vfs/file_system.js +++ b/lib/internal/vfs/file_system.js @@ -10,6 +10,7 @@ const { ERR_INVALID_STATE, }, } = require('internal/errors'); +const { validateBoolean } = require('internal/validators'); const { MemoryProvider } = require('internal/vfs/providers/memory'); const { normalizePath, @@ -81,6 +82,14 @@ class VirtualFileSystem { } } + // Validate boolean options + if (options.moduleHooks !== undefined) { + validateBoolean(options.moduleHooks, 'options.moduleHooks'); + } + if (options.virtualCwd !== undefined) { + validateBoolean(options.virtualCwd, 'options.virtualCwd'); + } + this[kProvider] = provider ?? new MemoryProvider(); this[kMountPoint] = null; this[kMounted] = false; @@ -181,7 +190,7 @@ class VirtualFileSystem { // If virtual cwd is enabled and set, resolve relative to it if (this[kVirtualCwdEnabled] && this[kVirtualCwd] !== null) { - const resolved = this[kVirtualCwd] + '/' + inputPath; + const resolved = `${this[kVirtualCwd]}/${inputPath}`; return normalizePath(resolved); } diff --git a/lib/internal/vfs/providers/memory.js b/lib/internal/vfs/providers/memory.js index 890afaa97185bd..b12944b4bedb24 100644 --- a/lib/internal/vfs/providers/memory.js +++ b/lib/internal/vfs/providers/memory.js @@ -63,9 +63,10 @@ class MemoryEntry { this.children = null; // For directories this.populate = null; // For directories - lazy population callback this.populated = true; // For directories - has populate been called? - this.mtime = DateNow(); - this.ctime = DateNow(); - this.birthtime = DateNow(); + const now = DateNow(); + this.mtime = now; + this.ctime = now; + this.birthtime = now; } /** diff --git a/lib/internal/vfs/streams.js b/lib/internal/vfs/streams.js index a07fd04bceb92a..06aafb9e7ad98a 100644 --- a/lib/internal/vfs/streams.js +++ b/lib/internal/vfs/streams.js @@ -11,6 +11,15 @@ const { createEBADF } = require('internal/vfs/errors'); * A readable stream for virtual files. */ class VirtualReadStream extends Readable { + #vfs; + #path; + #fd = null; + #end; + #pos; + #content = null; + #destroyed = false; + #autoClose; + /** * @param {VirtualFileSystem} vfs The VFS instance * @param {string} filePath The path to the file @@ -27,18 +36,14 @@ class VirtualReadStream extends Readable { super({ ...streamOptions, highWaterMark, encoding }); - this._vfs = vfs; - this._path = filePath; - this._fd = null; - this._start = start; - this._end = end; - this._pos = start; - this._content = null; - this._destroyed = false; - this._autoClose = options.autoClose !== false; + this.#vfs = vfs; + this.#path = filePath; + this.#end = end; + this.#pos = start; + this.#autoClose = options.autoClose !== false; // Open the file on next tick so listeners can be attached - process.nextTick(() => this._openFile()); + process.nextTick(() => this.#openFile()); } /** @@ -46,19 +51,18 @@ class VirtualReadStream extends Readable { * @returns {string} */ get path() { - return this._path; + return this.#path; } /** * Opens the virtual file. * Events are emitted synchronously within this method, which runs * asynchronously via process.nextTick - matching real fs behavior. - * @private */ - _openFile() { + #openFile() { try { - this._fd = this._vfs.openSync(this._path); - this.emit('open', this._fd); + this.#fd = this.#vfs.openSync(this.#path); + this.emit('open', this.#fd); this.emit('ready'); } catch (err) { this.destroy(err); @@ -68,23 +72,22 @@ class VirtualReadStream extends Readable { /** * Implements the readable _read method. * @param {number} size Number of bytes to read - * @private */ _read(size) { - if (this._destroyed || this._fd === null) { + if (this.#destroyed || this.#fd === null) { return; } // Load content on first read (lazy loading) - if (this._content === null) { + if (this.#content === null) { try { - const vfd = require('internal/vfs/fd').getVirtualFd(this._fd); + const vfd = require('internal/vfs/fd').getVirtualFd(this.#fd); if (!vfd) { this.destroy(createEBADF('read')); return; } // Use the file handle's readFileSync to get content - this._content = vfd.entry.readFileSync(); + this.#content = vfd.entry.readFileSync(); } catch (err) { this.destroy(err); return; @@ -93,40 +96,39 @@ class VirtualReadStream extends Readable { // Calculate how much to read // Note: end is inclusive, so we use end + 1 for the upper bound - const endPos = this._end === Infinity ? this._content.length : this._end + 1; - const remaining = MathMin(endPos, this._content.length) - this._pos; + const endPos = this.#end === Infinity ? this.#content.length : this.#end + 1; + const remaining = MathMin(endPos, this.#content.length) - this.#pos; if (remaining <= 0) { this.push(null); - // Note: _close() will be called by _destroy() when autoClose is true + // Note: #close() will be called by _destroy() when autoClose is true return; } const bytesToRead = MathMin(size, remaining); - const chunk = this._content.subarray(this._pos, this._pos + bytesToRead); - this._pos += bytesToRead; + const chunk = this.#content.subarray(this.#pos, this.#pos + bytesToRead); + this.#pos += bytesToRead; this.push(chunk); // Check if we've reached the end - if (this._pos >= endPos || this._pos >= this._content.length) { + if (this.#pos >= endPos || this.#pos >= this.#content.length) { this.push(null); - // Note: _close() will be called by _destroy() when autoClose is true + // Note: #close() will be called by _destroy() when autoClose is true } } /** * Closes the file descriptor. * Note: Does not emit 'close' - the base Readable class handles that. - * @private */ - _close() { - if (this._fd !== null) { + #close() { + if (this.#fd !== null) { try { - this._vfs.closeSync(this._fd); + this.#vfs.closeSync(this.#fd); } catch { // Ignore close errors } - this._fd = null; + this.#fd = null; } } @@ -134,12 +136,11 @@ class VirtualReadStream extends Readable { * Implements the readable _destroy method. * @param {Error|null} err The error * @param {Function} callback Callback - * @private */ _destroy(err, callback) { - this._destroyed = true; - if (this._autoClose) { - this._close(); + this.#destroyed = true; + if (this.#autoClose) { + this.#close(); } callback(err); } diff --git a/lib/vfs.js b/lib/vfs.js index b9c7b133fc865f..6def631e112d36 100644 --- a/lib/vfs.js +++ b/lib/vfs.js @@ -11,10 +11,10 @@ const { MemoryProvider } = require('internal/vfs/providers/memory'); const { RealFSProvider } = require('internal/vfs/providers/real'); // SEAProvider is lazy-loaded to avoid loading SEA bindings when not needed -let SEAProvider = null; +let SEAProvider; function getSEAProvider() { - if (SEAProvider === null) { + if (SEAProvider === undefined) { try { SEAProvider = require('internal/vfs/providers/sea').SEAProvider; } catch { From 3de6a3fe07437063867eaa7ebd7df36bb148158b Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 29 Jan 2026 20:51:54 +0100 Subject: [PATCH 20/32] vfs: add overlay mode for selective file interception Add overlay mode to VirtualFileSystem that only intercepts paths that exist in the VFS, allowing real filesystem access to fall through for non-mocked files. This enables use cases like the test runner mock fs where specific files are mocked while allowing access to real files. Changes: - Add `overlay` option to VirtualFileSystem constructor - Modify shouldHandle() to check file existence in overlay mode - Add `overlay` property getter - Update test runner mock to use VFS with overlay mode - Use path.dirname() instead of string manipulation in mock.js - Rename isMounted to mounted for consistency - Add security documentation for overlay mode risks - Add comprehensive tests for overlay mode including worker threads --- doc/api/vfs.md | 46 +- lib/internal/test_runner/mock/mock.js | 25 +- lib/internal/vfs/file_system.js | 62 +- lib/internal/vfs/module_hooks.js | 30 +- lib/internal/vfs/virtual_fs.js | 1348 ------------------------- src/node_builtins.cc | 1 + test/parallel/test-runner-mock-fs.js | 3 +- test/parallel/test-vfs-basic.js | 8 +- test/parallel/test-vfs-overlay.js | 241 +++++ 9 files changed, 381 insertions(+), 1383 deletions(-) delete mode 100644 lib/internal/vfs/virtual_fs.js create mode 100644 test/parallel/test-vfs-overlay.js diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 6bb1501ca5bc92..8204d090f5c8d8 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -116,6 +116,11 @@ added: REPLACEME loading modules from the VFS. **Default:** `true`. * `virtualCwd` {boolean} Whether to enable virtual working directory support. **Default:** `false`. + * `overlay` {boolean} Whether to enable overlay mode. In overlay mode, the VFS + only intercepts paths that exist in the VFS, allowing other paths to fall + through to the real file system. Useful for mocking specific files while + leaving others unchanged. See [Security considerations][] for important + warnings. **Default:** `false`. * Returns: {VirtualFileSystem} Creates a new `VirtualFileSystem` instance. If no provider is specified, a @@ -232,7 +237,7 @@ accessible through the `fs` module. The VFS can be remounted at the same or a different path by calling `mount()` again. Unmounting also resets the virtual working directory if one was set. -### `vfs.isMounted` +### `vfs.mounted` + +* {boolean} + +Returns `true` if overlay mode is enabled. In overlay mode, the VFS only +intercepts paths that exist in the VFS, allowing other paths to fall through +to the real file system. + ### `vfs.mountPoint` - -> Stability: 1 - Experimental - -The virtual file system (VFS) allows creating in-memory file system overlays -that integrate seamlessly with the Node.js `fs` module and module loader. Virtual -files and directories can be accessed using standard `fs` operations and can be -`require()`d or `import`ed like regular files. - -### Creating a virtual file system - -Use `fs.createVirtual()` to create a new VFS instance: - -```cjs -const fs = require('node:fs'); - -const vfs = fs.createVirtual(); - -// Add files to the VFS -vfs.addFile('/config.json', JSON.stringify({ debug: true })); -vfs.addFile('/data.txt', 'Hello, World!'); - -// Mount the VFS at a specific path -vfs.mount('/app'); - -// Now files are accessible via standard fs APIs -const config = JSON.parse(fs.readFileSync('/app/config.json', 'utf8')); -console.log(config.debug); // true -``` - -```mjs -import fs from 'node:fs'; - -const vfs = fs.createVirtual(); - -// Add files to the VFS -vfs.addFile('/config.json', JSON.stringify({ debug: true })); -vfs.addFile('/data.txt', 'Hello, World!'); - -// Mount the VFS at a specific path -vfs.mount('/app'); - -// Now files are accessible via standard fs APIs -const config = JSON.parse(fs.readFileSync('/app/config.json', 'utf8')); -console.log(config.debug); // true -``` - -### `fs.createVirtual([options])` - - - -* `options` {Object} - * `fallthrough` {boolean} When `true`, operations on paths not in the VFS - fall through to the real file system. **Default:** `true`. - * `moduleHooks` {boolean} When `true`, enables hooks for `require()` and - `import` to load modules from the VFS. **Default:** `true`. - * `virtualCwd` {boolean} When `true`, enables virtual working directory - support via `vfs.chdir()` and `vfs.cwd()`. **Default:** `false`. -* Returns: {VirtualFileSystem} - -Creates a new virtual file system instance. - -```cjs -const fs = require('node:fs'); - -// Create a VFS that falls through to real fs for unmatched paths -const vfs = fs.createVirtual({ fallthrough: true }); - -// Create a VFS that only serves virtual files -const isolatedVfs = fs.createVirtual({ fallthrough: false }); - -// Create a VFS without module loading hooks (fs operations only) -const fsOnlyVfs = fs.createVirtual({ moduleHooks: false }); -``` - -### Class: `VirtualFileSystem` - - - -A `VirtualFileSystem` instance manages virtual files and directories and -provides methods to mount them into the file system namespace. - -#### `vfs.addFile(path, content)` - - - -* `path` {string} The virtual path for the file. -* `content` {string|Buffer|Function} The file content, or a function that - returns the content. - -Adds a virtual file. The `content` can be: - -* A `string` or `Buffer` for static content -* A synchronous function `() => string|Buffer` for dynamic content -* An async function `async () => string|Buffer` for async dynamic content - -```cjs -const fs = require('node:fs'); - -const vfs = fs.createVirtual(); - -// Static content -vfs.addFile('/config.json', '{"debug": true}'); - -// Dynamic content (evaluated on each read) -vfs.addFile('/timestamp.txt', () => Date.now().toString()); - -// Async dynamic content -vfs.addFile('/data.json', async () => { - const data = await fetchData(); - return JSON.stringify(data); -}); -``` - -#### `vfs.addDirectory(path[, populate])` - - - -* `path` {string} The virtual path for the directory. -* `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. - -```cjs -const fs = require('node:fs'); - -const vfs = fs.createVirtual(); - -// Empty directory -vfs.addDirectory('/empty'); - -// Directory with static contents -vfs.addDirectory('/lib'); -vfs.addFile('/lib/utils.js', 'module.exports = {}'); - -// Dynamic directory (populated on first access) -vfs.addDirectory('/plugins', (dir) => { - dir.addFile('a.js', 'module.exports = "plugin a"'); - dir.addFile('b.js', 'module.exports = "plugin b"'); -}); -``` - -#### `vfs.mount(prefix)` - - - -* `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. - -```cjs -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.overlay()` - - - -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). - -```cjs -const fs = require('node:fs'); - -const vfs = fs.createVirtual(); -vfs.addFile('/etc/myapp/config.json', '{"virtual": true}'); -vfs.overlay(); - -// Virtual file is returned -fs.readFileSync('/etc/myapp/config.json', 'utf8'); // '{"virtual": true}' - -// Real file system used for non-virtual paths -fs.readFileSync('/etc/hosts', 'utf8'); // Real file contents -``` - -#### `vfs.unmount()` - - - -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 -``` - -#### `vfs.has(path)` - - - -* `path` {string} The path to check. -* Returns: {boolean} - -Returns `true` if the VFS contains a file or directory at the given path. - -#### `vfs.remove(path)` - - - -* `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. - -#### `vfs.virtualCwdEnabled` - - - -* {boolean} - -Returns `true` if virtual working directory support is enabled for this VFS -instance. This is determined by the `virtualCwd` option passed to -`fs.createVirtual()`. - -#### `vfs.cwd()` - - - -* Returns: {string|null} The current virtual working directory, or `null` if - not set. - -Gets the virtual current working directory. Throws `ERR_INVALID_STATE` if -`virtualCwd` option was not enabled when creating the VFS. - -```cjs -const fs = require('node:fs'); - -const vfs = fs.createVirtual({ virtualCwd: true }); -vfs.addDirectory('/project'); -vfs.mount('/app'); - -console.log(vfs.cwd()); // null (not set yet) - -vfs.chdir('/app/project'); -console.log(vfs.cwd()); // '/app/project' -``` - -#### `vfs.chdir(path)` - - - -* `path` {string} The directory path to set as the current working directory. - -Sets the virtual current working directory. The path must exist in the VFS and -must be a directory. Throws `ENOENT` if the path does not exist, `ENOTDIR` if -the path is not a directory, or `ERR_INVALID_STATE` if `virtualCwd` option was -not enabled. - -```cjs -const fs = require('node:fs'); - -const vfs = fs.createVirtual({ virtualCwd: true }); -vfs.addDirectory('/project'); -vfs.addDirectory('/project/src'); -vfs.addFile('/project/src/index.js', 'module.exports = "hello";'); -vfs.mount('/app'); - -vfs.chdir('/app/project'); -console.log(vfs.cwd()); // '/app/project' - -vfs.chdir('/app/project/src'); -console.log(vfs.cwd()); // '/app/project/src' -``` - -##### `process.chdir()` and `process.cwd()` interception - -When `virtualCwd` is enabled and the VFS is mounted or in overlay mode, -`process.chdir()` and `process.cwd()` are intercepted to support transparent -virtual working directory operations: - -* `process.chdir(path)` - When called with a path that resolves to the VFS, - the virtual cwd is updated instead of changing the real process working - directory. Paths outside the VFS fall through to the real `process.chdir()`. - -* `process.cwd()` - When a virtual cwd is set, returns the virtual cwd. - Otherwise, returns the real process working directory. - -```cjs -const fs = require('node:fs'); - -const vfs = fs.createVirtual({ virtualCwd: true }); -vfs.addDirectory('/project'); -vfs.mount('/virtual'); - -const originalCwd = process.cwd(); - -// Change to a VFS directory using process.chdir -process.chdir('/virtual/project'); -console.log(process.cwd()); // '/virtual/project' -console.log(vfs.cwd()); // '/virtual/project' - -// Change to a real directory (falls through) -process.chdir('/tmp'); -console.log(process.cwd()); // '/tmp' (real cwd) - -// Restore and unmount -process.chdir(originalCwd); -vfs.unmount(); -``` - -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 -> worker thread has its own `process` object and must set up its own VFS -> instance if virtual cwd support is needed. - -#### `vfs.resolvePath(path)` - - - -* `path` {string} The path to resolve. -* Returns: {string} The resolved absolute path. - -Resolves a path relative to the virtual current working directory. If the path -is absolute, it is returned as-is (normalized). If `virtualCwd` is enabled and -a virtual cwd is set, relative paths are resolved against it. Otherwise, -relative paths are resolved using the real process working directory. - -```cjs -const fs = require('node:fs'); - -const vfs = fs.createVirtual({ virtualCwd: true }); -vfs.addDirectory('/project'); -vfs.addDirectory('/project/src'); -vfs.mount('/app'); - -vfs.chdir('/app/project'); - -// Absolute paths returned as-is -console.log(vfs.resolvePath('/other/path')); // '/other/path' - -// Relative paths resolved against virtual cwd -console.log(vfs.resolvePath('src/index.js')); // '/app/project/src/index.js' -console.log(vfs.resolvePath('./src/index.js')); // '/app/project/src/index.js' -``` - -### VFS file system operations - -The `VirtualFileSystem` instance provides direct access to file system -operations that bypass the real file system entirely. These methods have the -same signatures as their `fs` module counterparts. - -#### Synchronous methods - -* `vfs.readFileSync(path[, options])` - Read file contents -* `vfs.statSync(path[, options])` - Get file stats -* `vfs.lstatSync(path[, options])` - Get file stats (same as statSync for VFS) -* `vfs.readdirSync(path[, options])` - List directory contents -* `vfs.existsSync(path)` - Check if path exists -* `vfs.realpathSync(path[, options])` - Resolve path (normalizes `.` and `..`) -* `vfs.accessSync(path[, mode])` - Check file accessibility -* `vfs.openSync(path[, flags[, mode]])` - Open file and return file descriptor -* `vfs.closeSync(fd)` - Close file descriptor -* `vfs.readSync(fd, buffer, offset, length, position)` - Read from file descriptor -* `vfs.fstatSync(fd[, options])` - Get stats from file descriptor - -```cjs -const fs = require('node:fs'); - -const vfs = fs.createVirtual(); -vfs.addFile('/data.txt', 'Hello, World!'); - -// Direct VFS operations (no mounting required) -const content = vfs.readFileSync('/data.txt', 'utf8'); -const stats = vfs.statSync('/data.txt'); -console.log(content); // 'Hello, World!' -console.log(stats.size); // 13 -``` - -#### Callback methods - -* `vfs.readFile(path[, options], callback)` - Read file contents -* `vfs.stat(path[, options], callback)` - Get file stats -* `vfs.lstat(path[, options], callback)` - Get file stats -* `vfs.readdir(path[, options], callback)` - List directory contents -* `vfs.realpath(path[, options], callback)` - Resolve path -* `vfs.access(path[, mode], callback)` - Check file accessibility -* `vfs.open(path[, flags[, mode]], callback)` - Open file -* `vfs.close(fd, callback)` - Close file descriptor -* `vfs.read(fd, buffer, offset, length, position, callback)` - Read from fd -* `vfs.fstat(fd[, options], callback)` - Get stats from file descriptor - -```cjs -const fs = require('node:fs'); - -const vfs = fs.createVirtual(); -vfs.addFile('/async.txt', 'Async content'); - -vfs.readFile('/async.txt', 'utf8', (err, data) => { - if (err) throw err; - console.log(data); // 'Async content' -}); -``` - -#### Promise methods - -The `vfs.promises` object provides promise-based versions of the file system -methods: - -* `vfs.promises.readFile(path[, options])` - Read file contents -* `vfs.promises.stat(path[, options])` - Get file stats -* `vfs.promises.lstat(path[, options])` - Get file stats -* `vfs.promises.readdir(path[, options])` - List directory contents -* `vfs.promises.realpath(path[, options])` - Resolve path -* `vfs.promises.access(path[, mode])` - Check file accessibility - -```cjs -const fs = require('node:fs'); - -const vfs = fs.createVirtual(); -vfs.addFile('/promise.txt', 'Promise content'); - -(async () => { - const data = await vfs.promises.readFile('/promise.txt', 'utf8'); - console.log(data); // 'Promise content' -})(); -``` - -#### Streams - -* `vfs.createReadStream(path[, options])` - Create a readable stream - -```cjs -const fs = require('node:fs'); - -const vfs = fs.createVirtual(); -vfs.addFile('/stream.txt', 'Streaming content'); - -const stream = vfs.createReadStream('/stream.txt', { encoding: 'utf8' }); -stream.on('data', (chunk) => console.log(chunk)); -stream.on('end', () => console.log('Done')); -``` - -The readable stream supports the following options: - -* `encoding` {string} Character encoding for string output. -* `start` {integer} Byte position to start reading from. -* `end` {integer} Byte position to stop reading at (inclusive). -* `highWaterMark` {integer} Maximum number of bytes to buffer. -* `autoClose` {boolean} Automatically close the stream on end. **Default:** `true`. - -### Module loading from VFS - -Virtual files can be loaded as modules using `require()` or `import`. The VFS -integrates with the Node.js module loaders automatically when mounted or in -overlay mode. - -```cjs -const fs = require('node:fs'); - -const vfs = fs.createVirtual(); - -// Add a CommonJS module -vfs.addFile('/app/math.js', ` - module.exports = { - add: (a, b) => a + b, - multiply: (a, b) => a * b - }; -`); - -// Add a package.json -vfs.addFile('/app/package.json', '{"name": "virtual-app", "main": "math.js"}'); - -vfs.mount('/app'); - -// Require the virtual module -const math = require('/app/math.js'); -console.log(math.add(2, 3)); // 5 - -// Require the package -const pkg = require('/app'); -console.log(pkg.multiply(4, 5)); // 20 -``` - -```mjs -import fs from 'node:fs'; - -const vfs = fs.createVirtual(); - -// Add an ES module -vfs.addFile('/esm/module.mjs', ` - export const value = 42; - export default function greet() { return 'Hello'; } -`); - -vfs.mount('/esm'); - -// Dynamic import of virtual ES module -const mod = await import('/esm/module.mjs'); -console.log(mod.value); // 42 -console.log(mod.default()); // 'Hello' -``` - -### Glob support - -The VFS integrates with `fs.glob()`, `fs.globSync()`, and `fs/promises.glob()` -when mounted or in overlay mode: - -```cjs -const fs = require('node:fs'); - -const vfs = fs.createVirtual(); -vfs.addFile('/src/index.js', 'export default 1;'); -vfs.addFile('/src/utils.js', 'export const util = 1;'); -vfs.addFile('/src/lib/helper.js', 'export const helper = 1;'); -vfs.mount('/virtual'); - -// Sync glob -const files = fs.globSync('/virtual/src/**/*.js'); -console.log(files); -// ['/virtual/src/index.js', '/virtual/src/utils.js', '/virtual/src/lib/helper.js'] - -// Async glob with callback -fs.glob('/virtual/src/*.js', (err, matches) => { - console.log(matches); // ['/virtual/src/index.js', '/virtual/src/utils.js'] -}); - -// Async glob with promises (returns async iterator) -const { glob } = require('node:fs/promises'); -(async () => { - for await (const file of glob('/virtual/src/**/*.js')) { - console.log(file); - } -})(); -``` - -### Limitations - -The current VFS implementation has the following limitations: - -* **Read-only**: Files can only be set via `addFile()`. Write operations - (`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 - separately from real file descriptors. - ## Notes ### Ordering of callback and promise-based operations diff --git a/lib/fs.js b/lib/fs.js index 8e7bf9804a7082..3e3de359407b5e 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -3207,20 +3207,6 @@ function globSync(pattern, options) { return new Glob(pattern, options).globSync(); } -const lazyVfs = getLazy(() => require('internal/vfs/file_system').VirtualFileSystem); - -/** - * Creates a new virtual file system instance. - * @param {object} [options] Configuration options - * @param {boolean} [options.fallthrough] Whether to fall through to real fs on miss - * @param {boolean} [options.moduleHooks] Whether to enable require/import hooks - * @returns {VirtualFileSystem} - */ -function createVirtual(options) { - const VirtualFileSystem = lazyVfs(); - return new VirtualFileSystem(options); -} - module.exports = fs = { appendFile, appendFileSync, @@ -3237,7 +3223,6 @@ module.exports = fs = { cp, cpSync, createReadStream, - createVirtual, createWriteStream, exists, existsSync, diff --git a/test/parallel/test-vfs-basic.js b/test/parallel/test-vfs-basic.js index 1b667cee623d6e..d4096e4c0e66b8 100644 --- a/test/parallel/test-vfs-basic.js +++ b/test/parallel/test-vfs-basic.js @@ -2,11 +2,11 @@ require('../common'); const assert = require('assert'); -const fs = require('fs'); +const vfs = require('node:vfs'); -// Test that VirtualFileSystem can be created via fs.createVirtual() +// Test that VirtualFileSystem can be created via vfs.create() { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); assert.ok(myVfs); assert.strictEqual(typeof myVfs.writeFileSync, 'function'); assert.strictEqual(myVfs.mounted, false); @@ -14,7 +14,7 @@ const fs = require('fs'); // Test adding and reading a static file { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/test', { recursive: true }); myVfs.writeFileSync('/test/file.txt', 'hello world'); @@ -33,7 +33,7 @@ const fs = require('fs'); // Test statSync { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/test/dir', { recursive: true }); myVfs.writeFileSync('/test/file.txt', 'content'); @@ -54,7 +54,7 @@ const fs = require('fs'); // Test readdirSync { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/dir/subdir', { recursive: true }); myVfs.writeFileSync('/dir/a.txt', 'a'); myVfs.writeFileSync('/dir/b.txt', 'b'); @@ -89,7 +89,7 @@ const fs = require('fs'); // Test removing entries { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/test', { recursive: true }); myVfs.writeFileSync('/test/file.txt', 'content'); @@ -105,7 +105,7 @@ const fs = require('fs'); // Test mount mode { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/data', { recursive: true }); myVfs.writeFileSync('/data/file.txt', 'mounted content'); @@ -124,7 +124,7 @@ const fs = require('fs'); // Test internalModuleStat (used by Module._stat) { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/dir', { recursive: true }); myVfs.writeFileSync('/module.js', 'module.exports = {}'); @@ -135,7 +135,7 @@ const fs = require('fs'); // Test reading directory as file throws EISDIR { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/mydir', { recursive: true }); assert.throws(() => { @@ -145,7 +145,7 @@ const fs = require('fs'); // Test realpathSync { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/test', { recursive: true }); myVfs.writeFileSync('/test/file.txt', 'content'); diff --git a/test/parallel/test-vfs-chdir-worker.js b/test/parallel/test-vfs-chdir-worker.js index e8d0c880984ca5..ad6af0c169c521 100644 --- a/test/parallel/test-vfs-chdir-worker.js +++ b/test/parallel/test-vfs-chdir-worker.js @@ -2,15 +2,15 @@ const common = require('../common'); const assert = require('assert'); -const fs = require('fs'); +const vfs = require('node:vfs'); const { Worker, isMainThread, parentPort, workerData } = require('worker_threads'); if (isMainThread) { // Test 1: Verify that VFS setup in main thread doesn't automatically apply to workers { - const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.mkdirSync('/project', { recursive: true }); - vfs.mount('/virtual'); + const myVfs = vfs.create({ virtualCwd: true }); + myVfs.mkdirSync('/project', { recursive: true }); + myVfs.mount('/virtual'); // Set virtual cwd in main thread process.chdir('/virtual/project'); @@ -29,7 +29,7 @@ if (isMainThread) { worker.on('exit', common.mustCall((code) => { assert.strictEqual(code, 0); - vfs.unmount(); + myVfs.unmount(); })); } @@ -75,30 +75,30 @@ if (isMainThread) { parentPort.postMessage({ cwd: process.cwd() }); } else if (test === 'worker-independent-vfs') { // Set up VFS independently in worker - const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.mkdirSync('/data', { recursive: true }); - vfs.mount('/worker-virtual'); + const myVfs = vfs.create({ virtualCwd: true }); + myVfs.mkdirSync('/data', { recursive: true }); + myVfs.mount('/worker-virtual'); process.chdir('/worker-virtual/data'); const cwd = process.cwd(); - vfs.unmount(); + myVfs.unmount(); parentPort.postMessage({ success: true, cwd }); } else if (test === 'worker-create-vfs') { // Test VFS creation and chdir in worker - const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.mkdirSync('/project/src', { recursive: true }); - vfs.mount('/'); + const myVfs = vfs.create({ virtualCwd: true }); + myVfs.mkdirSync('/project/src', { recursive: true }); + myVfs.mount('/'); - vfs.chdir('/project/src'); + myVfs.chdir('/project/src'); parentPort.postMessage({ success: true, - virtualCwdEnabled: vfs.virtualCwdEnabled, - vfsCwd: vfs.cwd(), + virtualCwdEnabled: myVfs.virtualCwdEnabled, + vfsCwd: myVfs.cwd(), }); - vfs.unmount(); + myVfs.unmount(); } } diff --git a/test/parallel/test-vfs-chdir.js b/test/parallel/test-vfs-chdir.js index fc5ea4c11b7d32..9f5018a1c6ba4c 100644 --- a/test/parallel/test-vfs-chdir.js +++ b/test/parallel/test-vfs-chdir.js @@ -3,135 +3,136 @@ require('../common'); const assert = require('assert'); const fs = require('fs'); +const vfs = require('node:vfs'); // Test that virtualCwd option is disabled by default { - const vfs = fs.createVirtual(); - assert.strictEqual(vfs.virtualCwdEnabled, false); + const myVfs = vfs.create(); + assert.strictEqual(myVfs.virtualCwdEnabled, false); // Should throw when trying to use cwd() without enabling assert.throws(() => { - vfs.cwd(); + myVfs.cwd(); }, { code: 'ERR_INVALID_STATE' }); // Should throw when trying to use chdir() without enabling assert.throws(() => { - vfs.chdir('/'); + myVfs.chdir('/'); }, { code: 'ERR_INVALID_STATE' }); } // Test that virtualCwd option can be enabled { - const vfs = fs.createVirtual({ virtualCwd: true }); - assert.strictEqual(vfs.virtualCwdEnabled, true); + const myVfs = vfs.create({ virtualCwd: true }); + assert.strictEqual(myVfs.virtualCwdEnabled, true); // Initial cwd should be null - assert.strictEqual(vfs.cwd(), null); + assert.strictEqual(myVfs.cwd(), null); } // Test basic chdir functionality { - const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.mkdirSync('/project/src', { recursive: true }); - vfs.writeFileSync('/project/src/index.js', 'module.exports = "hello";'); - vfs.mount('/virtual'); + const myVfs = vfs.create({ virtualCwd: true }); + myVfs.mkdirSync('/project/src', { recursive: true }); + myVfs.writeFileSync('/project/src/index.js', 'module.exports = "hello";'); + myVfs.mount('/virtual'); // Change to a directory that exists - vfs.chdir('/virtual/project'); - assert.strictEqual(vfs.cwd(), '/virtual/project'); + myVfs.chdir('/virtual/project'); + assert.strictEqual(myVfs.cwd(), '/virtual/project'); // Change to a subdirectory - vfs.chdir('/virtual/project/src'); - assert.strictEqual(vfs.cwd(), '/virtual/project/src'); + myVfs.chdir('/virtual/project/src'); + assert.strictEqual(myVfs.cwd(), '/virtual/project/src'); - vfs.unmount(); + myVfs.unmount(); } // Test chdir with non-existent path throws ENOENT { - const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.mkdirSync('/project', { recursive: true }); - vfs.mount('/virtual'); + const myVfs = vfs.create({ virtualCwd: true }); + myVfs.mkdirSync('/project', { recursive: true }); + myVfs.mount('/virtual'); assert.throws(() => { - vfs.chdir('/virtual/nonexistent'); + myVfs.chdir('/virtual/nonexistent'); }, { code: 'ENOENT' }); - vfs.unmount(); + myVfs.unmount(); } // Test chdir with file path throws ENOTDIR { - const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.writeFileSync('/file.txt', 'content'); - vfs.mount('/virtual'); + const myVfs = vfs.create({ virtualCwd: true }); + myVfs.writeFileSync('/file.txt', 'content'); + myVfs.mount('/virtual'); assert.throws(() => { - vfs.chdir('/virtual/file.txt'); + myVfs.chdir('/virtual/file.txt'); }, { code: 'ENOTDIR' }); - vfs.unmount(); + myVfs.unmount(); } // Test resolvePath with virtual cwd { - const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.mkdirSync('/project/src', { recursive: true }); - vfs.writeFileSync('/project/src/index.js', 'module.exports = "hello";'); - vfs.mount('/virtual'); + const myVfs = vfs.create({ virtualCwd: true }); + myVfs.mkdirSync('/project/src', { recursive: true }); + myVfs.writeFileSync('/project/src/index.js', 'module.exports = "hello";'); + myVfs.mount('/virtual'); // Before setting cwd, relative paths use real cwd - const resolvedBefore = vfs.resolvePath('test.js'); + const resolvedBefore = myVfs.resolvePath('test.js'); assert.ok(resolvedBefore.endsWith('test.js')); // Set virtual cwd - vfs.chdir('/virtual/project'); + myVfs.chdir('/virtual/project'); // Absolute paths are returned as-is - assert.strictEqual(vfs.resolvePath('/absolute/path'), '/absolute/path'); + assert.strictEqual(myVfs.resolvePath('/absolute/path'), '/absolute/path'); // Relative paths are resolved relative to virtual cwd - assert.strictEqual(vfs.resolvePath('src/index.js'), '/virtual/project/src/index.js'); - assert.strictEqual(vfs.resolvePath('./src/index.js'), '/virtual/project/src/index.js'); + assert.strictEqual(myVfs.resolvePath('src/index.js'), '/virtual/project/src/index.js'); + assert.strictEqual(myVfs.resolvePath('./src/index.js'), '/virtual/project/src/index.js'); // Change to subdirectory and resolve again - vfs.chdir('/virtual/project/src'); - assert.strictEqual(vfs.resolvePath('index.js'), '/virtual/project/src/index.js'); + myVfs.chdir('/virtual/project/src'); + assert.strictEqual(myVfs.resolvePath('index.js'), '/virtual/project/src/index.js'); - vfs.unmount(); + myVfs.unmount(); } // Test resolvePath without virtual cwd enabled { - const vfs = fs.createVirtual({ virtualCwd: false }); - vfs.mount('/virtual'); + const myVfs = vfs.create({ virtualCwd: false }); + myVfs.mount('/virtual'); // Should still work, but uses real cwd for relative paths - const resolved = vfs.resolvePath('/absolute/path'); + const resolved = myVfs.resolvePath('/absolute/path'); assert.strictEqual(resolved, '/absolute/path'); - vfs.unmount(); + myVfs.unmount(); } // Test process.chdir() interception { - const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.mkdirSync('/project/src', { recursive: true }); - vfs.mount('/virtual'); + const myVfs = vfs.create({ virtualCwd: true }); + myVfs.mkdirSync('/project/src', { recursive: true }); + myVfs.mount('/virtual'); const originalCwd = process.cwd(); // process.chdir to VFS path process.chdir('/virtual/project'); assert.strictEqual(process.cwd(), '/virtual/project'); - assert.strictEqual(vfs.cwd(), '/virtual/project'); + assert.strictEqual(myVfs.cwd(), '/virtual/project'); // process.chdir to another VFS path process.chdir('/virtual/project/src'); assert.strictEqual(process.cwd(), '/virtual/project/src'); - assert.strictEqual(vfs.cwd(), '/virtual/project/src'); + assert.strictEqual(myVfs.cwd(), '/virtual/project/src'); - vfs.unmount(); + myVfs.unmount(); // After unmount, process.cwd should return original cwd assert.strictEqual(process.cwd(), originalCwd); @@ -139,9 +140,9 @@ const fs = require('fs'); // Test process.chdir() to real path falls through { - const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.mkdirSync('/project', { recursive: true }); - vfs.mount('/virtual'); + const myVfs = vfs.create({ virtualCwd: true }); + myVfs.mkdirSync('/project', { recursive: true }); + myVfs.mount('/virtual'); const originalCwd = process.cwd(); @@ -150,20 +151,20 @@ const fs = require('fs'); const tmpDir = fs.realpathSync('/tmp'); process.chdir('/tmp'); assert.strictEqual(process.cwd(), tmpDir); - // vfs.cwd() should still be null (not set) - assert.strictEqual(vfs.cwd(), null); + // myVfs.cwd() should still be null (not set) + assert.strictEqual(myVfs.cwd(), null); // Change back to original process.chdir(originalCwd); - vfs.unmount(); + myVfs.unmount(); } // Test process.cwd() returns virtual cwd when set { - const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.mkdirSync('/project', { recursive: true }); - vfs.mount('/virtual'); + const myVfs = vfs.create({ virtualCwd: true }); + myVfs.mkdirSync('/project', { recursive: true }); + myVfs.mount('/virtual'); const originalCwd = process.cwd(); @@ -171,10 +172,10 @@ const fs = require('fs'); assert.strictEqual(process.cwd(), originalCwd); // Set virtual cwd - vfs.chdir('/virtual/project'); + myVfs.chdir('/virtual/project'); assert.strictEqual(process.cwd(), '/virtual/project'); - vfs.unmount(); + myVfs.unmount(); // After unmount, returns real cwd assert.strictEqual(process.cwd(), originalCwd); @@ -185,33 +186,33 @@ const fs = require('fs'); const originalChdir = process.chdir; const originalCwd = process.cwd; - const vfs = fs.createVirtual({ virtualCwd: false }); - vfs.mkdirSync('/project', { recursive: true }); - vfs.mount('/virtual'); + const myVfs = vfs.create({ virtualCwd: false }); + myVfs.mkdirSync('/project', { recursive: true }); + myVfs.mount('/virtual'); // process.chdir and process.cwd should not be modified assert.strictEqual(process.chdir, originalChdir); assert.strictEqual(process.cwd, originalCwd); - vfs.unmount(); + myVfs.unmount(); } // Test virtual cwd is reset on unmount { - const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.mkdirSync('/project', { recursive: true }); - vfs.mount('/virtual'); + const myVfs = vfs.create({ virtualCwd: true }); + myVfs.mkdirSync('/project', { recursive: true }); + myVfs.mount('/virtual'); - vfs.chdir('/virtual/project'); - assert.strictEqual(vfs.cwd(), '/virtual/project'); + myVfs.chdir('/virtual/project'); + assert.strictEqual(myVfs.cwd(), '/virtual/project'); - vfs.unmount(); + myVfs.unmount(); // After unmount, cwd should throw (not enabled) // Actually, virtualCwdEnabled is still true, just unmounted // Let's remount and check cwd is reset - vfs.mount('/virtual'); - assert.strictEqual(vfs.cwd(), null); + myVfs.mount('/virtual'); + assert.strictEqual(myVfs.cwd(), null); - vfs.unmount(); + myVfs.unmount(); } diff --git a/test/parallel/test-vfs-fd.js b/test/parallel/test-vfs-fd.js index 9e971a16a340a3..cd3dbe38b7a974 100644 --- a/test/parallel/test-vfs-fd.js +++ b/test/parallel/test-vfs-fd.js @@ -2,11 +2,11 @@ const common = require('../common'); const assert = require('assert'); -const fs = require('fs'); +const vfs = require('node:vfs'); // Test openSync and closeSync { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/file.txt', 'hello world'); const fd = myVfs.openSync('/file.txt'); @@ -16,7 +16,7 @@ const fs = require('fs'); // Test openSync with non-existent file { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); assert.throws(() => { myVfs.openSync('/nonexistent.txt'); @@ -25,7 +25,7 @@ const fs = require('fs'); // Test openSync with directory { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/mydir', { recursive: true }); assert.throws(() => { @@ -35,7 +35,7 @@ const fs = require('fs'); // Test closeSync with invalid fd { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); assert.throws(() => { myVfs.closeSync(12345); @@ -44,7 +44,7 @@ const fs = require('fs'); // Test readSync { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/file.txt', 'hello world'); const fd = myVfs.openSync('/file.txt'); @@ -59,7 +59,7 @@ const fs = require('fs'); // Test readSync with position tracking { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/file.txt', 'hello world'); const fd = myVfs.openSync('/file.txt'); @@ -81,7 +81,7 @@ const fs = require('fs'); // Test readSync with explicit position { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/file.txt', 'hello world'); const fd = myVfs.openSync('/file.txt'); @@ -97,7 +97,7 @@ const fs = require('fs'); // Test readSync at end of file { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/file.txt', 'short'); const fd = myVfs.openSync('/file.txt'); @@ -112,7 +112,7 @@ const fs = require('fs'); // Test readSync with invalid fd { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); const buffer = Buffer.alloc(10); assert.throws(() => { @@ -122,7 +122,7 @@ const fs = require('fs'); // Test fstatSync { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/file.txt', 'hello world'); const fd = myVfs.openSync('/file.txt'); @@ -137,7 +137,7 @@ const fs = require('fs'); // Test fstatSync with invalid fd { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); assert.throws(() => { myVfs.fstatSync(99999); @@ -146,7 +146,7 @@ const fs = require('fs'); // Test async open and close { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/async-file.txt', 'async content'); myVfs.open('/async-file.txt', common.mustCall((err, fd) => { @@ -161,7 +161,7 @@ const fs = require('fs'); // Test async open with error { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.open('/nonexistent.txt', common.mustCall((err, fd) => { assert.strictEqual(err.code, 'ENOENT'); @@ -171,7 +171,7 @@ const fs = require('fs'); // Test async close with invalid fd { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.close(99999, common.mustCall((err) => { assert.strictEqual(err.code, 'EBADF'); @@ -180,7 +180,7 @@ const fs = require('fs'); // Test async read { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/read-test.txt', 'read content'); myVfs.open('/read-test.txt', common.mustCall((err, fd) => { @@ -200,7 +200,7 @@ const fs = require('fs'); // Test async read with position tracking { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/track-test.txt', 'ABCDEFGHIJ'); myVfs.open('/track-test.txt', common.mustCall((err, fd) => { @@ -228,7 +228,7 @@ const fs = require('fs'); // Test async read with invalid fd { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); const buffer = Buffer.alloc(10); myVfs.read(99999, buffer, 0, 10, 0, common.mustCall((err, bytesRead, buf) => { @@ -238,7 +238,7 @@ const fs = require('fs'); // Test async fstat { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/fstat-test.txt', '12345'); myVfs.open('/fstat-test.txt', common.mustCall((err, fd) => { @@ -256,7 +256,7 @@ const fs = require('fs'); // Test async fstat with invalid fd { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.fstat(99999, common.mustCall((err, stats) => { assert.strictEqual(err.code, 'EBADF'); @@ -265,8 +265,8 @@ const fs = require('fs'); // Test that separate VFS instances have separate fd spaces { - const vfs1 = fs.createVirtual(); - const vfs2 = fs.createVirtual(); + const vfs1 = vfs.create(); + const vfs2 = vfs.create(); vfs1.writeFileSync('/file1.txt', 'content1'); vfs2.writeFileSync('/file2.txt', 'content2'); @@ -296,7 +296,7 @@ const fs = require('fs'); // Test multiple opens of same file { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/multi.txt', 'multi content'); const fd1 = myVfs.openSync('/multi.txt'); diff --git a/test/parallel/test-vfs-glob.js b/test/parallel/test-vfs-glob.js index a3a13440284674..8475e9aeca4257 100644 --- a/test/parallel/test-vfs-glob.js +++ b/test/parallel/test-vfs-glob.js @@ -3,10 +3,11 @@ const common = require('../common'); const assert = require('assert'); const fs = require('fs'); +const vfs = require('node:vfs'); // Test globSync with VFS mounted directory { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/src/lib/deep', { recursive: true }); myVfs.mkdirSync('/src/empty', { recursive: true }); myVfs.writeFileSync('/src/index.js', 'export default 1;'); @@ -40,7 +41,7 @@ const fs = require('fs'); // Test async glob (callback API) with VFS mounted directory { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/async-src/lib', { recursive: true }); myVfs.writeFileSync('/async-src/index.js', 'export default 1;'); myVfs.writeFileSync('/async-src/utils.js', 'export const util = 1;'); @@ -68,7 +69,7 @@ const fs = require('fs'); // Test async glob (promise API) with VFS (async () => { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/promise-src', { recursive: true }); myVfs.writeFileSync('/promise-src/a.ts', 'const a = 1;'); myVfs.writeFileSync('/promise-src/b.ts', 'const b = 2;'); @@ -98,7 +99,7 @@ const fs = require('fs'); // Test glob with withFileTypes option { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/typed/subdir', { recursive: true }); myVfs.writeFileSync('/typed/file.txt', 'text'); myVfs.writeFileSync('/typed/subdir/nested.txt', 'nested'); @@ -122,7 +123,7 @@ const fs = require('fs'); // Test glob with multiple patterns { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/multi', { recursive: true }); myVfs.writeFileSync('/multi/a.js', 'a'); myVfs.writeFileSync('/multi/b.ts', 'b'); @@ -139,7 +140,7 @@ const fs = require('fs'); // Test that unmounting stops glob from finding VFS files { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/unmount-test', { recursive: true }); myVfs.writeFileSync('/unmount-test/file.js', 'content'); myVfs.mount('/unmount-glob'); @@ -155,7 +156,7 @@ const fs = require('fs'); // Test glob pattern that doesn't match anything { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/nomatch', { recursive: true }); myVfs.writeFileSync('/nomatch/file.txt', 'content'); myVfs.mount('/nomatchvfs'); @@ -168,7 +169,7 @@ const fs = require('fs'); // Test cwd option with VFS (relative patterns) { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/cwd-test', { recursive: true }); myVfs.writeFileSync('/cwd-test/a.js', 'a'); myVfs.writeFileSync('/cwd-test/b.js', 'b'); diff --git a/test/parallel/test-vfs-import.mjs b/test/parallel/test-vfs-import.mjs index cb254c96724e17..0049d31224fece 100644 --- a/test/parallel/test-vfs-import.mjs +++ b/test/parallel/test-vfs-import.mjs @@ -1,10 +1,10 @@ import '../common/index.mjs'; import assert from 'assert'; -import fs from 'fs'; +import vfs from 'node:vfs'; // Test importing a simple virtual ES module { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/hello.mjs', 'export const message = "hello from vfs";'); myVfs.mount('/virtual'); @@ -16,7 +16,7 @@ import fs from 'fs'; // Test importing a virtual module with default export { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/default.mjs', 'export default { name: "test", value: 42 };'); myVfs.mount('/virtual2'); @@ -29,7 +29,7 @@ import fs from 'fs'; // Test importing a virtual module that imports another virtual module { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/utils.mjs', 'export function add(a, b) { return a + b; }'); myVfs.writeFileSync('/main.mjs', ` import { add } from '/virtual3/utils.mjs'; @@ -45,7 +45,7 @@ import fs from 'fs'; // Test importing with relative paths { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/lib', { recursive: true }); myVfs.writeFileSync('/lib/helper.mjs', 'export const helper = () => "helped";'); myVfs.writeFileSync('/lib/index.mjs', ` @@ -62,7 +62,7 @@ import fs from 'fs'; // Test importing JSON from VFS (with import assertion) { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/data.json', JSON.stringify({ items: [1, 2, 3], enabled: true })); myVfs.mount('/virtual5'); @@ -75,7 +75,7 @@ import fs from 'fs'; // Test that real modules still work when VFS is mounted { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/test.mjs', 'export const x = 1;'); myVfs.mount('/virtual6'); @@ -88,7 +88,7 @@ import fs from 'fs'; // Test mixed CJS and ESM - ESM importing from VFS while CJS also works { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/esm-module.mjs', 'export const esmValue = "esm";'); myVfs.writeFileSync('/cjs-module.js', 'module.exports = { cjsValue: "cjs" };'); myVfs.mount('/virtual8'); diff --git a/test/parallel/test-vfs-promises.js b/test/parallel/test-vfs-promises.js index bb4d583083e9a8..980cc718716f50 100644 --- a/test/parallel/test-vfs-promises.js +++ b/test/parallel/test-vfs-promises.js @@ -2,11 +2,11 @@ const common = require('../common'); const assert = require('assert'); -const fs = require('fs'); +const vfs = require('node:vfs'); // Test callback-based readFile { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/test.txt', 'hello world'); myVfs.readFile('/test.txt', common.mustCall((err, data) => { @@ -28,7 +28,7 @@ const fs = require('fs'); // Test callback-based readFile with non-existent file { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.readFile('/nonexistent.txt', common.mustCall((err, data) => { assert.strictEqual(err.code, 'ENOENT'); @@ -38,7 +38,7 @@ const fs = require('fs'); // Test callback-based readFile with directory { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/mydir', { recursive: true }); myVfs.readFile('/mydir', common.mustCall((err, data) => { @@ -49,7 +49,7 @@ const fs = require('fs'); // Test callback-based stat { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/dir', { recursive: true }); myVfs.writeFileSync('/file.txt', 'content'); @@ -74,7 +74,7 @@ const fs = require('fs'); // Test callback-based lstat (same as stat for VFS) { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/file.txt', 'content'); myVfs.lstat('/file.txt', common.mustCall((err, stats) => { @@ -85,7 +85,7 @@ const fs = require('fs'); // Test callback-based readdir { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/dir/subdir', { recursive: true }); myVfs.writeFileSync('/dir/file1.txt', 'a'); myVfs.writeFileSync('/dir/file2.txt', 'b'); @@ -121,7 +121,7 @@ const fs = require('fs'); // Test callback-based realpath { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/path/to', { recursive: true }); myVfs.writeFileSync('/path/to/file.txt', 'content'); @@ -143,7 +143,7 @@ const fs = require('fs'); // Test callback-based access { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/accessible.txt', 'content'); myVfs.access('/accessible.txt', common.mustCall((err) => { @@ -159,7 +159,7 @@ const fs = require('fs'); // Test promises.readFile (async () => { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/promise-test.txt', 'promise content'); const bufferData = await myVfs.promises.readFile('/promise-test.txt'); @@ -186,7 +186,7 @@ const fs = require('fs'); // Test promises.stat (async () => { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/stat-dir', { recursive: true }); myVfs.writeFileSync('/stat-file.txt', 'hello'); @@ -205,7 +205,7 @@ const fs = require('fs'); // Test promises.lstat (async () => { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/lstat-file.txt', 'content'); const stats = await myVfs.promises.lstat('/lstat-file.txt'); @@ -214,7 +214,7 @@ const fs = require('fs'); // Test promises.readdir (async () => { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/pdir/sub', { recursive: true }); myVfs.writeFileSync('/pdir/a.txt', 'a'); myVfs.writeFileSync('/pdir/b.txt', 'b'); @@ -240,7 +240,7 @@ const fs = require('fs'); // Test promises.realpath (async () => { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/real/path', { recursive: true }); myVfs.writeFileSync('/real/path/file.txt', 'content'); @@ -258,7 +258,7 @@ const fs = require('fs'); // Test promises.access (async () => { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/access-test.txt', 'content'); await myVfs.promises.access('/access-test.txt'); diff --git a/test/parallel/test-vfs-provider-memory.js b/test/parallel/test-vfs-provider-memory.js index 57949233d8696e..19697177be044e 100644 --- a/test/parallel/test-vfs-provider-memory.js +++ b/test/parallel/test-vfs-provider-memory.js @@ -2,11 +2,11 @@ const common = require('../common'); const assert = require('assert'); -const fs = require('fs'); +const vfs = require('node:vfs'); // Test copyFileSync { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/source.txt', 'original content'); myVfs.copyFileSync('/source.txt', '/dest.txt'); @@ -28,7 +28,7 @@ const fs = require('fs'); // Test async copyFile (async () => { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/async-source.txt', 'async content'); await myVfs.promises.copyFile('/async-source.txt', '/async-dest.txt'); @@ -43,7 +43,7 @@ const fs = require('fs'); // Test copyFileSync with mode argument { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/src-mode.txt', 'mode content'); // copyFileSync also accepts a mode argument (ignored for VFS but tests the code path) @@ -53,7 +53,7 @@ const fs = require('fs'); // Test appendFileSync { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/append.txt', 'hello'); myVfs.appendFileSync('/append.txt', ' world'); @@ -70,7 +70,7 @@ const fs = require('fs'); // Test async appendFile (async () => { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/async-append.txt', 'start'); await myVfs.promises.appendFile('/async-append.txt', '-end'); @@ -79,7 +79,7 @@ const fs = require('fs'); // Test appendFileSync with Buffer { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/buffer-append.txt', Buffer.from('start')); myVfs.appendFileSync('/buffer-append.txt', Buffer.from('-buffer')); @@ -88,7 +88,7 @@ const fs = require('fs'); // Test MemoryProvider readonly mode { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/file.txt', 'content'); myVfs.mkdirSync('/dir', { recursive: true }); @@ -141,7 +141,7 @@ const fs = require('fs'); // Test async operations on readonly VFS (async () => { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/readonly.txt', 'content'); myVfs.provider.setReadOnly(); @@ -173,7 +173,7 @@ const fs = require('fs'); // Test accessSync { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/access-test.txt', 'content'); // Should not throw for existing file diff --git a/test/parallel/test-vfs-require.js b/test/parallel/test-vfs-require.js index f343c4d2d565a4..7aa26fe931a944 100644 --- a/test/parallel/test-vfs-require.js +++ b/test/parallel/test-vfs-require.js @@ -3,13 +3,14 @@ require('../common'); const assert = require('assert'); const fs = require('fs'); +const vfs = require('node:vfs'); // Test requiring a simple virtual module // VFS internal path: /hello.js // Mount point: /virtual // External path: /virtual/hello.js { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/hello.js', 'module.exports = "hello from vfs";'); myVfs.mount('/virtual'); @@ -21,7 +22,7 @@ const fs = require('fs'); // Test requiring a virtual module that exports an object { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/config.js', ` module.exports = { name: 'test-config', @@ -41,7 +42,7 @@ const fs = require('fs'); // Test requiring a virtual module that requires another virtual module { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/utils.js', ` module.exports = { add: function(a, b) { return a + b; } @@ -63,7 +64,7 @@ const fs = require('fs'); // Test requiring a JSON file from VFS { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/data.json', JSON.stringify({ items: [1, 2, 3], enabled: true, @@ -79,7 +80,7 @@ const fs = require('fs'); // Test virtual package.json resolution { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/my-package', { recursive: true }); myVfs.writeFileSync('/my-package/package.json', JSON.stringify({ name: 'my-package', @@ -98,7 +99,7 @@ const fs = require('fs'); // Test that real modules still work when VFS is mounted { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/test.js', 'module.exports = 1;'); myVfs.mount('/virtual6'); @@ -114,7 +115,7 @@ const fs = require('fs'); // Test require with relative paths inside VFS module { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.mkdirSync('/lib', { recursive: true }); myVfs.writeFileSync('/lib/helper.js', ` module.exports = { help: function() { return 'helped'; } }; @@ -133,7 +134,7 @@ const fs = require('fs'); // Test fs.readFileSync interception when VFS is active { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/file.txt', 'virtual content'); myVfs.mount('/virtual9'); @@ -145,7 +146,7 @@ const fs = require('fs'); // Test that unmounting stops interception { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/unmount-test.js', 'module.exports = "before unmount";'); myVfs.mount('/virtual10'); diff --git a/test/parallel/test-vfs-streams.js b/test/parallel/test-vfs-streams.js index 6675caee7521fa..775b20e65b080e 100644 --- a/test/parallel/test-vfs-streams.js +++ b/test/parallel/test-vfs-streams.js @@ -2,11 +2,11 @@ const common = require('../common'); const assert = require('assert'); -const fs = require('fs'); +const vfs = require('node:vfs'); // Test basic createReadStream { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/file.txt', 'hello world'); const stream = myVfs.createReadStream('/file.txt'); @@ -31,7 +31,7 @@ const fs = require('fs'); // Test createReadStream with encoding { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/encoded.txt', 'encoded content'); const stream = myVfs.createReadStream('/encoded.txt', { encoding: 'utf8' }); @@ -53,7 +53,7 @@ const fs = require('fs'); // Test createReadStream with start and end { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/range.txt', '0123456789'); const stream = myVfs.createReadStream('/range.txt', { @@ -74,7 +74,7 @@ const fs = require('fs'); // Test createReadStream with start only { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/start.txt', 'abcdefghij'); const stream = myVfs.createReadStream('/start.txt', { start: 5 }); @@ -91,7 +91,7 @@ const fs = require('fs'); // Test createReadStream with non-existent file { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); const stream = myVfs.createReadStream('/nonexistent.txt'); @@ -102,7 +102,7 @@ const fs = require('fs'); // Test createReadStream path property { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/path-test.txt', 'test'); const stream = myVfs.createReadStream('/path-test.txt'); @@ -114,7 +114,7 @@ const fs = require('fs'); // Test createReadStream with small highWaterMark { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/small-hwm.txt', 'AAAABBBBCCCCDDDD'); const stream = myVfs.createReadStream('/small-hwm.txt', { @@ -135,7 +135,7 @@ const fs = require('fs'); // Test createReadStream destroy { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/destroy.txt', 'content to destroy'); const stream = myVfs.createReadStream('/destroy.txt'); @@ -149,7 +149,7 @@ const fs = require('fs'); // Test createReadStream with large file { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); const largeContent = 'X'.repeat(100000); myVfs.writeFileSync('/large.txt', largeContent); @@ -167,7 +167,7 @@ const fs = require('fs'); // Test createReadStream pipe to another stream { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); const { Writable } = require('stream'); myVfs.writeFileSync('/pipe-source.txt', 'pipe this content'); @@ -191,7 +191,7 @@ const fs = require('fs'); // Test autoClose: false { - const myVfs = fs.createVirtual(); + const myVfs = vfs.create(); myVfs.writeFileSync('/no-auto-close.txt', 'content'); const stream = myVfs.createReadStream('/no-auto-close.txt', { diff --git a/test/parallel/test-vfs-symlinks.js b/test/parallel/test-vfs-symlinks.js index c68e47a7e872d4..a9526395bcb0b8 100644 --- a/test/parallel/test-vfs-symlinks.js +++ b/test/parallel/test-vfs-symlinks.js @@ -2,44 +2,44 @@ const common = require('../common'); const assert = require('assert'); -const fs = require('fs'); +const vfs = require('node:vfs'); // Test basic symlink creation { - const vfs = fs.createVirtual(); - vfs.writeFileSync('/target.txt', 'Hello, World!'); - vfs.symlinkSync('/target.txt', '/link.txt'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.writeFileSync('/target.txt', 'Hello, World!'); + myVfs.symlinkSync('/target.txt', '/link.txt'); + myVfs.mount('/virtual'); // Verify symlink exists - assert.strictEqual(vfs.existsSync('/virtual/link.txt'), true); + assert.strictEqual(myVfs.existsSync('/virtual/link.txt'), true); - vfs.unmount(); + myVfs.unmount(); } // Test reading file through symlink { - const vfs = fs.createVirtual(); - vfs.mkdirSync('/data', { recursive: true }); - vfs.writeFileSync('/data/file.txt', 'File content'); - vfs.symlinkSync('/data/file.txt', '/shortcut'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.mkdirSync('/data', { recursive: true }); + myVfs.writeFileSync('/data/file.txt', 'File content'); + myVfs.symlinkSync('/data/file.txt', '/shortcut'); + myVfs.mount('/virtual'); - const content = vfs.readFileSync('/virtual/shortcut', 'utf8'); + const content = myVfs.readFileSync('/virtual/shortcut', 'utf8'); assert.strictEqual(content, 'File content'); - vfs.unmount(); + myVfs.unmount(); } // Test statSync follows symlinks (returns target's stats) { - const vfs = fs.createVirtual(); - vfs.writeFileSync('/real.txt', 'x'.repeat(100)); - vfs.symlinkSync('/real.txt', '/link.txt'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.writeFileSync('/real.txt', 'x'.repeat(100)); + myVfs.symlinkSync('/real.txt', '/link.txt'); + myVfs.mount('/virtual'); - const statLink = vfs.statSync('/virtual/link.txt'); - const statReal = vfs.statSync('/virtual/real.txt'); + const statLink = myVfs.statSync('/virtual/link.txt'); + const statReal = myVfs.statSync('/virtual/real.txt'); // Both should have the same size (the file's size) assert.strictEqual(statLink.size, 100); @@ -49,17 +49,17 @@ const fs = require('fs'); assert.strictEqual(statLink.isFile(), true); assert.strictEqual(statLink.isSymbolicLink(), false); - vfs.unmount(); + myVfs.unmount(); } // Test lstatSync does NOT follow symlinks { - const vfs = fs.createVirtual(); - vfs.writeFileSync('/real.txt', 'x'.repeat(100)); - vfs.symlinkSync('/real.txt', '/link.txt'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.writeFileSync('/real.txt', 'x'.repeat(100)); + myVfs.symlinkSync('/real.txt', '/link.txt'); + myVfs.mount('/virtual'); - const lstat = vfs.lstatSync('/virtual/link.txt'); + const lstat = myVfs.lstatSync('/virtual/link.txt'); // Lstat should show it's a symlink assert.strictEqual(lstat.isSymbolicLink(), true); @@ -68,135 +68,135 @@ const fs = require('fs'); // Size should be the length of the target path assert.strictEqual(lstat.size, '/real.txt'.length); - vfs.unmount(); + myVfs.unmount(); } // Test readlinkSync returns symlink target { - const vfs = fs.createVirtual(); - vfs.writeFileSync('/target.txt', 'content'); - vfs.symlinkSync('/target.txt', '/link.txt'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.writeFileSync('/target.txt', 'content'); + myVfs.symlinkSync('/target.txt', '/link.txt'); + myVfs.mount('/virtual'); - const target = vfs.readlinkSync('/virtual/link.txt'); + const target = myVfs.readlinkSync('/virtual/link.txt'); assert.strictEqual(target, '/target.txt'); - vfs.unmount(); + myVfs.unmount(); } // Test readlinkSync throws EINVAL for non-symlinks { - const vfs = fs.createVirtual(); - vfs.writeFileSync('/file.txt', 'content'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'content'); + myVfs.mount('/virtual'); assert.throws(() => { - vfs.readlinkSync('/virtual/file.txt'); + myVfs.readlinkSync('/virtual/file.txt'); }, { code: 'EINVAL' }); - vfs.unmount(); + myVfs.unmount(); } // Test symlink to directory { - const vfs = fs.createVirtual(); - vfs.mkdirSync('/data', { recursive: true }); - vfs.writeFileSync('/data/file.txt', 'content'); - vfs.symlinkSync('/data', '/shortcut'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.mkdirSync('/data', { recursive: true }); + myVfs.writeFileSync('/data/file.txt', 'content'); + myVfs.symlinkSync('/data', '/shortcut'); + myVfs.mount('/virtual'); // Reading through symlink directory - const content = vfs.readFileSync('/virtual/shortcut/file.txt', 'utf8'); + const content = myVfs.readFileSync('/virtual/shortcut/file.txt', 'utf8'); assert.strictEqual(content, 'content'); // Listing symlinked directory - const files = vfs.readdirSync('/virtual/shortcut'); + const files = myVfs.readdirSync('/virtual/shortcut'); assert.deepStrictEqual(files, ['file.txt']); - vfs.unmount(); + myVfs.unmount(); } // Test relative symlinks { - const vfs = fs.createVirtual(); - vfs.mkdirSync('/dir', { recursive: true }); - vfs.writeFileSync('/dir/file.txt', 'content'); - vfs.symlinkSync('file.txt', '/dir/link.txt'); // Relative target - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.mkdirSync('/dir', { recursive: true }); + myVfs.writeFileSync('/dir/file.txt', 'content'); + myVfs.symlinkSync('file.txt', '/dir/link.txt'); // Relative target + myVfs.mount('/virtual'); - const content = vfs.readFileSync('/virtual/dir/link.txt', 'utf8'); + const content = myVfs.readFileSync('/virtual/dir/link.txt', 'utf8'); assert.strictEqual(content, 'content'); // Readlink should return the relative target as-is - const target = vfs.readlinkSync('/virtual/dir/link.txt'); + const target = myVfs.readlinkSync('/virtual/dir/link.txt'); assert.strictEqual(target, 'file.txt'); - vfs.unmount(); + myVfs.unmount(); } // Test symlink chains (symlink pointing to another symlink) { - const vfs = fs.createVirtual(); - vfs.writeFileSync('/file.txt', 'chained'); - vfs.symlinkSync('/file.txt', '/link1'); - vfs.symlinkSync('/link1', '/link2'); - vfs.symlinkSync('/link2', '/link3'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'chained'); + myVfs.symlinkSync('/file.txt', '/link1'); + myVfs.symlinkSync('/link1', '/link2'); + myVfs.symlinkSync('/link2', '/link3'); + myVfs.mount('/virtual'); // Should resolve through all symlinks - const content = vfs.readFileSync('/virtual/link3', 'utf8'); + const content = myVfs.readFileSync('/virtual/link3', 'utf8'); assert.strictEqual(content, 'chained'); - vfs.unmount(); + myVfs.unmount(); } // Test realpathSync resolves symlinks { - const vfs = fs.createVirtual(); - vfs.mkdirSync('/actual', { recursive: true }); - vfs.writeFileSync('/actual/file.txt', 'content'); - vfs.symlinkSync('/actual', '/link'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.mkdirSync('/actual', { recursive: true }); + myVfs.writeFileSync('/actual/file.txt', 'content'); + myVfs.symlinkSync('/actual', '/link'); + myVfs.mount('/virtual'); - const realpath = vfs.realpathSync('/virtual/link/file.txt'); + const realpath = myVfs.realpathSync('/virtual/link/file.txt'); assert.strictEqual(realpath, '/virtual/actual/file.txt'); - vfs.unmount(); + myVfs.unmount(); } // Test symlink loop detection (ELOOP) { - const vfs = fs.createVirtual(); - vfs.symlinkSync('/loop2', '/loop1'); - vfs.symlinkSync('/loop1', '/loop2'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.symlinkSync('/loop2', '/loop1'); + myVfs.symlinkSync('/loop1', '/loop2'); + myVfs.mount('/virtual'); // statSync should throw ELOOP assert.throws(() => { - vfs.statSync('/virtual/loop1'); + myVfs.statSync('/virtual/loop1'); }, { code: 'ELOOP' }); // realpathSync should throw ELOOP assert.throws(() => { - vfs.realpathSync('/virtual/loop1'); + myVfs.realpathSync('/virtual/loop1'); }, { code: 'ELOOP' }); // lstatSync should still work (doesn't follow symlinks) - const lstat = vfs.lstatSync('/virtual/loop1'); + const lstat = myVfs.lstatSync('/virtual/loop1'); assert.strictEqual(lstat.isSymbolicLink(), true); - vfs.unmount(); + myVfs.unmount(); } // Test readdirSync with withFileTypes includes symlinks { - const vfs = fs.createVirtual(); - vfs.mkdirSync('/dir/subdir', { recursive: true }); - vfs.writeFileSync('/dir/file.txt', 'content'); - vfs.symlinkSync('/dir/file.txt', '/dir/link'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.mkdirSync('/dir/subdir', { recursive: true }); + myVfs.writeFileSync('/dir/file.txt', 'content'); + myVfs.symlinkSync('/dir/file.txt', '/dir/link'); + myVfs.mount('/virtual'); - const entries = vfs.readdirSync('/virtual/dir', { withFileTypes: true }); + const entries = myVfs.readdirSync('/virtual/dir', { withFileTypes: true }); const file = entries.find((e) => e.name === 'file.txt'); const subdir = entries.find((e) => e.name === 'subdir'); @@ -206,129 +206,127 @@ const fs = require('fs'); assert.strictEqual(subdir.isDirectory(), true); assert.strictEqual(link.isSymbolicLink(), true); - vfs.unmount(); + myVfs.unmount(); } // Test async readlink { - const vfs = fs.createVirtual(); - vfs.writeFileSync('/target', 'content'); - vfs.symlinkSync('/target', '/link'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.writeFileSync('/target', 'content'); + myVfs.symlinkSync('/target', '/link'); + myVfs.mount('/virtual'); - vfs.readlink('/virtual/link', common.mustSucceed((target) => { + myVfs.readlink('/virtual/link', common.mustSucceed((target) => { assert.strictEqual(target, '/target'); - vfs.unmount(); + myVfs.unmount(); })); } // Test async realpath with symlinks { - const vfs = fs.createVirtual(); - vfs.mkdirSync('/real', { recursive: true }); - vfs.writeFileSync('/real/file.txt', 'content'); - vfs.symlinkSync('/real', '/link'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.mkdirSync('/real', { recursive: true }); + myVfs.writeFileSync('/real/file.txt', 'content'); + myVfs.symlinkSync('/real', '/link'); + myVfs.mount('/virtual'); - vfs.realpath('/virtual/link/file.txt', common.mustSucceed((resolvedPath) => { + myVfs.realpath('/virtual/link/file.txt', common.mustSucceed((resolvedPath) => { assert.strictEqual(resolvedPath, '/virtual/real/file.txt'); - vfs.unmount(); + myVfs.unmount(); })); } // Test promises API - stat follows symlinks { - const vfs = fs.createVirtual(); - vfs.writeFileSync('/file.txt', 'x'.repeat(50)); - vfs.symlinkSync('/file.txt', '/link.txt'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'x'.repeat(50)); + myVfs.symlinkSync('/file.txt', '/link.txt'); + myVfs.mount('/virtual'); (async () => { - const stat = await vfs.promises.stat('/virtual/link.txt'); + const stat = await myVfs.promises.stat('/virtual/link.txt'); assert.strictEqual(stat.isFile(), true); assert.strictEqual(stat.size, 50); - vfs.unmount(); + myVfs.unmount(); })().then(common.mustCall()); } // Test promises API - lstat does not follow symlinks { - const vfs = fs.createVirtual(); - vfs.writeFileSync('/file.txt', 'x'.repeat(50)); - vfs.symlinkSync('/file.txt', '/link.txt'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'x'.repeat(50)); + myVfs.symlinkSync('/file.txt', '/link.txt'); + myVfs.mount('/virtual'); (async () => { - const lstat = await vfs.promises.lstat('/virtual/link.txt'); + const lstat = await myVfs.promises.lstat('/virtual/link.txt'); assert.strictEqual(lstat.isSymbolicLink(), true); - vfs.unmount(); + myVfs.unmount(); })().then(common.mustCall()); } // Test promises API - readlink { - const vfs = fs.createVirtual(); - vfs.writeFileSync('/target', 'content'); - vfs.symlinkSync('/target', '/link'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.writeFileSync('/target', 'content'); + myVfs.symlinkSync('/target', '/link'); + myVfs.mount('/virtual'); (async () => { - const target = await vfs.promises.readlink('/virtual/link'); + const target = await myVfs.promises.readlink('/virtual/link'); assert.strictEqual(target, '/target'); - vfs.unmount(); + myVfs.unmount(); })().then(common.mustCall()); } // Test promises API - realpath resolves symlinks { - const vfs = fs.createVirtual(); - vfs.mkdirSync('/real', { recursive: true }); - vfs.writeFileSync('/real/file.txt', 'content'); - vfs.symlinkSync('/real', '/link'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.mkdirSync('/real', { recursive: true }); + myVfs.writeFileSync('/real/file.txt', 'content'); + myVfs.symlinkSync('/real', '/link'); + myVfs.mount('/virtual'); (async () => { - const resolved = await vfs.promises.realpath('/virtual/link/file.txt'); + const resolved = await myVfs.promises.realpath('/virtual/link/file.txt'); assert.strictEqual(resolved, '/virtual/real/file.txt'); - vfs.unmount(); + myVfs.unmount(); })().then(common.mustCall()); } // Test broken symlink (target doesn't exist) { - const vfs = fs.createVirtual(); - vfs.symlinkSync('/nonexistent', '/broken'); - vfs.mount('/virtual'); + const myVfs = vfs.create(); + myVfs.symlinkSync('/nonexistent', '/broken'); + myVfs.mount('/virtual'); // statSync should throw ENOENT for broken symlink assert.throws(() => { - vfs.statSync('/virtual/broken'); + myVfs.statSync('/virtual/broken'); }, { code: 'ENOENT' }); // lstatSync should work (the symlink itself exists) - const lstat = vfs.lstatSync('/virtual/broken'); + const lstat = myVfs.lstatSync('/virtual/broken'); assert.strictEqual(lstat.isSymbolicLink(), true); // readlinkSync should work (returns target path) - const target = vfs.readlinkSync('/virtual/broken'); + const target = myVfs.readlinkSync('/virtual/broken'); assert.strictEqual(target, '/nonexistent'); - vfs.unmount(); + myVfs.unmount(); } // Test symlink with parent traversal (..) { - const vfs = fs.createVirtual(); - vfs.mkdirSync('/a', { recursive: true }); - vfs.mkdirSync('/b', { recursive: true }); - vfs.writeFileSync('/a/file.txt', 'content'); - vfs.symlinkSync('../a/file.txt', '/b/link'); - vfs.mount('/virtual'); - - const content = vfs.readFileSync('/virtual/b/link', 'utf8'); + const myVfs = vfs.create(); + myVfs.mkdirSync('/a', { recursive: true }); + myVfs.mkdirSync('/b', { recursive: true }); + myVfs.writeFileSync('/a/file.txt', 'content'); + myVfs.symlinkSync('../a/file.txt', '/b/link'); + myVfs.mount('/virtual'); + + const content = myVfs.readFileSync('/virtual/b/link', 'utf8'); assert.strictEqual(content, 'content'); - vfs.unmount(); + myVfs.unmount(); } - -console.log('All VFS symlink tests passed!'); From 826d4ae1ec585d0a98d4621985e456a3ab219778 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 31 Jan 2026 13:30:46 +0100 Subject: [PATCH 23/32] vfs: add watch and watchFile support Add fs.watch(), fs.watchFile(), fs.unwatchFile(), and fs.promises.watch() support for VFS-mounted paths using a polling-based implementation. Key features: - VFSWatcher: polling-based watcher compatible with fs.watch() interface - VFSStatWatcher: stat-based watcher compatible with fs.watchFile() - VFSWatchAsyncIterable: async iterator for fs.promises.watch() - Recursive directory watching via tracked files map - AbortSignal support for cancellation - Overlay mode: watches VFS if file exists, falls through to real fs The implementation follows "Approach A (VFS Priority)" where VFS files take precedence over real filesystem files under mount points. --- lib/internal/vfs/file_handle.js | 10 +- lib/internal/vfs/file_system.js | 57 +++ lib/internal/vfs/module_hooks.js | 102 +++++ lib/internal/vfs/provider.js | 59 +++ lib/internal/vfs/providers/memory.js | 88 +++++ lib/internal/vfs/watcher.js | 555 +++++++++++++++++++++++++++ test/parallel/test-vfs-watch.js | 375 ++++++++++++++++++ 7 files changed, 1243 insertions(+), 3 deletions(-) create mode 100644 lib/internal/vfs/watcher.js create mode 100644 test/parallel/test-vfs-watch.js diff --git a/lib/internal/vfs/file_handle.js b/lib/internal/vfs/file_handle.js index 0c3e1db9c0cc93..441a3954274fbd 100644 --- a/lib/internal/vfs/file_handle.js +++ b/lib/internal/vfs/file_handle.js @@ -1,6 +1,7 @@ 'use strict'; const { + DateNow, MathMin, Symbol, } = primordials; @@ -385,9 +386,10 @@ class MemoryFileHandle extends VirtualFileHandle { // Write the data data.copy(this.#content, writePos); - // Update the entry's content + // Update the entry's content and mtime if (this.#entry) { this.#entry.content = this.#content; + this.#entry.mtime = DateNow(); } // Update position if not using explicit position @@ -466,9 +468,10 @@ class MemoryFileHandle extends VirtualFileHandle { this.#content = Buffer.from(buffer); } - // Update the entry's content + // Update the entry's content and mtime if (this.#entry) { this.#entry.content = this.#content; + this.#entry.mtime = DateNow(); } this.position = this.#content.length; @@ -521,9 +524,10 @@ class MemoryFileHandle extends VirtualFileHandle { this.#content = newContent; } - // Update the entry's content + // Update the entry's content and mtime if (this.#entry) { this.#entry.content = this.#content; + this.#entry.mtime = DateNow(); } } diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js index 3c3a7ee10f2d69..111f1e7c1e0934 100644 --- a/lib/internal/vfs/file_system.js +++ b/lib/internal/vfs/file_system.js @@ -885,6 +885,58 @@ class VirtualFileSystem { return createVirtualReadStream(this, filePath, options); } + // ==================== Watch Operations ==================== + + /** + * Watches a file or directory for changes. + * @param {string} filePath The path to watch + * @param {object|Function} [options] Watch options or listener + * @param {Function} [listener] Change listener + * @returns {EventEmitter} A watcher that emits 'change' events + */ + watch(filePath, options, listener) { + if (typeof options === 'function') { + listener = options; + options = {}; + } + + const providerPath = this._toProviderPath(filePath); + const watcher = this[kProvider].watch(providerPath, options); + + if (listener) { + watcher.on('change', listener); + } + + return watcher; + } + + /** + * Watches a file for changes using stat polling. + * @param {string} filePath The path to watch + * @param {object|Function} [options] Watch options or listener + * @param {Function} [listener] Change listener + * @returns {EventEmitter} A stat watcher that emits 'change' events + */ + watchFile(filePath, options, listener) { + if (typeof options === 'function') { + listener = options; + options = {}; + } + + const providerPath = this._toProviderPath(filePath); + return this[kProvider].watchFile(providerPath, options, listener); + } + + /** + * Stops watching a file for changes. + * @param {string} filePath The path to stop watching + * @param {Function} [listener] Optional listener to remove + */ + unwatchFile(filePath, listener) { + const providerPath = this._toProviderPath(filePath); + this[kProvider].unwatchFile(providerPath, listener); + } + // ==================== Promise API ==================== /** @@ -989,6 +1041,11 @@ function createPromisesAPI(vfs) { const providerPath = vfs._toProviderPath(filePath); return provider.access(providerPath, mode); }, + + watch(filePath, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.watchAsync(providerPath, options); + }, }); } diff --git a/lib/internal/vfs/module_hooks.js b/lib/internal/vfs/module_hooks.js index 20e3fd6d75f319..6fd864c4ae6a0c 100644 --- a/lib/internal/vfs/module_hooks.js +++ b/lib/internal/vfs/module_hooks.js @@ -34,6 +34,14 @@ let originalExistsSync = null; let originalPromisesReaddir = null; // Original fs/promises.lstat function let originalPromisesLstat = null; +// Original fs.watch function +let originalWatch = null; +// Original fs.watchFile function +let originalWatchFile = null; +// Original fs.unwatchFile function +let originalUnwatchFile = null; +// Original fs/promises.watch function +let originalPromisesWatch = null; // Track if hooks are installed let hooksInstalled = false; @@ -282,6 +290,33 @@ async function findVFSForLstatAsync(filename) { return null; } +/** + * Checks all active VFS instances for watch operations. + * For Approach A (VFS Priority): watch VFS if file exists, otherwise fall through. + * @param {string} filename The path to watch + * @returns {{ vfs: VirtualFileSystem }|null} + */ +function findVFSForWatch(filename) { + const normalized = normalizePath(filename); + for (let i = 0; i < activeVFSList.length; i++) { + const vfs = activeVFSList[i]; + if (vfs.shouldHandle(normalized)) { + // In overlay mode, only handle if file exists in VFS + // In mount mode (default), always handle paths under mount point + if (vfs.overlay) { + if (vfs.existsSync(normalized)) { + return { vfs }; + } + // File doesn't exist in VFS, fall through to real fs.watch + continue; + } + // Mount mode: always handle + return { vfs }; + } + } + return null; +} + /** * Determine module format from file extension. * @param {string} url The file URL @@ -565,6 +600,73 @@ function installHooks() { return originalPromisesLstat.call(fsPromises, path, options); }; + // Override fs.watch + originalWatch = fs.watch; + fs.watch = function watch(filename, options, listener) { + // Handle optional options argument + if (typeof options === 'function') { + listener = options; + options = {}; + } + options ??= {}; + + if (typeof filename === 'string' || filename instanceof URL) { + const pathStr = typeof filename === 'string' ? filename : filename.pathname; + const vfsResult = findVFSForWatch(pathStr); + if (vfsResult !== null) { + return vfsResult.vfs.watch(pathStr, options, listener); + } + } + return originalWatch.call(fs, filename, options, listener); + }; + + // Override fs.watchFile + originalWatchFile = fs.watchFile; + fs.watchFile = function watchFile(filename, options, listener) { + // Handle optional options argument + if (typeof options === 'function') { + listener = options; + options = {}; + } + options ??= {}; + + if (typeof filename === 'string' || filename instanceof URL) { + const pathStr = typeof filename === 'string' ? filename : filename.pathname; + const vfsResult = findVFSForWatch(pathStr); + if (vfsResult !== null) { + return vfsResult.vfs.watchFile(pathStr, options, listener); + } + } + return originalWatchFile.call(fs, filename, options, listener); + }; + + // Override fs.unwatchFile + originalUnwatchFile = fs.unwatchFile; + fs.unwatchFile = function unwatchFile(filename, listener) { + if (typeof filename === 'string' || filename instanceof URL) { + const pathStr = typeof filename === 'string' ? filename : filename.pathname; + const vfsResult = findVFSForWatch(pathStr); + if (vfsResult !== null) { + vfsResult.vfs.unwatchFile(pathStr, listener); + return; + } + } + return originalUnwatchFile.call(fs, filename, listener); + }; + + // Override fs/promises.watch + originalPromisesWatch = fsPromises.watch; + fsPromises.watch = function watch(filename, options) { + if (typeof filename === 'string' || filename instanceof URL) { + const pathStr = typeof filename === 'string' ? filename : filename.pathname; + const vfsResult = findVFSForWatch(pathStr); + if (vfsResult !== null) { + return vfsResult.vfs.promises.watch(pathStr, options); + } + } + return originalPromisesWatch.call(fsPromises, filename, options); + }; + // Register ESM hooks using Module.registerHooks Module.registerHooks({ resolve: vfsResolveHook, diff --git a/lib/internal/vfs/provider.js b/lib/internal/vfs/provider.js index 5b5ac432d7d874..7d451f7c62ee2a 100644 --- a/lib/internal/vfs/provider.js +++ b/lib/internal/vfs/provider.js @@ -34,6 +34,14 @@ class VirtualProvider { return false; } + /** + * Returns true if this provider supports file watching. + * @returns {boolean} + */ + get supportsWatch() { + return false; + } + // === ESSENTIAL PRIMITIVES (must be implemented by subclasses) === /** @@ -492,6 +500,57 @@ class VirtualProvider { } throw new ERR_METHOD_NOT_IMPLEMENTED('symlinkSync'); } + + // === WATCH OPERATIONS (optional, polling-based) === + + /** + * Watches a file or directory for changes. + * Returns an EventEmitter-like object that emits 'change' and 'close' events. + * @param {string} path The path to watch + * @param {object} [options] Watch options + * @param {number} [options.interval] Polling interval in ms (default: 100) + * @param {boolean} [options.recursive] Watch subdirectories (default: false) + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not overridden by subclass + */ + watch(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('watch'); + } + + /** + * Watches a file or directory for changes (async iterable version). + * Used by fs.promises.watch(). + * @param {string} path The path to watch + * @param {object} [options] Watch options + * @param {number} [options.interval] Polling interval in ms (default: 100) + * @param {boolean} [options.recursive] Watch subdirectories (default: false) + * @param {AbortSignal} [options.signal] AbortSignal for cancellation + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not overridden by subclass + */ + watchAsync(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('watchAsync'); + } + + /** + * Watches a file for changes using stat polling. + * Returns a StatWatcher-like object that emits 'change' events with stats. + * @param {string} path The path to watch + * @param {object} [options] Watch options + * @param {number} [options.interval] Polling interval in ms (default: 5007) + * @param {boolean} [options.persistent] Whether the watcher should prevent exit + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not overridden by subclass + */ + watchFile(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('watchFile'); + } + + /** + * Stops watching a file for changes. + * @param {string} path The path to stop watching + * @param {Function} [listener] Optional listener to remove + */ + unwatchFile(path, listener) { + throw new ERR_METHOD_NOT_IMPLEMENTED('unwatchFile'); + } } module.exports = { diff --git a/lib/internal/vfs/providers/memory.js b/lib/internal/vfs/providers/memory.js index cf4e1dd768d5fc..6cf40bdc088597 100644 --- a/lib/internal/vfs/providers/memory.js +++ b/lib/internal/vfs/providers/memory.js @@ -10,6 +10,11 @@ const { const { Buffer } = require('buffer'); const { VirtualProvider } = require('internal/vfs/provider'); const { MemoryFileHandle } = require('internal/vfs/file_handle'); +const { + VFSWatcher, + VFSStatWatcher, + VFSWatchAsyncIterable, +} = require('internal/vfs/watcher'); const { codes: { ERR_INVALID_STATE, @@ -42,6 +47,7 @@ const { // Private symbols const kRoot = Symbol('kRoot'); const kReadonly = Symbol('kReadonly'); +const kStatWatchers = Symbol('kStatWatchers'); // Entry types const TYPE_FILE = 0; @@ -131,12 +137,18 @@ class MemoryProvider extends VirtualProvider { this[kRoot] = new MemoryEntry(TYPE_DIR); this[kRoot].children = new SafeMap(); this[kReadonly] = false; + // Map of path -> VFSStatWatcher for watchFile + this[kStatWatchers] = new SafeMap(); } get readonly() { return this[kReadonly]; } + get supportsWatch() { + return true; + } + /** * Sets the provider to read-only mode. * Once set to read-only, the provider cannot be changed back to writable. @@ -720,6 +732,82 @@ class MemoryProvider extends VirtualProvider { async realpath(path, options) { return this.realpathSync(path, options); } + + // === WATCH OPERATIONS === + + /** + * Watches a file or directory for changes. + * @param {string} path The path to watch + * @param {object} [options] Watch options + * @returns {VFSWatcher} + */ + watch(path, options) { + const normalized = this._normalizePath(path); + return new VFSWatcher(this, normalized, options); + } + + /** + * Watches a file or directory for changes (async iterable version). + * Used by fs.promises.watch(). + * @param {string} path The path to watch + * @param {object} [options] Watch options + * @returns {VFSWatchAsyncIterable} + */ + watchAsync(path, options) { + const normalized = this._normalizePath(path); + return new VFSWatchAsyncIterable(this, normalized, options); + } + + /** + * Watches a file for changes using stat polling. + * @param {string} path The path to watch + * @param {object} [options] Watch options + * @param {Function} [listener] Change listener + * @returns {VFSStatWatcher} + */ + watchFile(path, options, listener) { + const normalized = this._normalizePath(path); + + // Reuse existing watcher for the same path + let watcher = this[kStatWatchers].get(normalized); + if (!watcher) { + watcher = new VFSStatWatcher(this, normalized, options); + this[kStatWatchers].set(normalized, watcher); + } + + if (listener) { + watcher.addListener(listener); + } + + return watcher; + } + + /** + * Stops watching a file for changes. + * @param {string} path The path to stop watching + * @param {Function} [listener] Optional listener to remove + */ + unwatchFile(path, listener) { + const normalized = this._normalizePath(path); + const watcher = this[kStatWatchers].get(normalized); + + if (!watcher) { + return; + } + + if (listener) { + watcher.removeListener(listener); + } else { + // Remove all listeners + watcher.removeAllListeners('change'); + } + + // If no more listeners, stop and remove the watcher + if (watcher.hasNoListeners()) { + watcher.stop(); + this[kStatWatchers].delete(normalized); + } + } } module.exports = { diff --git a/lib/internal/vfs/watcher.js b/lib/internal/vfs/watcher.js new file mode 100644 index 00000000000000..bcad3d7773f11b --- /dev/null +++ b/lib/internal/vfs/watcher.js @@ -0,0 +1,555 @@ +'use strict'; + +const { + ArrayPrototypePush, + Promise, + PromiseResolve, + SafeMap, + SafeSet, + Symbol, + SymbolAsyncIterator, +} = primordials; + +const { EventEmitter } = require('events'); +const { basename, join } = require('path'); +const { + setInterval, + clearInterval, +} = require('timers'); + +// Private symbols +const kVfs = Symbol('kVfs'); +const kPath = Symbol('kPath'); +const kInterval = Symbol('kInterval'); +const kTimer = Symbol('kTimer'); +const kLastStats = Symbol('kLastStats'); +const kClosed = Symbol('kClosed'); +const kPersistent = Symbol('kPersistent'); +const kListeners = Symbol('kListeners'); +const kRecursive = Symbol('kRecursive'); +const kTrackedFiles = Symbol('kTrackedFiles'); +const kSignal = Symbol('kSignal'); +const kAbortHandler = Symbol('kAbortHandler'); + +/** + * VFSWatcher - Polling-based file/directory watcher for VFS. + * Emits 'change' events when the file content or stats change. + * Compatible with fs.watch() return value interface. + */ +class VFSWatcher extends EventEmitter { + /** + * @param {VirtualProvider} provider The VFS provider + * @param {string} path The path to watch (provider-relative) + * @param {object} [options] Options + * @param {number} [options.interval] Polling interval in ms (default: 100) + * @param {boolean} [options.persistent] Keep process alive (default: true) + * @param {boolean} [options.recursive] Watch subdirectories (default: false) + * @param {AbortSignal} [options.signal] AbortSignal for cancellation + */ + constructor(provider, path, options = {}) { + super(); + + this[kVfs] = provider; + this[kPath] = path; + this[kInterval] = options.interval ?? 100; + this[kPersistent] = options.persistent !== false; + this[kRecursive] = options.recursive === true; + this[kClosed] = false; + this[kTimer] = null; + this[kTrackedFiles] = new SafeMap(); // path -> { stats, relativePath } + this[kSignal] = options.signal; + this[kAbortHandler] = null; + + // Handle AbortSignal + if (this[kSignal]) { + if (this[kSignal].aborted) { + this.close(); + return; + } + this[kAbortHandler] = () => this.close(); + this[kSignal].addEventListener('abort', this[kAbortHandler], { once: true }); + } + + // Get initial stats + this[kLastStats] = this._getStats(); + + // If recursive and watching a directory, build file list + if (this[kRecursive] && this[kLastStats]?.isDirectory()) { + this._buildFileList(this[kPath], ''); + } + + // Start polling + this._startPolling(); + } + + /** + * Gets stats for the watched path. + * @returns {Stats|null} The stats or null if file doesn't exist + * @private + */ + _getStats() { + try { + return this[kVfs].statSync(this[kPath]); + } catch { + return null; + } + } + + /** + * Starts the polling timer. + * @private + */ + _startPolling() { + if (this[kClosed]) return; + + this[kTimer] = setInterval(() => this._poll(), this[kInterval]); + + // If not persistent, unref the timer to allow process to exit + if (!this[kPersistent] && this[kTimer].unref) { + this[kTimer].unref(); + } + } + + /** + * Polls for changes. + * @private + */ + _poll() { + if (this[kClosed]) return; + + // For recursive directory watching, check all tracked files + if (this[kRecursive] && this[kTrackedFiles].size > 0) { + for (const { 0: filePath, 1: info } of this[kTrackedFiles]) { + const newStats = this._getStatsFor(filePath); + if (this._statsChanged(info.stats, newStats)) { + const eventType = this._determineEventType(info.stats, newStats); + this.emit('change', eventType, info.relativePath); + info.stats = newStats; + } + } + return; + } + + // For single file/directory watching + const newStats = this._getStats(); + + if (this._statsChanged(this[kLastStats], newStats)) { + const eventType = this._determineEventType(this[kLastStats], newStats); + const filename = basename(this[kPath]); + this.emit('change', eventType, filename); + } + + this[kLastStats] = newStats; + } + + /** + * Gets stats for a specific path. + * @param {string} filePath The file path + * @returns {Stats|null} + * @private + */ + _getStatsFor(filePath) { + try { + return this[kVfs].statSync(filePath); + } catch { + return null; + } + } + + /** + * Builds the list of files to track for recursive watching. + * @param {string} dirPath The directory path + * @param {string} relativePath The relative path from the watched root + * @private + */ + _buildFileList(dirPath, relativePath) { + try { + const entries = this[kVfs].readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dirPath, entry.name); + const relPath = relativePath ? join(relativePath, entry.name) : entry.name; + + if (entry.isDirectory()) { + // Recurse into subdirectory + this._buildFileList(fullPath, relPath); + } else { + // Track the file + const stats = this._getStatsFor(fullPath); + this[kTrackedFiles].set(fullPath, { + stats, + relativePath: relPath, + }); + } + } + } catch { + // Directory might not exist or be readable + } + } + + /** + * Checks if stats have changed. + * @param {Stats|null} oldStats Previous stats + * @param {Stats|null} newStats Current stats + * @returns {boolean} True if stats changed + * @private + */ + _statsChanged(oldStats, newStats) { + // File created or deleted + if ((oldStats === null) !== (newStats === null)) { + return true; + } + + // Both null - no change + if (oldStats === null && newStats === null) { + return false; + } + + // Compare mtime and size + if (oldStats.mtimeMs !== newStats.mtimeMs) { + return true; + } + if (oldStats.size !== newStats.size) { + return true; + } + + return false; + } + + /** + * Determines the event type based on stats change. + * @param {Stats|null} oldStats Previous stats + * @param {Stats|null} newStats Current stats + * @returns {string} 'rename' or 'change' + * @private + */ + _determineEventType(oldStats, newStats) { + // File was created or deleted + if ((oldStats === null) !== (newStats === null)) { + return 'rename'; + } + // Content changed + return 'change'; + } + + /** + * Closes the watcher and stops polling. + */ + close() { + if (this[kClosed]) return; + this[kClosed] = true; + + if (this[kTimer]) { + clearInterval(this[kTimer]); + this[kTimer] = null; + } + + // Clear tracked files + this[kTrackedFiles].clear(); + + // Remove abort handler + if (this[kSignal] && this[kAbortHandler]) { + this[kSignal].removeEventListener('abort', this[kAbortHandler]); + } + + this.emit('close'); + } + + /** + * Alias for close() - compatibility with FSWatcher. + * @returns {this} + */ + unref() { + this[kTimer]?.unref?.(); + return this; + } + + /** + * Makes the timer keep the process alive - compatibility with FSWatcher. + * @returns {this} + */ + ref() { + this[kTimer]?.ref?.(); + return this; + } +} + +/** + * VFSStatWatcher - Polling-based stat watcher for VFS. + * Emits 'change' events with current and previous stats. + * Compatible with fs.watchFile() return value interface. + */ +class VFSStatWatcher extends EventEmitter { + /** + * @param {VirtualProvider} provider The VFS provider + * @param {string} path The path to watch (provider-relative) + * @param {object} [options] Options + * @param {number} [options.interval] Polling interval in ms (default: 5007) + * @param {boolean} [options.persistent] Keep process alive (default: true) + */ + constructor(provider, path, options = {}) { + super(); + + this[kVfs] = provider; + this[kPath] = path; + this[kInterval] = options.interval ?? 5007; + this[kPersistent] = options.persistent !== false; + this[kClosed] = false; + this[kTimer] = null; + this[kListeners] = new SafeSet(); + + // Get initial stats + this[kLastStats] = this._getStats(); + + // Start polling + this._startPolling(); + } + + /** + * Gets stats for the watched path. + * @returns {Stats} The stats (with zeroed values if file doesn't exist) + * @private + */ + _getStats() { + try { + return this[kVfs].statSync(this[kPath]); + } catch { + // Return a zeroed stats object for non-existent files + // This matches Node.js behavior + return this._createZeroStats(); + } + } + + /** + * Creates a zeroed stats object for non-existent files. + * @returns {object} Zeroed stats + * @private + */ + _createZeroStats() { + const { createFileStats } = require('internal/vfs/stats'); + return createFileStats(0, { + mode: 0, + mtimeMs: 0, + ctimeMs: 0, + birthtimeMs: 0, + }); + } + + /** + * Starts the polling timer. + * @private + */ + _startPolling() { + if (this[kClosed]) return; + + this[kTimer] = setInterval(() => this._poll(), this[kInterval]); + + // If not persistent, unref the timer to allow process to exit + if (!this[kPersistent] && this[kTimer].unref) { + this[kTimer].unref(); + } + } + + /** + * Polls for changes. + * @private + */ + _poll() { + if (this[kClosed]) return; + + const newStats = this._getStats(); + + if (this._statsChanged(this[kLastStats], newStats)) { + const prevStats = this[kLastStats]; + this[kLastStats] = newStats; + this.emit('change', newStats, prevStats); + } + } + + /** + * Checks if stats have changed. + * @param {Stats} oldStats Previous stats + * @param {Stats} newStats Current stats + * @returns {boolean} True if stats changed + * @private + */ + _statsChanged(oldStats, newStats) { + // Compare mtime and ctime + if (oldStats.mtimeMs !== newStats.mtimeMs) { + return true; + } + if (oldStats.ctimeMs !== newStats.ctimeMs) { + return true; + } + if (oldStats.size !== newStats.size) { + return true; + } + return false; + } + + /** + * Adds a change listener. + * @param {Function} listener The listener function + */ + addListener(listener) { + this[kListeners].add(listener); + this.on('change', listener); + } + + /** + * Removes a change listener. + * @param {Function} listener The listener function + * @returns {boolean} True if listener was removed + */ + removeListener(listener) { + const had = this[kListeners].has(listener); + this[kListeners].delete(listener); + super.removeListener('change', listener); + return had; + } + + /** + * Returns true if there are no listeners. + * @returns {boolean} + */ + hasNoListeners() { + return this[kListeners].size === 0; + } + + /** + * Stops the watcher. + */ + stop() { + if (this[kClosed]) return; + this[kClosed] = true; + + if (this[kTimer]) { + clearInterval(this[kTimer]); + this[kTimer] = null; + } + + this.emit('stop'); + } + + /** + * Makes the timer not keep the process alive. + * @returns {this} + */ + unref() { + this[kTimer]?.unref?.(); + return this; + } + + /** + * Makes the timer keep the process alive. + * @returns {this} + */ + ref() { + this[kTimer]?.ref?.(); + return this; + } +} + +/** + * VFSWatchAsyncIterable - Async iterable wrapper for VFSWatcher. + * Compatible with fs.promises.watch() return value interface. + */ +class VFSWatchAsyncIterable { + #watcher; + #closed = false; + #pendingEvents = []; + #pendingResolvers = []; + #error = null; + + /** + * @param {VirtualProvider} provider The VFS provider + * @param {string} path The path to watch (provider-relative) + * @param {object} [options] Options + */ + constructor(provider, path, options = {}) { + this.#watcher = new VFSWatcher(provider, path, options); + + this.#watcher.on('change', (eventType, filename) => { + const event = { eventType, filename }; + if (this.#pendingResolvers.length > 0) { + const resolve = this.#pendingResolvers.shift(); + resolve({ value: event, done: false }); + } else { + ArrayPrototypePush(this.#pendingEvents, event); + } + }); + + this.#watcher.on('close', () => { + this.#closed = true; + // Resolve any pending iterators + while (this.#pendingResolvers.length > 0) { + const resolve = this.#pendingResolvers.shift(); + resolve({ value: undefined, done: true }); + } + }); + + this.#watcher.on('error', (err) => { + this.#error = err; + // Reject any pending iterators + while (this.#pendingResolvers.length > 0) { + const resolve = this.#pendingResolvers.shift(); + resolve(PromiseResolve({ value: undefined, done: true })); + } + }); + } + + /** + * Returns the async iterator. + * @returns {AsyncIterator} + */ + [SymbolAsyncIterator]() { + return this; + } + + /** + * Gets the next event. + * @returns {Promise} + */ + next() { + if (this.#error) { + return PromiseResolve({ value: undefined, done: true }); + } + + if (this.#closed) { + return PromiseResolve({ value: undefined, done: true }); + } + + if (this.#pendingEvents.length > 0) { + const event = this.#pendingEvents.shift(); + return PromiseResolve({ value: event, done: false }); + } + + return new Promise((resolve) => { + ArrayPrototypePush(this.#pendingResolvers, resolve); + }); + } + + /** + * Closes the iterator and underlying watcher. + * @returns {Promise} + */ + return() { + this.#watcher.close(); + return PromiseResolve({ value: undefined, done: true }); + } + + /** + * Handles iterator throw. + * @param {Error} error The error to throw + * @returns {Promise} + */ + throw(error) { + this.#watcher.close(); + return PromiseResolve({ value: undefined, done: true }); + } +} + +module.exports = { + VFSWatcher, + VFSStatWatcher, + VFSWatchAsyncIterable, +}; diff --git a/test/parallel/test-vfs-watch.js b/test/parallel/test-vfs-watch.js new file mode 100644 index 00000000000000..87467fe575f12a --- /dev/null +++ b/test/parallel/test-vfs-watch.js @@ -0,0 +1,375 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const vfs = require('node:vfs'); + +// Test basic VFS watcher via vfs.watch() +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'initial'); + + const watcher = myVfs.watch('/file.txt', { interval: 50, persistent: false }); + assert.ok(watcher); + assert.strictEqual(typeof watcher.on, 'function'); + assert.strictEqual(typeof watcher.close, 'function'); + + watcher.on('change', common.mustCall((eventType, filename) => { + assert.strictEqual(eventType, 'change'); + assert.strictEqual(filename, 'file.txt'); + watcher.close(); + })); + + // Trigger change after a small delay + setTimeout(() => { + myVfs.writeFileSync('/file.txt', 'updated'); + }, 100); +} + +// Test VFS watcher detects file deletion (rename event) +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/delete-me.txt', 'content'); + + const watcher = myVfs.watch('/delete-me.txt', { interval: 50, persistent: false }); + + watcher.on('change', common.mustCall((eventType, filename) => { + assert.strictEqual(eventType, 'rename'); + assert.strictEqual(filename, 'delete-me.txt'); + watcher.close(); + })); + + // Delete the file after a small delay + setTimeout(() => { + myVfs.unlinkSync('/delete-me.txt'); + }, 100); +} + +// Test VFS watcher with listener passed directly +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/listener-test.txt', 'initial'); + + const watcher = myVfs.watch( + '/listener-test.txt', + { interval: 50, persistent: false }, + common.mustCall((eventType, filename) => { + assert.strictEqual(eventType, 'change'); + assert.strictEqual(filename, 'listener-test.txt'); + watcher.close(); + }), + ); + + setTimeout(() => { + myVfs.writeFileSync('/listener-test.txt', 'updated'); + }, 100); +} + +// Test VFS watchFile via vfs.watchFile() +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/watchfile.txt', 'initial'); + + const statWatcher = myVfs.watchFile( + '/watchfile.txt', + { interval: 50, persistent: false }, + common.mustCall((curr, prev) => { + assert.ok(curr); + assert.ok(prev); + assert.strictEqual(curr.isFile(), true); + // Stats should have changed + assert.notStrictEqual(curr.mtimeMs, prev.mtimeMs); + myVfs.unwatchFile('/watchfile.txt'); + }), + ); + + assert.ok(statWatcher); + + setTimeout(() => { + myVfs.writeFileSync('/watchfile.txt', 'updated content'); + }, 100); +} + +// Test VFS unwatchFile removes listener +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/unwatch.txt', 'initial'); + + const listener = common.mustNotCall(); + myVfs.watchFile('/unwatch.txt', { interval: 50, persistent: false }, listener); + + // Immediately unwatch + myVfs.unwatchFile('/unwatch.txt', listener); + + // Change the file - listener should not be called + setTimeout(() => { + myVfs.writeFileSync('/unwatch.txt', 'updated'); + }, 100); +} + +// Test watcher close() method +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/close-test.txt', 'content'); + + const watcher = myVfs.watch('/close-test.txt', { interval: 50, persistent: false }); + + watcher.on('close', common.mustCall()); + watcher.on('change', common.mustNotCall()); + + // Close immediately + watcher.close(); + + // Change shouldn't trigger anything + setTimeout(() => { + myVfs.writeFileSync('/close-test.txt', 'updated'); + }, 100); +} + +// Test fs.watch with mounted VFS +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/data/file.txt', 'initial'); + myVfs.mount('/virtual'); + + const watcher = fs.watch('/virtual/data/file.txt', { interval: 50, persistent: false }); + + watcher.on('change', common.mustCall((eventType, filename) => { + assert.strictEqual(eventType, 'change'); + assert.strictEqual(filename, 'file.txt'); + watcher.close(); + myVfs.unmount(); + })); + + setTimeout(() => { + // Use addFile to write directly to provider, bypassing mount path logic + myVfs.addFile('/data/file.txt', 'updated via fs.watch'); + }, 100); +} + +// Test fs.watchFile with mounted VFS +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/data/watchfile.txt', 'initial'); + myVfs.mount('/virtual2'); + + const listener = common.mustCall((curr, prev) => { + assert.ok(curr); + assert.ok(prev); + fs.unwatchFile('/virtual2/data/watchfile.txt', listener); + myVfs.unmount(); + }); + + fs.watchFile('/virtual2/data/watchfile.txt', { interval: 50, persistent: false }, listener); + + setTimeout(() => { + // Use addFile to write directly to provider, bypassing mount path logic + myVfs.addFile('/data/watchfile.txt', 'updated via fs.watchFile'); + }, 100); +} + +// Test overlay mode - VFS file exists, should watch VFS +{ + const myVfs = vfs.create({ overlay: true }); + myVfs.writeFileSync('/file.txt', 'vfs content'); + myVfs.mount('/overlay-test'); + + const watcher = fs.watch('/overlay-test/file.txt', { interval: 50, persistent: false }); + + watcher.on('change', common.mustCall((eventType, filename) => { + assert.strictEqual(eventType, 'change'); + assert.strictEqual(filename, 'file.txt'); + watcher.close(); + myVfs.unmount(); + })); + + setTimeout(() => { + // Use addFile to write directly to provider, bypassing mount path logic + myVfs.addFile('/file.txt', 'vfs updated'); + }, 100); +} + +// Test watcher.unref() doesn't throw +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/unref-test.txt', 'content'); + + const watcher = myVfs.watch('/unref-test.txt', { persistent: false }); + const result = watcher.unref(); + assert.strictEqual(result, watcher); // Should return this + watcher.close(); +} + +// Test watcher.ref() doesn't throw +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/ref-test.txt', 'content'); + + const watcher = myVfs.watch('/ref-test.txt', { persistent: false }); + watcher.unref(); + const result = watcher.ref(); + assert.strictEqual(result, watcher); // Should return this + watcher.close(); +} + +// Test watching non-existent file starts with null stats +{ + const myVfs = vfs.create(); + + const watcher = myVfs.watch('/nonexistent.txt', { interval: 50, persistent: false }); + + watcher.on('change', common.mustCall((eventType, filename) => { + // File was created + assert.strictEqual(eventType, 'rename'); + assert.strictEqual(filename, 'nonexistent.txt'); + watcher.close(); + })); + + setTimeout(() => { + myVfs.writeFileSync('/nonexistent.txt', 'now exists'); + }, 100); +} + +// Test supportsWatch property +{ + const myVfs = vfs.create(); + assert.strictEqual(myVfs.provider.supportsWatch, true); +} + +// Test multiple changes are detected +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/multi.txt', 'initial'); + + let changeCount = 0; + const watcher = myVfs.watch('/multi.txt', { interval: 30, persistent: false }); + + // Keep process alive until test completes + const keepAlive = setTimeout(() => {}, 500); + + watcher.on('change', common.mustCall(() => { + changeCount++; + if (changeCount >= 2) { + clearTimeout(keepAlive); + watcher.close(); + } + }, 2)); + + setTimeout(() => { + myVfs.writeFileSync('/multi.txt', 'first update'); + }, 100); + + // Give enough time between changes for the poll to detect both + setTimeout(() => { + myVfs.writeFileSync('/multi.txt', 'second update'); + }, 300); +} + +// Test fs.promises.watch with VFS +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/promises-test.txt', 'initial'); + myVfs.mount('/virtual-promises'); + + const ac = new AbortController(); + + (async () => { + const watcher = fs.promises.watch('/virtual-promises/promises-test.txt', { + signal: ac.signal, + persistent: false, + }); + + // Schedule a change + setTimeout(() => { + myVfs.addFile('/promises-test.txt', 'updated'); + }, 100); + + // Schedule abort after getting one event + let eventCount = 0; + for await (const event of watcher) { + assert.strictEqual(event.eventType, 'change'); + assert.strictEqual(event.filename, 'promises-test.txt'); + eventCount++; + ac.abort(); + } + assert.strictEqual(eventCount, 1); + myVfs.unmount(); + })().then(common.mustCall()); +} + +// Test VFS promises.watch directly +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/direct-promises.txt', 'initial'); + + const ac = new AbortController(); + + (async () => { + const watcher = myVfs.promises.watch('/direct-promises.txt', { + signal: ac.signal, + persistent: false, + }); + + // Schedule a change + setTimeout(() => { + myVfs.writeFileSync('/direct-promises.txt', 'updated'); + }, 100); + + let eventCount = 0; + for await (const event of watcher) { + assert.ok(event.eventType); + assert.ok(event.filename); + eventCount++; + ac.abort(); + } + assert.strictEqual(eventCount, 1); + })().then(common.mustCall()); +} + +// Test recursive watching +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/parent/child', { recursive: true }); + myVfs.writeFileSync('/parent/file.txt', 'parent file'); + myVfs.writeFileSync('/parent/child/file.txt', 'child file'); + + const watcher = myVfs.watch('/parent', { recursive: true, interval: 50, persistent: false }); + + watcher.on('change', common.mustCall((eventType, filename) => { + // Should detect change in subdirectory + assert.strictEqual(eventType, 'change'); + // Filename should include relative path from watched dir + assert.ok(filename.includes('child') || filename === 'file.txt'); + watcher.close(); + })); + + setTimeout(() => { + myVfs.writeFileSync('/parent/child/file.txt', 'updated child'); + }, 100); +} + +// Test recursive watching via fs.watch with mounted VFS +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/data/subdir', { recursive: true }); + myVfs.writeFileSync('/data/top.txt', 'top'); + myVfs.writeFileSync('/data/subdir/nested.txt', 'nested'); + myVfs.mount('/virtual-recursive'); + + const watcher = fs.watch('/virtual-recursive/data', { + recursive: true, + interval: 50, + persistent: false, + }); + + watcher.on('change', common.mustCall((eventType, filename) => { + assert.strictEqual(eventType, 'change'); + watcher.close(); + myVfs.unmount(); + })); + + setTimeout(() => { + myVfs.addFile('/data/subdir/nested.txt', 'updated nested'); + }, 100); +} From abe6d08526ddefb5ccac79711c27b129dc1ffc1e Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 31 Jan 2026 19:11:22 +0100 Subject: [PATCH 24/32] vfs: improve test coverage for watch implementation - Add tests for async iterator return() and throw() methods - Add test for pending events buffered before next() is called - Add test for close while iteration is pending - Add test for VFSStatWatcher ref() and unref() methods - Remove unreachable error handling code in VFSWatchAsyncIterable (VFSWatcher never emits 'error' events) This brings watcher.js coverage above the 95% threshold. --- lib/internal/vfs/watcher.js | 14 ---- test/parallel/test-vfs-watch.js | 122 ++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 14 deletions(-) diff --git a/lib/internal/vfs/watcher.js b/lib/internal/vfs/watcher.js index bcad3d7773f11b..a98347a09d169f 100644 --- a/lib/internal/vfs/watcher.js +++ b/lib/internal/vfs/watcher.js @@ -458,7 +458,6 @@ class VFSWatchAsyncIterable { #closed = false; #pendingEvents = []; #pendingResolvers = []; - #error = null; /** * @param {VirtualProvider} provider The VFS provider @@ -486,15 +485,6 @@ class VFSWatchAsyncIterable { resolve({ value: undefined, done: true }); } }); - - this.#watcher.on('error', (err) => { - this.#error = err; - // Reject any pending iterators - while (this.#pendingResolvers.length > 0) { - const resolve = this.#pendingResolvers.shift(); - resolve(PromiseResolve({ value: undefined, done: true })); - } - }); } /** @@ -510,10 +500,6 @@ class VFSWatchAsyncIterable { * @returns {Promise} */ next() { - if (this.#error) { - return PromiseResolve({ value: undefined, done: true }); - } - if (this.#closed) { return PromiseResolve({ value: undefined, done: true }); } diff --git a/test/parallel/test-vfs-watch.js b/test/parallel/test-vfs-watch.js index 87467fe575f12a..631d5cb63405fe 100644 --- a/test/parallel/test-vfs-watch.js +++ b/test/parallel/test-vfs-watch.js @@ -373,3 +373,125 @@ const vfs = require('node:vfs'); myVfs.addFile('/data/subdir/nested.txt', 'updated nested'); }, 100); } + +// Test async iterator return() method +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/return-test.txt', 'initial'); + + (async () => { + const watcher = myVfs.promises.watch('/return-test.txt', { persistent: false }); + + // Call return() to close the iterator early + const result = await watcher.return(); + assert.strictEqual(result.done, true); + assert.strictEqual(result.value, undefined); + })().then(common.mustCall()); +} + +// Test async iterator throw() method +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/throw-test.txt', 'initial'); + + (async () => { + const watcher = myVfs.promises.watch('/throw-test.txt', { persistent: false }); + + // Call throw() to close the iterator with an error + const result = await watcher.throw(new Error('test error')); + assert.strictEqual(result.done, true); + assert.strictEqual(result.value, undefined); + })().then(common.mustCall()); +} + +// Test pending events (events buffered before next() is called) +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/pending-test.txt', 'initial'); + + (async () => { + const watcher = myVfs.promises.watch('/pending-test.txt', { + interval: 20, + persistent: false, + }); + + // Trigger a change and wait for it to be buffered + setTimeout(() => { + myVfs.writeFileSync('/pending-test.txt', 'updated'); + }, 50); + + // Wait a bit longer than the poll interval to ensure event is buffered + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Now iterate - the event should already be pending + let eventCount = 0; + const ac = new AbortController(); + setTimeout(() => ac.abort(), 200); + + try { + for await (const event of watcher) { + assert.ok(event.eventType); + eventCount++; + break; // Exit after first event + } + } catch { + // Ignore abort errors + } + assert.strictEqual(eventCount, 1); + await watcher.return(); + })().then(common.mustCall()); +} + +// Test close while iteration is pending (waiting for next event) +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/close-pending.txt', 'initial'); + + (async () => { + const watcher = myVfs.promises.watch('/close-pending.txt', { + interval: 50, + persistent: false, + }); + + // Start iterating in background (this will wait for events) + const iterPromise = (async () => { + const events = []; + for await (const event of watcher) { + events.push(event); + } + return events; + })(); + + // Close the watcher while iteration is waiting + await new Promise((resolve) => setTimeout(resolve, 30)); + await watcher.return(); + + // The iteration should complete with no events + const events = await iterPromise; + assert.strictEqual(events.length, 0); + })().then(common.mustCall()); +} + +// Test VFSStatWatcher ref() and unref() methods +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/stat-ref-test.txt', 'content'); + + const listener = common.mustNotCall(); + const statWatcher = myVfs.watchFile( + '/stat-ref-test.txt', + { interval: 50, persistent: false }, + listener, + ); + + // Test unref() returns this + const unrefResult = statWatcher.unref(); + assert.strictEqual(unrefResult, statWatcher); + + // Test ref() returns this + const refResult = statWatcher.ref(); + assert.strictEqual(refResult, statWatcher); + + // Clean up + myVfs.unwatchFile('/stat-ref-test.txt', listener); +} From 51def85453f102e80b2f9dd86c6197032567895f Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 31 Jan 2026 21:50:47 +0100 Subject: [PATCH 25/32] vfs: remove unused utility functions from module_hooks Remove getActiveVFSCount() and areHooksInstalled() functions that were exported but never used anywhere in the codebase. This improves code coverage by removing unreachable code. --- lib/internal/vfs/module_hooks.js | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/lib/internal/vfs/module_hooks.js b/lib/internal/vfs/module_hooks.js index 6fd864c4ae6a0c..0fd3c61c5e31dc 100644 --- a/lib/internal/vfs/module_hooks.js +++ b/lib/internal/vfs/module_hooks.js @@ -676,27 +676,9 @@ function installHooks() { hooksInstalled = true; } -/** - * Get the count of active VFS instances. - * @returns {number} - */ -function getActiveVFSCount() { - return activeVFSList.length; -} - -/** - * Check if hooks are installed. - * @returns {boolean} - */ -function areHooksInstalled() { - return hooksInstalled; -} - module.exports = { registerVFS, unregisterVFS, findVFSForStat, findVFSForRead, - getActiveVFSCount, - areHooksInstalled, }; From d6db585e53d368ce0f3229047baa3841c440a4f9 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 1 Feb 2026 09:48:54 +0100 Subject: [PATCH 26/32] vfs: add test for symlink target creation after symlink Addresses @jasnell's review feedback to test that a symlink can be created before its target exists, and once the target is created, the symlink starts working correctly. --- test/parallel/test-vfs-symlinks.js | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/parallel/test-vfs-symlinks.js b/test/parallel/test-vfs-symlinks.js index a9526395bcb0b8..5d23d13894f1a8 100644 --- a/test/parallel/test-vfs-symlinks.js +++ b/test/parallel/test-vfs-symlinks.js @@ -316,6 +316,37 @@ const vfs = require('node:vfs'); myVfs.unmount(); } +// Test symlink target created after symlink (dangling symlink becomes valid) +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/dir'); + + // Create symlink pointing to non-existent target + myVfs.symlinkSync('/dir/target.txt', '/dir/link'); + myVfs.mount('/virtual'); + + // Initially the symlink is broken + assert.throws(() => { + myVfs.statSync('/virtual/dir/link'); + }, { code: 'ENOENT' }); + + // lstatSync works (symlink itself exists) + assert.strictEqual(myVfs.lstatSync('/virtual/dir/link').isSymbolicLink(), true); + + // Now create the target file + myVfs.writeFileSync('/virtual/dir/target.txt', 'created after symlink'); + + // Now the symlink should work + const content = myVfs.readFileSync('/virtual/dir/link', 'utf8'); + assert.strictEqual(content, 'created after symlink'); + + // Stat should also work now + const stats = myVfs.statSync('/virtual/dir/link'); + assert.strictEqual(stats.isFile(), true); + + myVfs.unmount(); +} + // Test symlink with parent traversal (..) { const myVfs = vfs.create(); From a4514bca7d176addfecdb21f1ac7149f35910e98 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 1 Feb 2026 10:13:26 +0100 Subject: [PATCH 27/32] doc: add worker thread limitations to VFS documentation Document that VFS instances are not shared across worker threads. Each worker has its own V8 isolate and module cache, so workers must create their own VFS instances. Includes workarounds: - Recreate VFS in each worker using workerData - Use RealFSProvider to mount same real directory Addresses @jasnell's review feedback about worker thread considerations. --- doc/api/vfs.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 8204d090f5c8d8..7406451a34ce60 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -713,6 +713,46 @@ myVfs.symlinkSync('/etc/passwd', '/passwd-link'); // myVfs.readFileSync('/passwd-link'); // Throws ENOENT ``` +## Worker threads + +VFS instances are **not shared across worker threads**. Each worker thread has +its own V8 isolate and module cache, which means: + +* A VFS mounted in the main thread is not accessible from worker threads +* Each worker thread must create and mount its own VFS instance +* VFS data is not synchronized between threads - changes in one thread are not + visible in another + +If you need to share virtual file content with worker threads, you must either: + +1. **Recreate the VFS in each worker** - Pass the data to workers via + `workerData` and have each worker create its own VFS: + +```cjs +const { Worker, isMainThread, workerData } = require('node:worker_threads'); +const vfs = require('node:vfs'); + +if (isMainThread) { + const fileData = { '/config.json': '{"key": "value"}' }; + new Worker(__filename, { workerData: fileData }); +} else { + // Worker: recreate VFS from passed data + const myVfs = vfs.create(); + for (const [path, content] of Object.entries(workerData)) { + myVfs.writeFileSync(path, content); + } + myVfs.mount('/virtual'); + // Now the worker has its own copy of the VFS +} +``` + +2. **Use `RealFSProvider`** - If the data exists on the real file system, use + `RealFSProvider` in each worker to mount the same directory. + +This limitation exists because implementing cross-thread VFS access would +require moving the implementation to C++ with shared memory management, which +significantly increases complexity. This may be addressed in future versions. + ## Security considerations ### Path shadowing From dae621c804aa70f120a8dedc366658cc3bad1ce6 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 1 Feb 2026 10:20:45 +0100 Subject: [PATCH 28/32] doc: clarify VFS accepts same types as fs module Add note that VFS methods accept the same argument types as their fs counterparts, including string, Buffer, TypedArray, and DataView. --- doc/api/vfs.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 7406451a34ce60..b1aacd0bddce3b 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -315,6 +315,9 @@ Throws `ERR_INVALID_STATE` if `virtualCwd` was not enabled during construction. The `VirtualFileSystem` class provides methods that mirror the `fs` module API. All paths are relative to the VFS root (not the mount point). +These methods accept the same argument types as their `fs` counterparts, +including `string`, `Buffer`, `TypedArray`, and `DataView` where applicable. + #### Synchronous Methods * `vfs.accessSync(path[, mode])` - Check file accessibility From ab1d435d76a98af458579224ca4660afb6b494c4 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 1 Feb 2026 11:57:25 +0100 Subject: [PATCH 29/32] vfs: remove unused entries.js, add error tests - Remove lib/internal/vfs/entries.js which was dead code (not imported anywhere in the codebase) - Add tests for ENOTEMPTY error (rmdir on non-empty directory) - Add tests for EEXIST error (mkdir on existing directory) PR-URL: https://github.com/nodejs/node/pull/61478 --- lib/internal/vfs/entries.js | 351 -------------------------------- test/parallel/test-vfs-basic.js | 30 +++ 2 files changed, 30 insertions(+), 351 deletions(-) delete mode 100644 lib/internal/vfs/entries.js diff --git a/lib/internal/vfs/entries.js b/lib/internal/vfs/entries.js deleted file mode 100644 index a079adac414b4c..00000000000000 --- a/lib/internal/vfs/entries.js +++ /dev/null @@ -1,351 +0,0 @@ -'use strict'; - -const { - Promise, - SafeMap, - Symbol, -} = primordials; - -const { Buffer } = require('buffer'); -const { - codes: { - ERR_INVALID_STATE, - }, -} = require('internal/errors'); -// Use path.posix.join for cross-platform consistency - VFS uses forward slashes internally -const { posix: { join } } = require('path'); -const { createFileStats, createDirectoryStats, createSymlinkStats } = require('internal/vfs/stats'); - -// Symbols for private properties -const kContent = Symbol('kContent'); -const kContentProvider = Symbol('kContentProvider'); -const kPopulate = Symbol('kPopulate'); -const kPopulated = Symbol('kPopulated'); -const kEntries = Symbol('kEntries'); -const kStats = Symbol('kStats'); -const kPath = Symbol('kPath'); -const kTarget = Symbol('kTarget'); - -/** - * Base class for virtual file system entries. - */ -class VirtualEntry { - /** - * @param {string} path The absolute path of this entry - */ - constructor(path) { - this[kPath] = path; - this[kStats] = null; - } - - /** - * Gets the absolute path of this entry. - * @returns {string} - */ - get path() { - return this[kPath]; - } - - /** - * Gets the stats for this entry. - * @returns {Stats} - */ - getStats() { - return this[kStats]; - } - - /** - * Returns true if this entry is a file. - * @returns {boolean} - */ - isFile() { - return false; - } - - /** - * Returns true if this entry is a directory. - * @returns {boolean} - */ - isDirectory() { - return false; - } - - /** - * Returns true if this entry is a symbolic link. - * @returns {boolean} - */ - isSymbolicLink() { - return false; - } -} - -/** - * Represents a virtual file with static or dynamic content. - */ -class VirtualFile extends VirtualEntry { - /** - * @param {string} path The absolute path of this file - * @param {Buffer|string|Function} content The file content or content provider - * @param {object} [options] Optional configuration - * @param {number} [options.mode] File mode (default: 0o644) - */ - constructor(path, content, options = {}) { - super(path); - - if (typeof content === 'function') { - this[kContentProvider] = content; - this[kContent] = null; - // For dynamic content, we don't know the size until we call the provider - // Use 0 as placeholder, will be updated on first access - this[kStats] = createFileStats(0, options); - } else { - this[kContentProvider] = null; - this[kContent] = typeof content === 'string' ? Buffer.from(content) : content; - this[kStats] = createFileStats(this[kContent].length, options); - } - } - - /** - * @returns {boolean} - */ - isFile() { - return true; - } - - /** - * Returns true if this file has dynamic content. - * @returns {boolean} - */ - isDynamic() { - return this[kContentProvider] !== null; - } - - /** - * Gets the file content synchronously. - * @returns {Buffer} - * @throws {Error} If content provider is async-only - */ - getContentSync() { - if (this[kContentProvider] !== null) { - const result = this[kContentProvider](); - if (result instanceof Promise) { - throw new ERR_INVALID_STATE('cannot use sync API with async content provider'); - } - const buffer = typeof result === 'string' ? Buffer.from(result) : result; - // Update stats with actual size - this[kStats] = createFileStats(buffer.length); - return buffer; - } - return this[kContent]; - } - - /** - * Gets the file content asynchronously. - * @returns {Promise} - */ - async getContent() { - if (this[kContentProvider] !== null) { - const result = await this[kContentProvider](); - const buffer = typeof result === 'string' ? Buffer.from(result) : result; - // Update stats with actual size - this[kStats] = createFileStats(buffer.length); - return buffer; - } - return this[kContent]; - } - - /** - * Gets the file size. For dynamic content, this may be 0 until first access. - * @returns {number} - */ - get size() { - return this[kStats].size; - } -} - -/** - * Represents a virtual directory with static or dynamic entries. - */ -class VirtualDirectory extends VirtualEntry { - /** - * @param {string} path The absolute path of this directory - * @param {Function} [populate] Optional callback to populate directory contents - * @param {object} [options] Optional configuration - * @param {number} [options.mode] Directory mode (default: 0o755) - */ - constructor(path, populate, options = {}) { - super(path); - this[kEntries] = new SafeMap(); - this[kPopulate] = typeof populate === 'function' ? populate : null; - this[kPopulated] = this[kPopulate] === null; // Static dirs are already populated - this[kStats] = createDirectoryStats(options); - } - - /** - * @returns {boolean} - */ - isDirectory() { - return true; - } - - /** - * Returns true if this directory has a populate callback. - * @returns {boolean} - */ - isDynamic() { - return this[kPopulate] !== null; - } - - /** - * Returns true if this directory has been populated. - * @returns {boolean} - */ - isPopulated() { - return this[kPopulated]; - } - - /** - * Ensures the directory is populated (calls populate callback if needed). - * This is synchronous - the populate callback must be synchronous. - */ - ensurePopulated() { - if (!this[kPopulated]) { - const scopedVfs = createScopedVFS(this, (name, entry) => { - this[kEntries].set(name, entry); - }); - this[kPopulate](scopedVfs); - this[kPopulated] = true; - } - } - - /** - * Gets an entry by name. - * @param {string} name The entry name - * @returns {VirtualEntry|undefined} - */ - getEntry(name) { - this.ensurePopulated(); - return this[kEntries].get(name); - } - - /** - * Checks if an entry exists. - * @param {string} name The entry name - * @returns {boolean} - */ - hasEntry(name) { - this.ensurePopulated(); - return this[kEntries].has(name); - } - - /** - * Adds an entry to this directory. - * @param {string} name The entry name - * @param {VirtualEntry} entry The entry to add - */ - addEntry(name, entry) { - this[kEntries].set(name, entry); - } - - /** - * Removes an entry from this directory. - * @param {string} name The entry name - * @returns {boolean} True if the entry was removed - */ - removeEntry(name) { - return this[kEntries].delete(name); - } - - /** - * Gets all entry names in this directory. - * @returns {string[]} - */ - getEntryNames() { - this.ensurePopulated(); - return [...this[kEntries].keys()]; - } - - /** - * Gets all entries in this directory. - * @returns {IterableIterator<[string, VirtualEntry]>} - */ - getEntries() { - this.ensurePopulated(); - return this[kEntries].entries(); - } -} - -/** - * Represents a virtual symbolic link. - */ -class VirtualSymlink extends VirtualEntry { - /** - * @param {string} path The absolute path of this symlink - * @param {string} target The symlink target (can be relative or absolute) - * @param {object} [options] Optional configuration - * @param {number} [options.mode] Symlink mode (default: 0o777) - */ - constructor(path, target, options = {}) { - super(path); - this[kTarget] = target; - this[kStats] = createSymlinkStats(target.length, options); - } - - /** - * @returns {boolean} - */ - isSymbolicLink() { - return true; - } - - /** - * Gets the symlink target path. - * @returns {string} - */ - get target() { - return this[kTarget]; - } -} - -/** - * Creates a scoped VFS interface for dynamic directory populate callbacks. - * @param {VirtualDirectory} directory The parent directory - * @param {Function} addEntry Callback to add an entry - * @returns {object} Scoped VFS interface - */ -function createScopedVFS(directory, addEntry) { - return { - __proto__: null, - addFile(name, content, options) { - const filePath = join(directory.path, name); - const file = new VirtualFile(filePath, content, options); - addEntry(name, file); - }, - addDirectory(name, populate, options) { - const dirPath = join(directory.path, name); - const dir = new VirtualDirectory(dirPath, populate, options); - addEntry(name, dir); - }, - addSymlink(name, target, options) { - const linkPath = join(directory.path, name); - const symlink = new VirtualSymlink(linkPath, target, options); - addEntry(name, symlink); - }, - }; -} - -module.exports = { - VirtualEntry, - VirtualFile, - VirtualDirectory, - VirtualSymlink, - createScopedVFS, - kContent, - kContentProvider, - kPopulate, - kPopulated, - kEntries, - kStats, - kPath, - kTarget, -}; diff --git a/test/parallel/test-vfs-basic.js b/test/parallel/test-vfs-basic.js index d4096e4c0e66b8..e3ae9263a398e5 100644 --- a/test/parallel/test-vfs-basic.js +++ b/test/parallel/test-vfs-basic.js @@ -157,3 +157,33 @@ const vfs = require('node:vfs'); myVfs.realpathSync('/nonexistent'); }, { code: 'ENOENT' }); } + +// Test rmdir on non-empty directory throws ENOTEMPTY +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/parent', { recursive: true }); + myVfs.writeFileSync('/parent/file.txt', 'content'); + + assert.throws(() => { + myVfs.rmdirSync('/parent'); + }, { code: 'ENOTEMPTY' }); + + // After removing the file, rmdir should succeed + myVfs.unlinkSync('/parent/file.txt'); + myVfs.rmdirSync('/parent'); + assert.strictEqual(myVfs.existsSync('/parent'), false); +} + +// Test mkdir on existing directory throws EEXIST (without recursive) +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/existing', { recursive: true }); + + assert.throws(() => { + myVfs.mkdirSync('/existing'); + }, { code: 'EEXIST' }); + + // With recursive: true, it should not throw + myVfs.mkdirSync('/existing', { recursive: true }); + assert.strictEqual(myVfs.existsSync('/existing'), true); +} From 94b014b2368fce207755dce6a57fec3292e53a1c Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 1 Feb 2026 14:28:59 +0100 Subject: [PATCH 30/32] vfs: remove unused exports from fd.js and stats.js - Remove unused isVirtualFd() function from fd.js - Remove unused getOpenFdCount() function from fd.js - Remove unused kDefaultBlockSize export from stats.js (still used internally) These functions were exported but never used anywhere in the codebase. PR-URL: https://github.com/nodejs/node/pull/61478 --- lib/internal/vfs/fd.js | 19 ------------------- lib/internal/vfs/stats.js | 1 - 2 files changed, 20 deletions(-) diff --git a/lib/internal/vfs/fd.js b/lib/internal/vfs/fd.js index 3bc5811416459b..9111e544454c56 100644 --- a/lib/internal/vfs/fd.js +++ b/lib/internal/vfs/fd.js @@ -133,29 +133,10 @@ function closeVirtualFd(fd) { return openFDs.delete(fd); } -/** - * Checks if a file descriptor is a virtual fd. - * @param {number} fd The file descriptor number - * @returns {boolean} - */ -function isVirtualFd(fd) { - return openFDs.has(fd); -} - -/** - * Gets the count of open virtual file descriptors. - * @returns {number} - */ -function getOpenFdCount() { - return openFDs.size; -} - module.exports = { VirtualFD, VFS_FD_BASE, openVirtualFd, getVirtualFd, closeVirtualFd, - isVirtualFd, - getOpenFdCount, }; diff --git a/lib/internal/vfs/stats.js b/lib/internal/vfs/stats.js index 9ebc44fbb385b5..7015b8fd244d15 100644 --- a/lib/internal/vfs/stats.js +++ b/lib/internal/vfs/stats.js @@ -192,5 +192,4 @@ module.exports = { createFileStats, createDirectoryStats, createSymlinkStats, - kDefaultBlockSize, }; From 0b81588421426825b69c9087ef6d66af2d9ea86e Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 1 Feb 2026 16:37:54 +0100 Subject: [PATCH 31/32] vfs: remove unused VirtualFD methods Remove unused methods from VirtualFD class: - get path() - never accessed - getContentSync() - never called - getContent() - never called Also removes the unused path parameter from VirtualFD constructor and openVirtualFd function. PR-URL: https://github.com/nodejs/node/pull/61478 --- lib/internal/vfs/fd.js | 34 +++------------------------------ lib/internal/vfs/file_system.js | 4 ++-- 2 files changed, 5 insertions(+), 33 deletions(-) diff --git a/lib/internal/vfs/fd.js b/lib/internal/vfs/fd.js index 9111e544454c56..2c4eb61b8d2ff4 100644 --- a/lib/internal/vfs/fd.js +++ b/lib/internal/vfs/fd.js @@ -9,7 +9,6 @@ const { const kFd = Symbol('kFd'); const kEntry = Symbol('kEntry'); const kFlags = Symbol('kFlags'); -const kPath = Symbol('kPath'); // FD range: 10000+ to avoid conflicts with real fds const VFS_FD_BASE = 10_000; @@ -27,13 +26,11 @@ class VirtualFD { * @param {number} fd The file descriptor number * @param {VirtualFileHandle} entry The virtual file handle * @param {string} flags The open flags (r, r+, w, w+, a, a+) - * @param {string} path The path used to open the file */ - constructor(fd, entry, flags, path) { + constructor(fd, entry, flags) { this[kFd] = fd; this[kEntry] = entry; this[kFlags] = flags; - this[kPath] = path; } /** @@ -75,42 +72,17 @@ class VirtualFD { get flags() { return this[kFlags]; } - - /** - * Gets the path used to open the file. - * @returns {string} - */ - get path() { - return this[kPath]; - } - - /** - * Gets the content buffer synchronously. - * @returns {Buffer} - */ - getContentSync() { - return this[kEntry].readFileSync(); - } - - /** - * Gets the content buffer asynchronously. - * @returns {Promise} - */ - async getContent() { - return this[kEntry].readFile(); - } } /** * Opens a virtual file and returns its file descriptor. * @param {VirtualFileHandle} entry The virtual file handle * @param {string} flags The open flags - * @param {string} path The path used to open the file * @returns {number} The file descriptor */ -function openVirtualFd(entry, flags, path) { +function openVirtualFd(entry, flags) { const fd = nextFd++; - const vfd = new VirtualFD(fd, entry, flags, path); + const vfd = new VirtualFD(fd, entry, flags); openFDs.set(fd, vfd); return fd; } diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js index 111f1e7c1e0934..b28f9952f7bdb1 100644 --- a/lib/internal/vfs/file_system.js +++ b/lib/internal/vfs/file_system.js @@ -597,7 +597,7 @@ class VirtualFileSystem { openSync(filePath, flags = 'r', mode) { const providerPath = this._toProviderPath(filePath); const handle = this[kProvider].openSync(providerPath, flags, mode); - return openVirtualFd(handle, flags, this._toMountedPath(providerPath)); + return openVirtualFd(handle, flags); } /** @@ -803,7 +803,7 @@ class VirtualFileSystem { const providerPath = this._toProviderPath(filePath); this[kProvider].open(providerPath, flags, mode) .then((handle) => { - const fd = openVirtualFd(handle, flags, this._toMountedPath(providerPath)); + const fd = openVirtualFd(handle, flags); callback(null, fd); }) .catch((err) => callback(err)); From 612abc3691c96f0db4870d15ce9ccd499f083b08 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 1 Feb 2026 22:05:32 +0100 Subject: [PATCH 32/32] vfs: remove more unused VirtualFD code Remove additional unused code from VirtualFD class: - get position() / set position() - never accessed - get flags() - never accessed - kFlags symbol - no longer needed - flags parameter from constructor and openVirtualFd() fd.js reduced from 162 to 87 lines. PR-URL: https://github.com/nodejs/node/pull/61478 --- lib/internal/vfs/fd.js | 34 +++------------------------------ lib/internal/vfs/file_system.js | 4 ++-- 2 files changed, 5 insertions(+), 33 deletions(-) diff --git a/lib/internal/vfs/fd.js b/lib/internal/vfs/fd.js index 2c4eb61b8d2ff4..340356c63e47a9 100644 --- a/lib/internal/vfs/fd.js +++ b/lib/internal/vfs/fd.js @@ -8,7 +8,6 @@ const { // Private symbols const kFd = Symbol('kFd'); const kEntry = Symbol('kEntry'); -const kFlags = Symbol('kFlags'); // FD range: 10000+ to avoid conflicts with real fds const VFS_FD_BASE = 10_000; @@ -25,12 +24,10 @@ class VirtualFD { /** * @param {number} fd The file descriptor number * @param {VirtualFileHandle} entry The virtual file handle - * @param {string} flags The open flags (r, r+, w, w+, a, a+) */ - constructor(fd, entry, flags) { + constructor(fd, entry) { this[kFd] = fd; this[kEntry] = entry; - this[kFlags] = flags; } /** @@ -48,41 +45,16 @@ class VirtualFD { get entry() { return this[kEntry]; } - - /** - * Gets the current position. - * @returns {number} - */ - get position() { - return this[kEntry].position; - } - - /** - * Sets the current position. - * @param {number} pos The new position - */ - set position(pos) { - this[kEntry].position = pos; - } - - /** - * Gets the open flags. - * @returns {string} - */ - get flags() { - return this[kFlags]; - } } /** * Opens a virtual file and returns its file descriptor. * @param {VirtualFileHandle} entry The virtual file handle - * @param {string} flags The open flags * @returns {number} The file descriptor */ -function openVirtualFd(entry, flags) { +function openVirtualFd(entry) { const fd = nextFd++; - const vfd = new VirtualFD(fd, entry, flags); + const vfd = new VirtualFD(fd, entry); openFDs.set(fd, vfd); return fd; } diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js index b28f9952f7bdb1..8a7d9c63ca2efd 100644 --- a/lib/internal/vfs/file_system.js +++ b/lib/internal/vfs/file_system.js @@ -597,7 +597,7 @@ class VirtualFileSystem { openSync(filePath, flags = 'r', mode) { const providerPath = this._toProviderPath(filePath); const handle = this[kProvider].openSync(providerPath, flags, mode); - return openVirtualFd(handle, flags); + return openVirtualFd(handle); } /** @@ -803,7 +803,7 @@ class VirtualFileSystem { const providerPath = this._toProviderPath(filePath); this[kProvider].open(providerPath, flags, mode) .then((handle) => { - const fd = openVirtualFd(handle, flags); + const fd = openVirtualFd(handle); callback(null, fd); }) .catch((err) => callback(err));