node/lib/internal/vfs/file_handle.js
Matteo Collina c9562ddb82
vfs: add minimal node:vfs subsystem
Adds the node:vfs builtin module with VirtualFileSystem and
provider classes. No integration with fs, modules, or SEA.

Assisted-by: Claude-Opus4.7
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/63115
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
Reviewed-By: Robert Nagy <ronagy@icloud.com>
Reviewed-By: Stephen Belanger <admin@stephenbelanger.com>
2026-05-23 18:43:31 +00:00

720 lines
20 KiB
JavaScript

'use strict';
const {
DateNow,
MathMax,
MathMin,
Number,
Symbol,
SymbolAsyncDispose,
SymbolDispose,
} = primordials;
const { Buffer } = require('buffer');
const {
codes: {
ERR_INVALID_STATE,
ERR_METHOD_NOT_IMPLEMENTED,
},
} = require('internal/errors');
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.
* @param {string} syscall The syscall name for the error
*/
#checkClosed(syscall) {
if (this[kClosed]) {
throw createEBADF(syscall);
}
}
/**
* 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('read');
throw new ERR_METHOD_NOT_IMPLEMENTED('read');
}
/**
* 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)
* @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass
*/
readSync(buffer, offset, length, position) {
this.#checkClosed('read');
throw new ERR_METHOD_NOT_IMPLEMENTED('readSync');
}
/**
* 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('write');
throw new ERR_METHOD_NOT_IMPLEMENTED('write');
}
/**
* 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)
* @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass
*/
writeSync(buffer, offset, length, position) {
this.#checkClosed('write');
throw new ERR_METHOD_NOT_IMPLEMENTED('writeSync');
}
/**
* Reads the entire file.
* @param {object|string} [options] Options or encoding
* @returns {Promise<Buffer|string>}
*/
async readFile(options) {
this.#checkClosed('read');
throw new ERR_METHOD_NOT_IMPLEMENTED('readFile');
}
/**
* Reads the entire file synchronously.
* @param {object|string} [options] Options or encoding
* @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass
*/
readFileSync(options) {
this.#checkClosed('read');
throw new ERR_METHOD_NOT_IMPLEMENTED('readFileSync');
}
/**
* Writes data to the file (replacing content).
* @param {Buffer|string} data The data to write
* @param {object} [options] Options
* @returns {Promise<void>}
*/
async writeFile(data, options) {
this.#checkClosed('write');
throw new ERR_METHOD_NOT_IMPLEMENTED('writeFile');
}
/**
* 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('write');
throw new ERR_METHOD_NOT_IMPLEMENTED('writeFileSync');
}
/**
* Gets file stats.
* @param {object} [options] Options
* @returns {Promise<Stats>}
*/
async stat(options) {
this.#checkClosed('fstat');
throw new ERR_METHOD_NOT_IMPLEMENTED('stat');
}
/**
* Gets file stats synchronously.
* @param {object} [options] Options
* @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass
*/
statSync(options) {
this.#checkClosed('fstat');
throw new ERR_METHOD_NOT_IMPLEMENTED('statSync');
}
/**
* Truncates the file.
* @param {number} [len] The new length
* @returns {Promise<void>}
*/
async truncate(len) {
this.#checkClosed('ftruncate');
throw new ERR_METHOD_NOT_IMPLEMENTED('truncate');
}
/**
* Truncates the file synchronously.
* @param {number} [len] The new length
*/
truncateSync(len) {
this.#checkClosed('ftruncate');
throw new ERR_METHOD_NOT_IMPLEMENTED('truncateSync');
}
/**
* No-op chmod - VFS files don't have real permissions.
* @returns {Promise<void>}
*/
async chmod() {}
/**
* No-op chown - VFS files don't have real ownership.
* @returns {Promise<void>}
*/
async chown() {}
/**
* No-op utimes - timestamps are handled by the provider.
* @returns {Promise<void>}
*/
async utimes() {}
/**
* No-op datasync - VFS is in-memory.
* @returns {Promise<void>}
*/
async datasync() {}
/**
* No-op sync - VFS is in-memory.
* @returns {Promise<void>}
*/
async sync() {}
/**
* Reads data from the file into multiple buffers.
* @param {Buffer[]} buffers The buffers to read into
* @param {number|null} [position] The position to read from
* @returns {Promise<{ bytesRead: number, buffers: Buffer[] }>}
*/
async readv(buffers, position) {
this.#checkClosed('readv');
let totalRead = 0;
for (let i = 0; i < buffers.length; i++) {
const buf = buffers[i];
const pos = position != null ? position + totalRead : null;
const { bytesRead } = await this.read(buf, 0, buf.byteLength, pos);
totalRead += bytesRead;
if (bytesRead < buf.byteLength) break;
}
return { __proto__: null, bytesRead: totalRead, buffers };
}
/**
* Writes data from multiple buffers to the file.
* @param {Buffer[]} buffers The buffers to write from
* @param {number|null} [position] The position to write to
* @returns {Promise<{ bytesWritten: number, buffers: Buffer[] }>}
*/
async writev(buffers, position) {
this.#checkClosed('writev');
let totalWritten = 0;
for (let i = 0; i < buffers.length; i++) {
const buf = buffers[i];
const pos = position != null ? position + totalWritten : null;
const { bytesWritten } = await this.write(buf, 0, buf.byteLength, pos);
totalWritten += bytesWritten;
if (bytesWritten < buf.byteLength) break;
}
return { __proto__: null, bytesWritten: totalWritten, buffers };
}
/**
* Appends data to the file.
* @param {Buffer|string} data The data to append
* @param {object} [options] Options
* @returns {Promise<void>}
*/
async appendFile(data, options) {
this.#checkClosed('appendFile');
const buffer = typeof data === 'string' ?
Buffer.from(data, options?.encoding) : data;
await this.write(buffer, 0, buffer.length, null);
}
readableWebStream() {
throw new ERR_METHOD_NOT_IMPLEMENTED('readableWebStream');
}
readLines() {
throw new ERR_METHOD_NOT_IMPLEMENTED('readLines');
}
createReadStream() {
throw new ERR_METHOD_NOT_IMPLEMENTED('createReadStream');
}
createWriteStream() {
throw new ERR_METHOD_NOT_IMPLEMENTED('createWriteStream');
}
/**
* Closes the file handle.
* @returns {Promise<void>}
*/
async close() {
this[kClosed] = true;
}
/**
* Closes the file handle synchronously.
*/
closeSync() {
this[kClosed] = true;
}
}
VirtualFileHandle.prototype[SymbolAsyncDispose] = VirtualFileHandle.prototype.close;
VirtualFileHandle.prototype[SymbolDispose] = VirtualFileHandle.prototype.closeSync;
/**
* A file handle for in-memory file content.
* Used by MemoryProvider and similar providers.
*/
class MemoryFileHandle extends VirtualFileHandle {
#content;
#size;
#entry;
#getStats;
#checkClosed(syscall) {
if (this.closed) {
throw createEBADF(syscall);
}
}
/**
* @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.#size = content.length;
this.#entry = entry;
this.#getStats = getStats;
// Handle different open modes
if (flags === 'w' || flags === 'w+' ||
flags === 'wx' || flags === 'wx+') {
// Write mode: truncate
this.#content = Buffer.alloc(0);
this.#size = 0;
if (entry) {
entry.content = this.#content;
}
} else if (flags === 'a' || flags === 'a+' ||
flags === 'ax' || flags === 'ax+') {
// Append mode: position at end
this.position = this.#size;
}
}
/**
* Throws EBADF if the handle was not opened for writing.
*/
#checkWritable() {
if (this.flags === 'r') {
throw createEBADF('write');
}
}
/**
* Throws EBADF if the handle was not opened for reading.
*/
#checkReadable() {
const f = this.flags;
if (f === 'w' || f === 'a' || f === 'wx' || f === 'ax') {
throw createEBADF('read');
}
}
/**
* Returns true if this handle was opened in append mode.
* @returns {boolean}
*/
#isAppend() {
const f = this.flags;
return f === 'a' || f === 'a+' || f === 'ax' || f === 'ax+';
}
/**
* 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?.isDynamic && this.#entry.isDynamic()) {
return this.#entry.getContentSync();
}
return this.#content.subarray(0, this.#size);
}
/**
* Gets the current content asynchronously.
* For dynamic content providers, this gets fresh content from the entry.
* @returns {Promise<Buffer>}
*/
async getContentAsync() {
// If entry has a dynamic content provider, get fresh content async
if (this.#entry?.getContentAsync) {
return this.#entry.getContentAsync();
}
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('read');
this.#checkReadable();
// Get content (resolves dynamic content providers)
const content = this.content;
const readPos = position !== null && position !== undefined ?
Number(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 { __proto__: null, 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('write');
this.#checkWritable();
// In append mode, always write at the end
const writePos = this.#isAppend() ?
this.#size :
(position !== null && position !== undefined ?
Number(position) : this.position);
const data = buffer.subarray(offset, offset + length);
// Expand buffer if needed (geometric doubling for amortized O(1) appends)
const neededSize = writePos + length;
if (neededSize > this.#content.length) {
const newCapacity = MathMax(neededSize, this.#content.length * 2);
const newContent = Buffer.alloc(newCapacity);
this.#content.copy(newContent, 0, 0, this.#size);
this.#content = newContent;
}
// Write the data
data.copy(this.#content, writePos);
// Update actual content size
if (neededSize > this.#size) {
this.#size = neededSize;
}
// Update the entry's content, mtime, and ctime
if (this.#entry) {
const now = DateNow();
this.#entry.content = this.#content.subarray(0, this.#size);
this.#entry.mtime = now;
this.#entry.ctime = now;
}
// 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 { __proto__: null, bytesWritten, buffer };
}
/**
* Reads the entire file synchronously.
* @param {object|string} [options] Options or encoding
* @returns {Buffer|string}
*/
readFileSync(options) {
this.#checkClosed('read');
this.#checkReadable();
// 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<Buffer|string>}
*/
async readFile(options) {
this.#checkClosed('read');
this.#checkReadable();
// 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.
* Replaces content in 'w' mode, appends in 'a' mode.
* @param {Buffer|string} data The data to write
* @param {object} [options] Options
*/
writeFileSync(data, options) {
this.#checkClosed('write');
this.#checkWritable();
const buffer = typeof data === 'string' ? Buffer.from(data, options?.encoding) : data;
// In append mode, append to existing content
if (this.#isAppend()) {
const neededSize = this.#size + buffer.length;
if (neededSize > this.#content.length) {
const newCapacity = MathMax(neededSize, this.#content.length * 2);
const newContent = Buffer.alloc(newCapacity);
this.#content.copy(newContent, 0, 0, this.#size);
this.#content = newContent;
}
buffer.copy(this.#content, this.#size);
this.#size = neededSize;
} else {
this.#content = Buffer.from(buffer);
this.#size = buffer.length;
}
// Update the entry's content, mtime, and ctime
if (this.#entry) {
const now = DateNow();
this.#entry.content = this.#content.subarray(0, this.#size);
this.#entry.mtime = now;
this.#entry.ctime = now;
}
this.position = this.#size;
}
/**
* Writes data to the file (replacing content).
* @param {Buffer|string} data The data to write
* @param {object} [options] Options
* @returns {Promise<void>}
*/
async writeFile(data, options) {
this.writeFileSync(data, options);
}
/**
* Gets file stats synchronously.
* @param {object} [options] Options
* @returns {Stats}
*/
statSync(options) {
this.#checkClosed('fstat');
if (this.#getStats) {
return this.#getStats(this.#size);
}
throw new ERR_INVALID_STATE('stats not available');
}
/**
* Gets file stats.
* @param {object} [options] Options
* @returns {Promise<Stats>}
*/
async stat(options) {
return this.statSync(options);
}
/**
* Truncates the file synchronously.
* @param {number} [len] The new length
*/
truncateSync(len = 0) {
this.#checkClosed('ftruncate');
this.#checkWritable();
if (len < this.#size) {
// Zero out truncated region to avoid stale data
this.#content.fill(0, len, this.#size);
this.#size = len;
} else if (len > this.#size) {
if (len > this.#content.length) {
const newContent = Buffer.alloc(len);
this.#content.copy(newContent, 0, 0, this.#size);
this.#content = newContent;
} else {
// Buffer has enough capacity, just zero-fill the extension
this.#content.fill(0, this.#size, len);
}
this.#size = len;
}
// Update the entry's content, mtime, and ctime
if (this.#entry) {
const now = DateNow();
this.#entry.content = this.#content.subarray(0, this.#size);
this.#entry.mtime = now;
this.#entry.ctime = now;
}
}
/**
* Truncates the file.
* @param {number} [len] The new length
* @returns {Promise<void>}
*/
async truncate(len) {
this.truncateSync(len);
}
}
module.exports = {
VirtualFileHandle,
MemoryFileHandle,
};