Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ vfs.mount('/prefix'); // Start intercepting paths under /prefix
vfs.unmount(); // Stop intercepting
```

`mount()` returns the VFS instance for chaining. When mounted with `moduleHooks: true` (the default), `require()`, `import`, and core `fs` functions (`readFileSync`, `statSync`, `existsSync`, `readdirSync`, `realpathSync`, `watch`, etc.) are patched to serve files from the VFS.
`mount()` returns the VFS instance for chaining. When mounted with `moduleHooks: true` (the default), `require()`, `import`, and core `fs` functions (`readFileSync`, `statSync`, `existsSync`, `readdirSync`, `realpathSync`, `openSync`, `watch`, etc.) are patched to serve files from the VFS.

Emits `vfs-mount` and `vfs-unmount` events on `process`.

Expand Down Expand Up @@ -252,7 +252,7 @@ Higher-level operations (`readFile`, `writeFile`, `copyFile`, `exists`, `access`
When `moduleHooks` is enabled (the default), mounting a VFS instance:

1. **Patches `require()` and `import`** — On Node.js 23.5+ uses `Module.registerHooks()`. On older versions falls back to `Module._resolveFilename` + `Module._extensions` patching.
2. **Patches core `fs` functions** — `readFileSync`, `statSync`, `lstatSync`, `readdirSync`, `existsSync`, `realpathSync`, `watch`, `watchFile`, `unwatchFile`.
2. **Patches core `fs` functions** — `readFileSync`, `statSync`, `lstatSync`, `readdirSync`, `existsSync`, `realpathSync`, `watch`, `watchFile`, `unwatchFile`, and the descriptor family `openSync`/`open`, `readSync`/`read`, `closeSync`/`close`, `fstatSync`/`fstat`.

This means third-party code using `require()` or `fs.readFileSync()` will transparently pick up files from the VFS.

Expand Down
118 changes: 118 additions & 0 deletions lib/fd.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use strict';

const { createEBADF } = require('./errors.js');

const kFd = Symbol('kFd');
const kEntry = Symbol('kEntry');

Expand Down Expand Up @@ -38,9 +40,125 @@ function closeVirtualFd(fd) {
return openFDs.delete(fd);
}

function requireVirtualFd(fd, syscall) {
const vfd = getVirtualFd(fd);
if (!vfd) {
throw createEBADF(syscall);
}
return vfd;
}

const DEFAULT_READ_SIZE = 16384;
const kEmptyOptions = Object.freeze({});

function fromReadOptions(buffer, options) {
const buf = buffer ?? options.buffer ?? Buffer.alloc(DEFAULT_READ_SIZE);
return [buf, options.offset ?? 0, options.length ?? buf.byteLength, options.position ?? null];
}

// fs.read/readSync accept (fd, buffer, offset, length, position),
// (fd, buffer, options), (fd, options) and (fd). Collapse to positional form.
function normalizeReadArgs(buffer, offset, length, position) {
if (buffer == null) {
return fromReadOptions(null, kEmptyOptions);
}

if (!ArrayBuffer.isView(buffer)) {
return fromReadOptions(null, buffer);
}

if (offset !== null && typeof offset === 'object') {
return fromReadOptions(buffer, offset);
}

return [buffer, offset ?? 0, length ?? buffer.byteLength, position ?? null];
}

// The callback is always the last function argument, whichever overload was used.
function splitCallback(args) {
const at = args.findIndex((arg) => typeof arg === 'function');
if (at === -1) {
return null;
}
const callback = args[at];
args.length = at;
return callback;
}

// The operations below are keyed on the fd alone: a handle carries its own
// content and position, so which VirtualFileSystem opened it is irrelevant.
// VirtualFileSystem and the node:fs patches both delegate here.

function closeFdSync(fd) {
const vfd = requireVirtualFd(fd, 'close');
vfd.entry.closeSync();
closeVirtualFd(fd);
}

function readFdSync(fd, buffer, offset, length, position) {
const vfd = requireVirtualFd(fd, 'read');
const args = normalizeReadArgs(buffer, offset, length, position);
return vfd.entry.readSync(args[0], args[1], args[2], args[3]);
}

function fstatFdSync(fd, options) {
return requireVirtualFd(fd, 'fstat').entry.statSync(options);
}

function noop() {}

function closeFd(fd, callback = noop) {
const vfd = getVirtualFd(fd);
if (!vfd) {
process.nextTick(callback, createEBADF('close'));
return;
}

vfd.entry.close().then(() => {
closeVirtualFd(fd);
callback(null);
}, callback);
}

function readFd(fd, buffer, offset, length, position, callback) {
const rest = [buffer, offset, length, position];
callback = splitCallback(rest) ?? callback;

const vfd = getVirtualFd(fd);
if (!vfd) {
process.nextTick(callback, createEBADF('read'));
return;
}

const args = normalizeReadArgs(rest[0], rest[1], rest[2], rest[3]);
vfd.entry.read(args[0], args[1], args[2], args[3])
.then(({ bytesRead }) => callback(null, bytesRead, args[0]), callback);
}

function fstatFd(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), callback);
}

module.exports = {
VirtualFD,
openVirtualFd,
getVirtualFd,
closeVirtualFd,
closeFdSync,
readFdSync,
fstatFdSync,
closeFd,
readFd,
fstatFd,
};
58 changes: 12 additions & 46 deletions lib/file_system.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ const {
} = require('./router.js');
const {
openVirtualFd,
getVirtualFd,
closeVirtualFd,
closeFdSync,
readFdSync,
fstatFdSync,
closeFd,
readFd,
fstatFd,
} = require('./fd.js');
const {
createENOENT,
createENOTDIR,
createEBADF,
ERR_INVALID_STATE,
} = require('./errors.js');
const { VirtualReadStream } = require('./streams.js');
Expand Down Expand Up @@ -404,28 +407,15 @@ class VirtualFileSystem {
}

closeSync(fd) {
const vfd = getVirtualFd(fd);
if (!vfd) {
throw createEBADF('close');
}
vfd.entry.closeSync();
closeVirtualFd(fd);
closeFdSync(fd);
}

readSync(fd, buffer, offset, length, position) {
const vfd = getVirtualFd(fd);
if (!vfd) {
throw createEBADF('read');
}
return vfd.entry.readSync(buffer, offset, length, position);
return readFdSync(fd, buffer, offset, length, position);
}

fstatSync(fd, options) {
const vfd = getVirtualFd(fd);
if (!vfd) {
throw createEBADF('fstat');
}
return vfd.entry.statSync(options);
return fstatFdSync(fd, options);
}

// ==================== FS Operations (Async with Callbacks) ====================
Expand Down Expand Up @@ -529,28 +519,11 @@ class VirtualFileSystem {
}

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));
closeFd(fd, callback);
}

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));
readFd(fd, buffer, offset, length, position, callback);
}

fstat(fd, options, callback) {
Expand All @@ -559,14 +532,7 @@ class VirtualFileSystem {
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));
fstatFd(fd, options, callback);
}

// ==================== Stream Operations ====================
Expand Down
95 changes: 91 additions & 4 deletions lib/module_hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ const { dirname, extname, isAbsolute, resolve } = path;
const pathPosix = path.posix;
const { pathToFileURL, fileURLToPath } = require('node:url');
const { createENOENT } = require('./errors.js');
const {
getVirtualFd,
closeFdSync,
readFdSync,
fstatFdSync,
closeFd,
readFd,
fstatFd,
} = require('./fd.js');

const kEmptyObject = Object.freeze(Object.create(null));
const NodeModule = require('node:module');
Expand Down Expand Up @@ -230,7 +239,10 @@ function findVFSForReaddir(dirname, options) {
return null;
}

function findVFSForWatch(filename) {
// Which VFS owns this path? Overlay mounts only claim paths they actually
// hold, so anything else falls through to the real fs. Used by watch and by
// the fd-opening patches.
function findVFSForExistingPath(filename) {
const normalized = normalizeVFSPath(filename);
for (let i = 0; i < activeVFSList.length; i++) {
const vfs = activeVFSList[i];
Expand Down Expand Up @@ -1466,7 +1478,7 @@ function installFsPatches() {
} else options ??= kEmptyObject;

if (typeof filename === 'string') {
const vfsResult = findVFSForWatch(filename);
const vfsResult = findVFSForExistingPath(filename);
if (vfsResult !== null) {
return vfsResult.vfs.watch(filename, options, listener);
}
Expand All @@ -1482,7 +1494,7 @@ function installFsPatches() {
} else options ??= kEmptyObject;

if (typeof filename === 'string') {
const vfsResult = findVFSForWatch(filename);
const vfsResult = findVFSForExistingPath(filename);
if (vfsResult !== null) {
return vfsResult.vfs.watchFile(filename, options, listener);
}
Expand All @@ -1493,14 +1505,89 @@ function installFsPatches() {
originalUnwatchFile = fs.unwatchFile;
fs.unwatchFile = function unwatchFile(filename, listener) {
if (typeof filename === 'string') {
const vfsResult = findVFSForWatch(filename);
const vfsResult = findVFSForExistingPath(filename);
if (vfsResult !== null) {
vfsResult.vfs.unwatchFile(filename, listener);
return;
}
}
return originalUnwatchFile.call(fs, filename, listener);
};

// === File descriptor family ===
//
// openSync/open resolve a path to a VFS; the rest route on the fd itself,
// which fd.js allocates from 10000+ so it can never shadow a real one.

const originalOpenSync = fs.openSync;
fs.openSync = function openSync(path, flags, mode) {
if (typeof path === 'string') {
const vfsResult = findVFSForExistingPath(path);
if (vfsResult !== null) {
return vfsResult.vfs.openSync(path, flags ?? 'r', mode);
}
}
return originalOpenSync.call(fs, path, flags, mode);
};

const originalOpen = fs.open;
fs.open = function open(path, flags, mode, callback) {
if (typeof path === 'string') {
const vfsResult = findVFSForExistingPath(path);
if (vfsResult !== null) {
return vfsResult.vfs.open(path, flags, mode, callback);
}
}
return originalOpen.call(fs, path, flags, mode, callback);
};

const originalCloseSync = fs.closeSync;
fs.closeSync = function closeSync(fd) {
if (getVirtualFd(fd)) {
return closeFdSync(fd);
}
return originalCloseSync.call(fs, fd);
};

const originalClose = fs.close;
fs.close = function close(fd, callback) {
if (getVirtualFd(fd)) {
return closeFd(fd, callback);
}
return originalClose.call(fs, fd, callback);
};

const originalReadSync = fs.readSync;
fs.readSync = function readSync(fd, buffer, offset, length, position) {
if (getVirtualFd(fd)) {
return readFdSync(fd, buffer, offset, length, position);
}
return originalReadSync.call(fs, fd, buffer, offset, length, position);
};

const originalRead = fs.read;
fs.read = function read(fd, buffer, offset, length, position, callback) {
if (getVirtualFd(fd)) {
return readFd(fd, buffer, offset, length, position, callback);
}
return originalRead.call(fs, fd, buffer, offset, length, position, callback);
};

const originalFstatSync = fs.fstatSync;
fs.fstatSync = function fstatSync(fd, options) {
if (getVirtualFd(fd)) {
return fstatFdSync(fd, options);
}
return originalFstatSync.call(fs, fd, options);
};

const originalFstat = fs.fstat;
fs.fstat = function fstat(fd, options, callback) {
if (getVirtualFd(fd)) {
return fstatFd(fd, options, callback);
}
return originalFstat.call(fs, fd, options, callback);
};
}

function installHooks() {
Expand Down
Loading
Loading