node/lib/internal/vfs/file_system.js
Matteo Collina 569369f927
vfs: dispatch fs/promises to mounted VFS instances
Add mount/unmount lifecycle on `VirtualFileSystem`, a handler registry
that fs.js and fs/promises.js consult via `vfsState.handlers`, and a
router that maps absolute paths to the VFS that owns them. When a VFS
is mounted, the public `fs.*` and `fs/promises` APIs (including
streams, `fs.watch`, and `opendir`) dispatch to the provider for paths
under the mount point, and fall through to the real filesystem
otherwise. Includes per-method dispatch tests, error-path coverage,
multi-mount routing tests, and router unit tests.

Ref: https://github.com/nodejs/node/pull/63115

Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/63537
Refs: https://github.com/nodejs/node/pull/63115
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
2026-05-29 17:57:34 +00:00

1271 lines
37 KiB
JavaScript

'use strict';
const {
MathRandom,
ObjectFreeze,
Symbol,
SymbolDispose,
} = primordials;
const {
codes: {
ERR_INVALID_STATE,
},
} = require('internal/errors');
const { validateBoolean } = require('internal/validators');
const { MemoryProvider } = require('internal/vfs/providers/memory');
const path = require('path');
const { posix: pathPosix, isAbsolute, resolve: resolvePath } = path;
const { join: joinPath } = pathPosix;
const {
isUnderMountPoint,
getRelativePath,
} = require('internal/vfs/router');
const {
openVirtualFd,
getVirtualFd,
closeVirtualFd,
} = require('internal/vfs/fd');
const {
createENOENT,
createEBADF,
createEISDIR,
} = require('internal/vfs/errors');
const { VirtualReadStream, VirtualWriteStream } = require('internal/vfs/streams');
const { VirtualDir } = require('internal/vfs/dir');
const { emitExperimentalWarning, kEmptyObject } = require('internal/util');
let debug = require('internal/util/debuglog').debuglog('vfs', (fn) => {
debug = fn;
});
// Private symbols
const kProvider = Symbol('kProvider');
const kMountPoint = Symbol('kMountPoint');
const kMounted = Symbol('kMounted');
const kPromises = Symbol('kPromises');
// Lazy-loaded VFS setup
let registerVFS;
let deregisterVFS;
function loadVfsSetup() {
if (!registerVFS) {
const setup = require('internal/vfs/setup');
registerVFS = setup.registerVFS;
deregisterVFS = setup.deregisterVFS;
}
}
/**
* Virtual File System implementation using Provider architecture.
* Wraps a Provider and exposes an fs-like API operating on
* provider-relative paths.
*/
class VirtualFileSystem {
/**
* @param {VirtualProvider|object} [providerOrOptions] The provider to use, or options
* @param {object} [options] Configuration options
* @param {boolean} [options.emitExperimentalWarning] Emit the experimental warning (default: true)
*/
constructor(providerOrOptions, options = kEmptyObject) {
// Handle case where first arg is options object (no provider)
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 (no provider specified)
options = providerOrOptions;
provider = null;
}
}
if (options.emitExperimentalWarning !== undefined) {
validateBoolean(options.emitExperimentalWarning, 'options.emitExperimentalWarning');
}
if (options.emitExperimentalWarning !== false) {
emitExperimentalWarning('VirtualFileSystem');
}
this[kProvider] = provider ?? new MemoryProvider();
this[kMountPoint] = null;
this[kMounted] = false;
this[kPromises] = null; // Lazy-initialized
}
/**
* 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 mounted() {
return this[kMounted];
}
/**
* Returns true if the provider is read-only.
* @returns {boolean}
*/
get readonly() {
return this[kProvider].readonly;
}
// ==================== Mount ====================
/**
* Mounts the VFS at a specific path prefix.
* @param {string} prefix The mount point path
* @returns {VirtualFileSystem} The VFS instance for chaining
*/
mount(prefix) {
if (this[kMounted]) {
throw new ERR_INVALID_STATE('VFS is already mounted');
}
this[kMountPoint] = resolvePath(prefix);
this[kMounted] = true;
debug('mount %s', this[kMountPoint]);
loadVfsSetup();
registerVFS(this);
return this;
}
/**
* Unmounts the VFS.
*/
unmount() {
debug('unmount %s', this[kMountPoint]);
loadVfsSetup();
deregisterVFS(this);
this[kMountPoint] = null;
this[kMounted] = false;
}
/**
* Disposes of the VFS by unmounting it.
* Supports the Explicit Resource Management proposal (using declaration).
*/
[SymbolDispose]() {
if (this[kMounted]) {
this.unmount();
}
}
/**
* Checks if a path should be handled by this VFS.
* @param {string} inputPath The path to check (must be absolute & normalized)
* @returns {boolean}
*/
shouldHandle(inputPath) {
if (!this[kMounted] || !this[kMountPoint]) {
return false;
}
const normalized = isAbsolute(inputPath) ? inputPath : resolvePath(inputPath);
return isUnderMountPoint(normalized, this[kMountPoint]);
}
// ==================== Path Resolution ====================
/**
* Converts an absolute mounted path to a provider-relative POSIX path.
* If not mounted, treats the path as already provider-relative.
* @param {string} inputPath The path to convert
* @returns {string}
*/
#toProviderPath(inputPath) {
if (this[kMounted] && this[kMountPoint]) {
const resolved = isAbsolute(inputPath) ? inputPath : resolvePath(inputPath);
if (!isUnderMountPoint(resolved, this[kMountPoint])) {
throw createENOENT('open', inputPath);
}
return getRelativePath(resolved, this[kMountPoint]);
}
return pathPosix.normalize(inputPath);
}
/**
* Converts a provider-relative path back to a mounted path.
* If not mounted, returns the path as-is.
* @param {string} providerPath The provider-relative path
* @returns {string} The mounted path
*/
#toMountedPath(providerPath) {
if (this[kMounted] && this[kMountPoint]) {
return path.join(this[kMountPoint], providerPath);
}
return providerPath;
}
// ==================== 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);
const result = this[kProvider].readdirSync(providerPath, options);
// Fix Dirent parentPath from provider-relative to actual VFS path
if (options?.withFileTypes === true) {
const recursive = options?.recursive === true;
for (let i = 0; i < result.length; i++) {
const dirent = result[i];
if (recursive) {
// In recursive mode, name may contain slashes (e.g. 'a/b.txt').
// Fix to basename only and set correct parentPath.
const slashIdx = dirent.name.lastIndexOf('/');
if (slashIdx !== -1) {
const subdir = dirent.name.slice(0, slashIdx);
dirent.parentPath = joinPath(dirPath, subdir);
dirent.name = dirent.name.slice(slashIdx + 1);
} else {
dirent.parentPath = dirPath;
}
} else {
dirent.parentPath = dirPath;
}
}
}
return result;
}
/**
* 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);
return this[kProvider].mkdirSync(providerPath, options);
}
/**
* 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);
}
/**
* Removes a file or directory synchronously.
* @param {string} filePath The path to remove
* @param {object} [options] Options
* @param {boolean} [options.recursive] If true, remove directories recursively
* @param {boolean} [options.force] If true, ignore ENOENT errors
*/
rmSync(filePath, options) {
const recursive = options?.recursive === true;
const force = options?.force === true;
let stats;
try {
stats = this.lstatSync(filePath);
} catch (err) {
if (force && err?.code === 'ENOENT') return;
throw err;
}
// Symlinks should be unlinked directly, never recursed into
if (stats.isSymbolicLink()) {
this.unlinkSync(filePath);
return;
}
if (stats.isDirectory()) {
if (!recursive) {
throw createEISDIR('rm', filePath);
}
const entries = this.readdirSync(filePath);
for (let i = 0; i < entries.length; i++) {
this.rmSync(joinPath(filePath, entries[i]), options);
}
this.rmdirSync(filePath);
} else {
this.unlinkSync(filePath);
}
}
// ==================== Additional Sync Operations ====================
/**
* Truncates a file synchronously.
* @param {string} filePath The file path
* @param {number} [len] The new length
*/
truncateSync(filePath, len = 0) {
if (len < 0) len = 0;
const providerPath = this.#toProviderPath(filePath);
const handle = this[kProvider].openSync(providerPath, 'r+');
try {
handle.truncateSync(len);
} finally {
handle.closeSync();
}
}
/**
* Truncates a file descriptor synchronously.
* @param {number} fd The file descriptor
* @param {number} [len] The new length
*/
ftruncateSync(fd, len = 0) {
const vfd = getVirtualFd(fd);
if (!vfd) {
throw createEBADF('ftruncate');
}
vfd.entry.truncateSync(len);
}
/**
* Creates a hard link synchronously.
* @param {string} existingPath The existing file path
* @param {string} newPath The new link path
*/
linkSync(existingPath, newPath) {
const existingProviderPath = this.#toProviderPath(existingPath);
const newProviderPath = this.#toProviderPath(newPath);
this[kProvider].linkSync(existingProviderPath, newProviderPath);
}
chmodSync(filePath, mode) {
const providerPath = this.#toProviderPath(filePath);
this[kProvider].chmodSync(providerPath, mode);
}
chownSync(filePath, uid, gid) {
const providerPath = this.#toProviderPath(filePath);
this[kProvider].chownSync(providerPath, uid, gid);
}
utimesSync(filePath, atime, mtime) {
const providerPath = this.#toProviderPath(filePath);
this[kProvider].utimesSync(providerPath, atime, mtime);
}
lutimesSync(filePath, atime, mtime) {
const providerPath = this.#toProviderPath(filePath);
this[kProvider].lutimesSync(providerPath, atime, mtime);
}
/**
* Creates a unique temporary directory synchronously.
* @param {string} prefix The prefix for the temp directory
* @returns {string} The full path of the created directory
*/
mkdtempSync(prefix) {
const providerPrefix = this.#toProviderPath(prefix);
// Generate random 6-character suffix like Node does
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let suffix = '';
for (let i = 0; i < 6; i++) {
suffix += chars[(MathRandom() * chars.length) | 0];
}
const dirPath = providerPrefix + suffix;
this[kProvider].mkdirSync(dirPath);
return this.#toMountedPath(dirPath);
}
/**
* Opens a directory synchronously.
* @param {string} dirPath The directory path
* @param {object} [options] Options
* @returns {VirtualDir} A directory handle
*/
opendirSync(dirPath, options) {
const entries = this.readdirSync(dirPath, {
withFileTypes: true,
recursive: options?.recursive,
});
return new VirtualDir(dirPath, entries);
}
/**
* Opens a file as a Blob.
* @param {string} filePath The file path
* @param {object} [options] Options
* @returns {Blob} The file content as a Blob
*/
openAsBlob(filePath, options) {
const { Blob } = require('buffer');
const providerPath = this.#toProviderPath(filePath);
const content = this[kProvider].readFileSync(providerPath);
const type = options?.type || '';
return new Blob([content], { type });
}
// ==================== 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);
}
/**
* 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);
}
/**
* Writes to a file descriptor synchronously.
* @param {number} fd The file descriptor
* @param {Buffer} buffer The buffer to write from
* @param {number} offset The offset in the buffer
* @param {number} length The number of bytes to write
* @param {number|null} position The position in the file
* @returns {number} The number of bytes written
*/
writeSync(fd, buffer, offset, length, position) {
const vfd = getVirtualFd(fd);
if (!vfd) {
throw createEBADF('write');
}
return vfd.entry.writeSync(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), (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), (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), (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), (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), (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)),
(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), (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), (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);
callback(null, fd);
}, (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);
}, (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), (err) => callback(err));
}
/**
* Writes to a file descriptor asynchronously.
* @param {number} fd The file descriptor
* @param {Buffer} buffer The buffer to write from
* @param {number} offset The offset in the buffer
* @param {number} length The number of bytes to write
* @param {number|null} position The position in the file
* @param {Function} callback Callback (err, bytesWritten, buffer)
*/
write(fd, buffer, offset, length, position, callback) {
const vfd = getVirtualFd(fd);
if (!vfd) {
process.nextTick(callback, createEBADF('write'));
return;
}
vfd.entry.write(buffer, offset, length, position)
.then(({ bytesWritten }) => callback(null, bytesWritten, buffer), (err) => callback(err));
}
/**
* Removes a file or directory asynchronously.
* @param {string} filePath The path to remove
* @param {object|Function} [options] Options or callback
* @param {Function} [callback] Callback (err)
*/
rm(filePath, options, callback) {
if (typeof options === 'function') {
callback = options;
options = undefined;
}
try {
this.rmSync(filePath, options);
process.nextTick(callback, null);
} catch (err) {
process.nextTick(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), (err) => callback(err));
}
/**
* Truncates a file asynchronously.
* @param {string} filePath The file path
* @param {number|Function} [len] The new length or callback
* @param {Function} [callback] Callback (err)
*/
truncate(filePath, len, callback) {
if (typeof len === 'function') {
callback = len;
len = 0;
}
try {
this.truncateSync(filePath, len);
process.nextTick(callback, null);
} catch (err) {
process.nextTick(callback, err);
}
}
/**
* Truncates a file descriptor asynchronously.
* @param {number} fd The file descriptor
* @param {number|Function} [len] The new length or callback
* @param {Function} [callback] Callback (err)
*/
ftruncate(fd, len, callback) {
if (typeof len === 'function') {
callback = len;
len = 0;
}
try {
this.ftruncateSync(fd, len);
process.nextTick(callback, null);
} catch (err) {
process.nextTick(callback, err);
}
}
/**
* Creates a hard link asynchronously.
* @param {string} existingPath The existing file path
* @param {string} newPath The new link path
* @param {Function} callback Callback (err)
*/
link(existingPath, newPath, callback) {
try {
this.linkSync(existingPath, newPath);
process.nextTick(callback, null);
} catch (err) {
process.nextTick(callback, err);
}
}
/**
* Creates a unique temporary directory asynchronously.
* @param {string} prefix The prefix for the temp directory
* @param {object|Function} [options] Options or callback
* @param {Function} [callback] Callback (err, dirPath)
*/
mkdtemp(prefix, options, callback) {
if (typeof options === 'function') {
callback = options;
options = undefined;
}
try {
const dirPath = this.mkdtempSync(prefix);
process.nextTick(callback, null, dirPath);
} catch (err) {
process.nextTick(callback, err);
}
}
/**
* Opens a directory asynchronously.
* @param {string} dirPath The directory path
* @param {object|Function} [options] Options or callback
* @param {Function} [callback] Callback (err, dir)
*/
opendir(dirPath, options, callback) {
if (typeof options === 'function') {
callback = options;
options = undefined;
}
try {
const dir = this.opendirSync(dirPath, options);
process.nextTick(callback, null, dir);
} catch (err) {
process.nextTick(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 new VirtualReadStream(this, filePath, options);
}
/**
* Creates a writable stream for a virtual file.
* @param {string} filePath The path to the file
* @param {object} [options] Stream options
* @returns {WriteStream}
*/
createWriteStream(filePath, options) {
return new VirtualWriteStream(this, filePath, options);
}
// ==================== Watch Operations ====================
/**
* Watches a file or directory for changes.
* @param {string} filePath The path to watch
* @param {object|Function} [options] Watch options or listener
* @param {Function} [listener] Change listener
* @returns {EventEmitter} A watcher that emits 'change' events
*/
watch(filePath, options, listener) {
if (typeof options === 'function') {
listener = options;
options = {};
}
const providerPath = this.#toProviderPath(filePath);
const watcher = this[kProvider].watch(providerPath, options);
if (listener) {
watcher.on('change', listener);
}
return watcher;
}
/**
* Watches a file for changes using stat polling.
* @param {string} filePath The path to watch
* @param {object|Function} [options] Watch options or listener
* @param {Function} [listener] Change listener
* @returns {EventEmitter} A stat watcher that emits 'change' events
*/
watchFile(filePath, options, listener) {
if (typeof options === 'function') {
listener = options;
options = {};
}
const providerPath = this.#toProviderPath(filePath);
return this[kProvider].watchFile(providerPath, options, listener);
}
/**
* Stops watching a file for changes.
* @param {string} filePath The path to stop watching
* @param {Function} [listener] Optional listener to remove
*/
unwatchFile(filePath, listener) {
const providerPath = this.#toProviderPath(filePath);
this[kProvider].unwatchFile(providerPath, listener);
}
// ==================== Promise API ====================
/**
* Gets the promises API for this VFS instance.
* @returns {object} Promise-based fs methods
*/
get promises() {
if (this[kPromises] === null) {
this[kPromises] = this.#createPromisesAPI();
}
return this[kPromises];
}
/**
* Creates the promises API object for this VFS instance.
* @returns {object} Promise-based fs methods
*/
#createPromisesAPI() {
const provider = this[kProvider];
// Use arrow function to capture `this` for private method access
const toProviderPath = (p) => this.#toProviderPath(p);
const toMountedPath = (p) => this.#toMountedPath(p);
return ObjectFreeze({
async readFile(filePath, options) {
const providerPath = toProviderPath(filePath);
return provider.readFile(providerPath, options);
},
async writeFile(filePath, data, options) {
const providerPath = toProviderPath(filePath);
return provider.writeFile(providerPath, data, options);
},
async appendFile(filePath, data, options) {
const providerPath = toProviderPath(filePath);
return provider.appendFile(providerPath, data, options);
},
async stat(filePath, options) {
const providerPath = toProviderPath(filePath);
return provider.stat(providerPath, options);
},
async lstat(filePath, options) {
const providerPath = toProviderPath(filePath);
return provider.lstat(providerPath, options);
},
async readdir(dirPath, options) {
const providerPath = toProviderPath(dirPath);
return provider.readdir(providerPath, options);
},
async mkdir(dirPath, options) {
const providerPath = toProviderPath(dirPath);
return provider.mkdir(providerPath, options);
},
async rmdir(dirPath) {
const providerPath = toProviderPath(dirPath);
return provider.rmdir(providerPath);
},
async unlink(filePath) {
const providerPath = toProviderPath(filePath);
return provider.unlink(providerPath);
},
async rename(oldPath, newPath) {
const oldProviderPath = toProviderPath(oldPath);
const newProviderPath = toProviderPath(newPath);
return provider.rename(oldProviderPath, newProviderPath);
},
async copyFile(src, dest, mode) {
const srcProviderPath = toProviderPath(src);
const destProviderPath = toProviderPath(dest);
return provider.copyFile(srcProviderPath, destProviderPath, mode);
},
async realpath(filePath, options) {
const providerPath = toProviderPath(filePath);
return toMountedPath(await provider.realpath(providerPath, options));
},
async readlink(linkPath, options) {
const providerPath = toProviderPath(linkPath);
return provider.readlink(providerPath, options);
},
async symlink(target, path, type) {
const providerPath = toProviderPath(path);
return provider.symlink(target, providerPath, type);
},
async access(filePath, mode) {
const providerPath = toProviderPath(filePath);
return provider.access(providerPath, mode);
},
async rm(filePath, options) {
const recursive = options?.recursive === true;
const force = options?.force === true;
let stats;
try {
stats = await provider.lstat(toProviderPath(filePath));
} catch (err) {
if (force && err?.code === 'ENOENT') return;
throw err;
}
// Symlinks should be unlinked directly, never recursed into
if (stats.isSymbolicLink()) {
await provider.unlink(toProviderPath(filePath));
return;
}
if (stats.isDirectory()) {
if (!recursive) {
throw createEISDIR('rm', filePath);
}
const entries = await provider.readdir(toProviderPath(filePath));
for (let i = 0; i < entries.length; i++) {
await this.rm(joinPath(filePath, entries[i]), options);
}
await provider.rmdir(toProviderPath(filePath));
} else {
await provider.unlink(toProviderPath(filePath));
}
},
async truncate(filePath, len = 0) {
const providerPath = toProviderPath(filePath);
const handle = await provider.open(providerPath, 'r+');
try {
await handle.truncate(len);
} finally {
await handle.close();
}
},
async link(existingPath, newPath) {
const existingProviderPath = toProviderPath(existingPath);
const newProviderPath = toProviderPath(newPath);
return provider.link(existingProviderPath, newProviderPath);
},
async mkdtemp(prefix) {
const providerPrefix = toProviderPath(prefix);
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let suffix = '';
for (let i = 0; i < 6; i++) {
suffix += chars[(MathRandom() * chars.length) | 0];
}
const dirPath = providerPrefix + suffix;
await provider.mkdir(dirPath);
return toMountedPath(dirPath);
},
async chmod(filePath, mode) {
const providerPath = toProviderPath(filePath);
provider.chmodSync(providerPath, mode);
},
async chown(filePath, uid, gid) {
const providerPath = toProviderPath(filePath);
provider.chownSync(providerPath, uid, gid);
},
async lchown(filePath, uid, gid) {
const providerPath = toProviderPath(filePath);
provider.chownSync(providerPath, uid, gid);
},
async utimes(filePath, atime, mtime) {
const providerPath = toProviderPath(filePath);
provider.utimesSync(providerPath, atime, mtime);
},
async lutimes(filePath, atime, mtime) {
const providerPath = toProviderPath(filePath);
provider.lutimesSync(providerPath, atime, mtime);
},
async open(filePath, flags, mode) {
const providerPath = toProviderPath(filePath);
const handle = provider.openSync(providerPath, flags, mode);
return openVirtualFd(handle);
},
async lchmod(filePath, mode) {
const providerPath = toProviderPath(filePath);
provider.chmodSync(providerPath, mode);
},
watch(filePath, options) {
const providerPath = toProviderPath(filePath);
return provider.watchAsync(providerPath, options);
},
});
}
}
module.exports = {
VirtualFileSystem,
};