From 28ac77e5f82626a9f98074c5e383231728776b59 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 23 Jan 2026 00:26:10 +0100 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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); +}