node/lib/internal/vfs/streams.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

353 lines
8 KiB
JavaScript

'use strict';
const {
MathMin,
} = primordials;
const { Buffer } = require('buffer');
const { Readable, Writable } = require('stream');
const { createEBADF } = require('internal/vfs/errors');
const { getVirtualFd } = require('internal/vfs/fd');
const { kEmptyObject } = require('internal/util');
const { validateInteger } = require('internal/validators');
const {
codes: {
ERR_OUT_OF_RANGE,
},
} = require('internal/errors');
/**
* A readable stream for virtual files.
*/
class VirtualReadStream extends Readable {
#vfs;
#path;
#fd = null;
#end;
#pos;
#content = null;
#autoClose;
/**
* Number of bytes read so far.
* @type {number}
*/
bytesRead = 0;
/**
* True until the first read completes.
* @type {boolean}
*/
pending = true;
/**
* @param {VirtualFileSystem} vfs The VFS instance
* @param {string} filePath The path to the file
* @param {object} [options] Stream options
*/
constructor(vfs, filePath, options = kEmptyObject) {
const {
start,
end,
highWaterMark = 64 * 1024,
encoding,
fd,
...streamOptions
} = options;
// Validate start/end matching real ReadStream behavior
if (start !== undefined) {
validateInteger(start, 'start', 0);
}
if (end !== undefined && end !== Infinity) {
validateInteger(end, 'end', 0);
}
if (start !== undefined && end !== undefined && end !== Infinity &&
start > end) {
throw new ERR_OUT_OF_RANGE(
'start',
`<= "end" (here: ${end})`,
start,
);
}
super({ ...streamOptions, highWaterMark, encoding });
this.#vfs = vfs;
this.#path = filePath;
this.#end = end === undefined ? Infinity : end;
this.#pos = start === undefined ? 0 : start;
this.#autoClose = options.autoClose !== false;
if (fd !== null && fd !== undefined) {
// Use the already-open file descriptor
this.#fd = fd;
process.nextTick(() => {
this.emit('open', this.#fd);
this.emit('ready');
});
} else {
// Open the file on next tick so listeners can be attached.
// Note: #openFile will not throw - if it fails, the stream is destroyed.
process.nextTick(() => this.#openFile());
}
}
/**
* Gets the file path.
* @returns {string}
*/
get path() {
return this.#path;
}
/**
* Opens the virtual file.
* Events are emitted synchronously within this method, which runs
* asynchronously via process.nextTick - matching real fs behavior.
*/
#openFile() {
try {
this.#fd = this.#vfs.openSync(this.#path);
this.emit('open', this.#fd);
this.emit('ready');
} catch (err) {
this.destroy(err);
}
}
/**
* Implements the readable _read method.
* @param {number} size Number of bytes to read
*/
_read(size) {
if (this.destroyed || this.#fd === null) {
this.destroy(createEBADF('read'));
return;
}
// Load content on first read (lazy loading)
if (this.#content === null) {
try {
const vfd = getVirtualFd(this.#fd);
if (!vfd) {
this.destroy(createEBADF('read'));
return;
}
// Use the file handle's readFileSync to get content
this.#content = vfd.entry.readFileSync();
this.pending = false;
} catch (err) {
this.destroy(err);
return;
}
}
// Calculate how much to read
// Note: end is inclusive, so we use end + 1 for the upper bound
const endPos = this.#end === Infinity ? this.#content.length : this.#end + 1;
const remaining = MathMin(endPos, this.#content.length) - this.#pos;
if (remaining <= 0) {
this.push(null);
return;
}
const bytesToRead = MathMin(size, remaining);
const chunk = this.#content.subarray(this.#pos, this.#pos + bytesToRead);
this.#pos += bytesToRead;
this.bytesRead += bytesToRead;
this.push(chunk);
// Check if we've reached the end
if (this.#pos >= endPos || this.#pos >= this.#content.length) {
this.push(null);
}
}
/**
* Closes the file descriptor.
* Note: Does not emit 'close' - the base Readable class handles that.
*/
#close() {
if (this.#fd !== null) {
try {
this.#vfs.closeSync(this.#fd);
} catch {
// Ignore close errors
}
this.#fd = null;
}
}
/**
* Implements the readable _destroy method.
* @param {Error|null} err The error
* @param {Function} callback Callback
*/
_destroy(err, callback) {
if (this.#autoClose) {
this.#close();
}
callback(err);
}
}
/**
* A writable stream for virtual files.
*/
class VirtualWriteStream extends Writable {
#vfs;
#path;
#fd = null;
#autoClose;
#start;
/**
* Number of bytes written so far.
* @type {number}
*/
bytesWritten = 0;
/**
* True until the first write completes.
* @type {boolean}
*/
pending = true;
/**
* @param {VirtualFileSystem} vfs The VFS instance
* @param {string} filePath The path to the file
* @param {object} [options] Stream options
*/
constructor(vfs, filePath, options = kEmptyObject) {
const {
highWaterMark = 64 * 1024,
...streamOptions
} = options;
// Validate start matching real WriteStream behavior
if (options.start !== undefined) {
validateInteger(options.start, 'start', 0);
}
super({ ...streamOptions, highWaterMark });
this.#vfs = vfs;
this.#path = filePath;
this.#autoClose = options.autoClose !== false;
this.#start = options.start;
const fd = options.fd;
if (fd !== null && fd !== undefined) {
// Use the already-open file descriptor
this.#fd = fd;
if (this.#start !== undefined) {
this.#setPosition(this.#start);
}
process.nextTick(() => {
this.emit('open', this.#fd);
this.emit('ready');
});
} else {
// Open file synchronously (VFS is in-memory) so writes can proceed
// immediately. Emit events on next tick for listener attachment.
const flags = options.flags || 'w';
try {
this.#fd = this.#vfs.openSync(this.#path, flags);
if (this.#start !== undefined) {
this.#setPosition(this.#start);
}
} catch (err) {
process.nextTick(() => this.destroy(err));
return;
}
process.nextTick(() => {
this.emit('open', this.#fd);
this.emit('ready');
});
}
}
/**
* Sets the file handle position for the given fd.
* @param {number} pos The position to set
*/
#setPosition(pos) {
const vfd = getVirtualFd(this.#fd);
if (vfd) {
vfd.entry.position = pos;
}
}
/**
* Gets the file path.
* @returns {string}
*/
get path() {
return this.#path;
}
/**
* Implements the writable _write method.
* @param {Buffer|string} chunk Data to write
* @param {string} encoding Encoding
* @param {Function} callback Callback
*/
_write(chunk, encoding, callback) {
if (this.destroyed || this.#fd === null) {
callback(createEBADF('write'));
return;
}
try {
const buffer = typeof chunk === 'string' ?
Buffer.from(chunk, encoding) : chunk;
this.#vfs.writeSync(this.#fd, buffer, 0, buffer.length, null);
this.bytesWritten += buffer.length;
this.pending = false;
callback();
} catch (err) {
callback(err);
}
}
/**
* Implements the writable _final method (flush before close).
* @param {Function} callback Callback
*/
_final(callback) {
callback();
}
/**
* Closes the file descriptor.
*/
#close() {
if (this.#fd !== null) {
try {
this.#vfs.closeSync(this.#fd);
} catch {
// Ignore close errors
}
this.#fd = null;
}
}
/**
* Implements the writable _destroy method.
* @param {Error|null} err The error
* @param {Function} callback Callback
*/
_destroy(err, callback) {
if (this.#autoClose) {
this.#close();
}
callback(err);
}
}
module.exports = {
VirtualReadStream,
VirtualWriteStream,
};