From 956295e7a76743afb16869059395c186e3631527 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 27 Jan 2026 09:08:57 +0100 Subject: [PATCH 1/6] vfs: add provider-based architecture and node:vfs module Redesign VFS with a Provider-based architecture: - Add VirtualProvider base class defining the provider interface - Add MemoryProvider for in-memory file storage with full read/write - Add SEAProvider for read-only access to SEA assets - Add VirtualFileSystem class as thin wrapper delegating to providers - Add VirtualFileHandle and MemoryFileHandle for file operations - Register node:vfs as a builtin module (scheme-only access) - Add comprehensive API documentation in doc/api/vfs.md The provider interface supports: - Essential primitives: open, stat, readdir, mkdir, rmdir, unlink, rename - Derived methods: readFile, writeFile, exists, copyFile, etc. - Capability flags: readonly, supportsSymlinks - Dynamic content providers (functions called on each read) - Lazy directory population --- doc/api/index.md | 1 + doc/api/vfs.md | 578 ++++++++++++++ lib/fs.js | 2 +- lib/internal/bootstrap/realm.js | 1 + lib/internal/vfs/fd.js | 27 +- lib/internal/vfs/file_handle.js | 527 +++++++++++++ lib/internal/vfs/file_system.js | 1092 ++++++++++++++++++++++++++ lib/internal/vfs/provider.js | 499 ++++++++++++ lib/internal/vfs/providers/memory.js | 766 ++++++++++++++++++ lib/internal/vfs/providers/sea.js | 429 ++++++++++ lib/internal/vfs/sea.js | 45 +- lib/internal/vfs/streams.js | 3 +- lib/vfs.js | 78 ++ 13 files changed, 3996 insertions(+), 52 deletions(-) create mode 100644 doc/api/vfs.md create mode 100644 lib/internal/vfs/file_handle.js create mode 100644 lib/internal/vfs/file_system.js create mode 100644 lib/internal/vfs/provider.js create mode 100644 lib/internal/vfs/providers/memory.js create mode 100644 lib/internal/vfs/providers/sea.js create mode 100644 lib/vfs.js diff --git a/doc/api/index.md b/doc/api/index.md index 8db8b8c88062..d7a04fcea1ef 100644 --- a/doc/api/index.md +++ b/doc/api/index.md @@ -66,6 +66,7 @@ * [URL](url.md) * [Utilities](util.md) * [V8](v8.md) +* [Virtual File System](vfs.md) * [VM](vm.md) * [WASI](wasi.md) * [Web Crypto API](webcrypto.md) diff --git a/doc/api/vfs.md b/doc/api/vfs.md new file mode 100644 index 000000000000..5e9106abd071 --- /dev/null +++ b/doc/api/vfs.md @@ -0,0 +1,578 @@ +# Virtual File System + + + + + +> 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 3e2d6e8ce7c0..8e7bf9804a70 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 f49f0814bbc6..6e25416d6db5 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 93975ad0fc91..3bc581141645 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 000000000000..a014de7263e9 --- /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 000000000000..acef3e4b9ddd --- /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 000000000000..bc713c412221 --- /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 000000000000..487a1b4d755f --- /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 000000000000..983933e7651c --- /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 f99da97fd8a4..a5a2c80f3c14 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 71f6c234c6b3..a07fd04bceb9 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 000000000000..4110fc983461 --- /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 2/6] 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 5e9106abd071..332dcc369341 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`