From 8eff79ec5bfc4adee69bda327164be8596c4f657 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Tue, 7 Apr 2026 17:25:16 +0200 Subject: [PATCH 1/3] fix: add fs.promises and fs.access/accessSync patches to module hooks The VFS module hooks only patched synchronous fs methods, leaving async operations (fs.promises.*, fs.access) unpatched. This caused ENOENT errors when application code used: - `await fs.promises.access(path)` (e.g., file existence checks) - `await fs.promises.readFile(path)` (e.g., loading templates) - `await fs.promises.stat(path)`, `lstat`, `readdir`, `readlink`, `realpath` - `fs.accessSync(path)` or `fs.access(path, callback)` Since `require('fs/promises')` returns the same object as `fs.promises`, patching directly on `fs.promises` covers both import patterns. Fixes: https://github.com/platformatic/vfs/issues/8 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/module_hooks.js | 130 ++++++++++++++++++++++++ test/fs-hooks.test.js | 230 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 test/fs-hooks.test.js diff --git a/lib/module_hooks.js b/lib/module_hooks.js index e4cb823..b00d7ca 100644 --- a/lib/module_hooks.js +++ b/lib/module_hooks.js @@ -901,6 +901,136 @@ function installFsPatches() { return originalExistsSync.call(fs, path); }; + // Patch fs.readlinkSync for VFS + const originalReadlinkSync = fs.readlinkSync; + fs.readlinkSync = function readlinkSync(path, options) { + if (typeof path === 'string') { + const vfsResult = findVFSForRealpath(path); + if (vfsResult !== null) { + return vfsResult.realpath; + } + } + return originalReadlinkSync.call(fs, path, options); + }; + + // --- Async callback patches (fs.access, fs.accessSync) --- + + const originalAccessSync = fs.accessSync; + fs.accessSync = function accessSync(path, mode) { + if (typeof path === 'string') { + const vfsResult = findVFSForExists(path); + if (vfsResult !== null) { + if (!vfsResult.exists) { + throw createENOENT('access', path); + } + return; + } + } + return originalAccessSync.call(fs, path, mode); + }; + + const originalAccess = fs.access; + fs.access = function access(path, mode, callback) { + if (typeof mode === 'function') { + callback = mode; + mode = fs.constants.F_OK; + } + if (typeof path === 'string') { + const vfsResult = findVFSForExists(path); + if (vfsResult !== null) { + const err = vfsResult.exists ? null : createENOENT('access', path); + if (callback) process.nextTick(callback, err); + return; + } + } + return originalAccess.call(fs, path, mode, callback); + }; + + // --- fs.promises patches --- + // Patched directly on the shared object so require('fs/promises') also + // picks up the changes (it returns the same reference as fs.promises). + // Fixes: https://github.com/platformatic/vfs/issues/8 + + const origPAccess = fs.promises.access; + fs.promises.access = async function access(path, mode) { + if (typeof path === 'string') { + const vfsResult = findVFSForExists(path); + if (vfsResult !== null) { + if (!vfsResult.exists) { + throw createENOENT('access', path); + } + return; + } + } + return origPAccess.call(fs.promises, path, mode); + }; + + const origPReadFile = fs.promises.readFile; + fs.promises.readFile = async function readFile(path, options) { + if (typeof path === 'string') { + const vfsResult = findVFSForRead(path, options); + if (vfsResult !== null) { + return vfsResult.content; + } + } + return origPReadFile.call(fs.promises, path, options); + }; + + const origPStat = fs.promises.stat; + fs.promises.stat = async function stat(path, options) { + if (typeof path === 'string') { + const vfsResult = findVFSForFsStat(path); + if (vfsResult !== null) { + return vfsResult.stats; + } + } + return origPStat.call(fs.promises, path, options); + }; + + const origPLstat = fs.promises.lstat; + fs.promises.lstat = async function lstat(path, options) { + if (typeof path === 'string') { + const vfsResult = findVFSForFsStat(path); + if (vfsResult !== null) { + return vfsResult.stats; + } + } + return origPLstat.call(fs.promises, path, options); + }; + + const origPReaddir = fs.promises.readdir; + fs.promises.readdir = async function readdir(path, options) { + if (typeof path === 'string') { + const vfsResult = findVFSForReaddir(path, options); + if (vfsResult !== null) { + return vfsResult.entries; + } + } + return origPReaddir.call(fs.promises, path, options); + }; + + const origPReadlink = fs.promises.readlink; + fs.promises.readlink = async function readlink(path, options) { + if (typeof path === 'string') { + const vfsResult = findVFSForRealpath(path); + if (vfsResult !== null) { + return vfsResult.realpath; + } + } + return origPReadlink.call(fs.promises, path, options); + }; + + const origPRealpath = fs.promises.realpath; + fs.promises.realpath = async function realpath(path, options) { + if (typeof path === 'string') { + const vfsResult = findVFSForRealpath(path); + if (vfsResult !== null) { + return vfsResult.realpath; + } + } + return origPRealpath.call(fs.promises, path, options); + }; + originalWatch = fs.watch; fs.watch = function watch(filename, options, listener) { if (typeof options === 'function') { diff --git a/test/fs-hooks.test.js b/test/fs-hooks.test.js new file mode 100644 index 0000000..b50a445 --- /dev/null +++ b/test/fs-hooks.test.js @@ -0,0 +1,230 @@ +'use strict'; + +const { describe, it, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const { create } = require('../index.js'); + +// These tests verify that the module hooks patch real fs/fs.promises methods +// so that require('fs').readFileSync, require('fs/promises').readFile, etc. +// transparently serve VFS content. + +describe('Module hooks — fs sync patches', () => { + let vfs; + + afterEach(() => { + if (vfs?.mounted) { + vfs.unmount(); + } + }); + + it('fs.readFileSync reads from VFS', () => { + vfs = create(); + vfs.writeFileSync('/data.txt', 'hello from vfs'); + vfs.mount('/vfs-test-sync-read'); + + const content = fs.readFileSync('/vfs-test-sync-read/data.txt', 'utf8'); + assert.strictEqual(content, 'hello from vfs'); + }); + + it('fs.existsSync returns true for VFS files', () => { + vfs = create(); + vfs.writeFileSync('/exists.txt', 'yes'); + vfs.mount('/vfs-test-sync-exists'); + + assert.strictEqual(fs.existsSync('/vfs-test-sync-exists/exists.txt'), true); + assert.strictEqual(fs.existsSync('/vfs-test-sync-exists/nope.txt'), false); + }); + + it('fs.statSync returns stats for VFS files', () => { + vfs = create(); + vfs.writeFileSync('/stat.txt', 'data'); + vfs.mount('/vfs-test-sync-stat'); + + const stats = fs.statSync('/vfs-test-sync-stat/stat.txt'); + assert.ok(stats.isFile()); + }); + + it('fs.lstatSync returns stats for VFS files', () => { + vfs = create(); + vfs.writeFileSync('/lstat.txt', 'data'); + vfs.mount('/vfs-test-sync-lstat'); + + const stats = fs.lstatSync('/vfs-test-sync-lstat/lstat.txt'); + assert.ok(stats.isFile()); + }); + + it('fs.readdirSync lists VFS directory contents', () => { + vfs = create(); + vfs.writeFileSync('/dir/a.txt', 'a'); + vfs.writeFileSync('/dir/b.txt', 'b'); + vfs.mount('/vfs-test-sync-readdir'); + + const entries = fs.readdirSync('/vfs-test-sync-readdir/dir'); + assert.deepStrictEqual(entries.sort(), ['a.txt', 'b.txt']); + }); + + it('fs.realpathSync resolves VFS paths', () => { + vfs = create(); + vfs.writeFileSync('/real.txt', 'data'); + vfs.mount('/vfs-test-sync-realpath'); + + const resolved = fs.realpathSync('/vfs-test-sync-realpath/real.txt'); + assert.strictEqual(resolved, '/vfs-test-sync-realpath/real.txt'); + }); + + it('fs.accessSync does not throw for existing VFS files', () => { + vfs = create(); + vfs.writeFileSync('/access.txt', 'data'); + vfs.mount('/vfs-test-sync-access'); + + assert.doesNotThrow(() => fs.accessSync('/vfs-test-sync-access/access.txt')); + }); + + it('fs.accessSync throws ENOENT for missing VFS files', () => { + vfs = create(); + vfs.mount('/vfs-test-sync-access-miss'); + + assert.throws(() => fs.accessSync('/vfs-test-sync-access-miss/nope.txt'), { + code: 'ENOENT', + }); + }); + + it('fs.readlinkSync reads VFS symlinks', () => { + vfs = create(); + vfs.writeFileSync('/link-target.txt', 'data'); + vfs.symlinkSync('/link-target.txt', '/my-link.txt'); + vfs.mount('/vfs-test-sync-readlink'); + + const target = fs.readlinkSync('/vfs-test-sync-readlink/my-link.txt'); + assert.strictEqual(target, '/vfs-test-sync-readlink/link-target.txt'); + }); +}); + +describe('Module hooks — fs.access callback', () => { + let vfs; + + afterEach(() => { + if (vfs?.mounted) { + vfs.unmount(); + } + }); + + it('fs.access calls back without error for existing VFS files', (_, done) => { + vfs = create(); + vfs.writeFileSync('/cb.txt', 'data'); + vfs.mount('/vfs-test-cb-access'); + + fs.access('/vfs-test-cb-access/cb.txt', (err) => { + assert.ifError(err); + done(); + }); + }); + + it('fs.access calls back with ENOENT for missing VFS files', (_, done) => { + vfs = create(); + vfs.mount('/vfs-test-cb-access-miss'); + + fs.access('/vfs-test-cb-access-miss/nope.txt', (err) => { + assert.ok(err); + assert.strictEqual(err.code, 'ENOENT'); + done(); + }); + }); +}); + +describe('Module hooks — fs.promises patches', () => { + let vfs; + + afterEach(() => { + if (vfs?.mounted) { + vfs.unmount(); + } + }); + + it('fs.promises.access resolves for existing VFS files', async () => { + vfs = create(); + vfs.writeFileSync('/paccess.txt', 'data'); + vfs.mount('/vfs-test-p-access'); + + await assert.doesNotReject(fsp.access('/vfs-test-p-access/paccess.txt')); + }); + + it('fs.promises.access rejects with ENOENT for missing VFS files', async () => { + vfs = create(); + vfs.mount('/vfs-test-p-access-miss'); + + await assert.rejects(fsp.access('/vfs-test-p-access-miss/nope.txt'), { + code: 'ENOENT', + }); + }); + + it('fs.promises.readFile reads from VFS', async () => { + vfs = create(); + vfs.writeFileSync('/pread.txt', 'async vfs content'); + vfs.mount('/vfs-test-p-readfile'); + + const content = await fsp.readFile('/vfs-test-p-readfile/pread.txt', 'utf8'); + assert.strictEqual(content, 'async vfs content'); + }); + + it('fs.promises.stat returns stats for VFS files', async () => { + vfs = create(); + vfs.writeFileSync('/pstat.txt', 'data'); + vfs.mount('/vfs-test-p-stat'); + + const stats = await fsp.stat('/vfs-test-p-stat/pstat.txt'); + assert.ok(stats.isFile()); + }); + + it('fs.promises.lstat returns stats for VFS files', async () => { + vfs = create(); + vfs.writeFileSync('/plstat.txt', 'data'); + vfs.mount('/vfs-test-p-lstat'); + + const stats = await fsp.lstat('/vfs-test-p-lstat/plstat.txt'); + assert.ok(stats.isFile()); + }); + + it('fs.promises.readdir lists VFS directory contents', async () => { + vfs = create(); + vfs.writeFileSync('/pdir/x.txt', 'x'); + vfs.writeFileSync('/pdir/y.txt', 'y'); + vfs.mount('/vfs-test-p-readdir'); + + const entries = await fsp.readdir('/vfs-test-p-readdir/pdir'); + assert.deepStrictEqual(entries.sort(), ['x.txt', 'y.txt']); + }); + + it('fs.promises.readlink reads VFS symlinks', async () => { + vfs = create(); + vfs.writeFileSync('/plink-target.txt', 'data'); + vfs.symlinkSync('/plink-target.txt', '/plink.txt'); + vfs.mount('/vfs-test-p-readlink'); + + const target = await fsp.readlink('/vfs-test-p-readlink/plink.txt'); + assert.strictEqual(target, '/vfs-test-p-readlink/plink-target.txt'); + }); + + it('fs.promises.realpath resolves VFS paths', async () => { + vfs = create(); + vfs.writeFileSync('/prealpath.txt', 'data'); + vfs.mount('/vfs-test-p-realpath'); + + const resolved = await fsp.realpath('/vfs-test-p-realpath/prealpath.txt'); + assert.strictEqual(resolved, '/vfs-test-p-realpath/prealpath.txt'); + }); + + it('require("fs/promises") returns the same patched object', async () => { + vfs = create(); + vfs.writeFileSync('/shared.txt', 'shared content'); + vfs.mount('/vfs-test-p-shared'); + + // Both import paths should see VFS content + const content1 = await fs.promises.readFile('/vfs-test-p-shared/shared.txt', 'utf8'); + const content2 = await fsp.readFile('/vfs-test-p-shared/shared.txt', 'utf8'); + assert.strictEqual(content1, 'shared content'); + assert.strictEqual(content2, 'shared content'); + }); +}); From e835445efd14c0ea7f9bf0dba8d63d2f9c2d89fe Mon Sep 17 00:00:00 2001 From: robertsLando Date: Wed, 8 Apr 2026 16:47:49 +0200 Subject: [PATCH 2/3] fix: add callback fs.stat, fs.lstat, fs.readFile, and fs.createReadStream patches The VFS module hooks only patched sync and promise-based fs methods, leaving callback versions unpatched. This caused ENOENT errors when libraries like express.static (via send) use fs.stat(path, callback). Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/module_hooks.js | 87 +++++++++++++++++++++++++++++++++++++++++++ test/fs-hooks.test.js | 83 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/lib/module_hooks.js b/lib/module_hooks.js index b00d7ca..dce50db 100644 --- a/lib/module_hooks.js +++ b/lib/module_hooks.js @@ -946,6 +946,93 @@ function installFsPatches() { return originalAccess.call(fs, path, mode, callback); }; + // --- Callback patches for fs.stat, fs.lstat, fs.readFile, fs.createReadStream --- + + const originalStat = fs.stat; + fs.stat = function stat(path, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + if (typeof path === 'string') { + try { + const vfsResult = findVFSForFsStat(path); + if (vfsResult !== null) { + if (callback) process.nextTick(callback, null, vfsResult.stats); + return; + } + } catch (err) { + if (callback) process.nextTick(callback, err); + return; + } + } + return originalStat.call(fs, path, options, callback); + }; + + const originalLstat = fs.lstat; + fs.lstat = function lstat(path, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + if (typeof path === 'string') { + try { + const vfsResult = findVFSForFsStat(path); + if (vfsResult !== null) { + if (callback) process.nextTick(callback, null, vfsResult.stats); + return; + } + } catch (err) { + if (callback) process.nextTick(callback, err); + return; + } + } + return originalLstat.call(fs, path, options, callback); + }; + + const originalReadFile = fs.readFile; + fs.readFile = function readFile(path, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + if (typeof path === 'string') { + try { + const vfsResult = findVFSForRead(path, options); + if (vfsResult !== null) { + if (callback) process.nextTick(callback, null, vfsResult.content); + return; + } + } catch (err) { + if (callback) process.nextTick(callback, err); + return; + } + } + return originalReadFile.call(fs, path, options, callback); + }; + + const originalCreateReadStream = fs.createReadStream; + fs.createReadStream = function createReadStream(path, options) { + if (typeof path === 'string') { + try { + const vfsResult = findVFSForRead(path, options); + if (vfsResult !== null) { + const { Readable } = require('node:stream'); + const stream = new Readable({ read() {} }); + stream.push(vfsResult.content); + stream.push(null); + return stream; + } + } catch (err) { + const { Readable } = require('node:stream'); + const stream = new Readable({ read() {} }); + process.nextTick(() => stream.destroy(err)); + return stream; + } + } + return originalCreateReadStream.call(fs, path, options); + }; + // --- fs.promises patches --- // Patched directly on the shared object so require('fs/promises') also // picks up the changes (it returns the same reference as fs.promises). diff --git a/test/fs-hooks.test.js b/test/fs-hooks.test.js index b50a445..af626b5 100644 --- a/test/fs-hooks.test.js +++ b/test/fs-hooks.test.js @@ -134,6 +134,89 @@ describe('Module hooks — fs.access callback', () => { }); }); +describe('Module hooks — fs callback patches', () => { + let vfs; + + afterEach(() => { + if (vfs?.mounted) { + vfs.unmount(); + } + }); + + it('fs.stat calls back with stats for VFS files', (_, done) => { + vfs = create(); + vfs.writeFileSync('/cb-stat.txt', 'data'); + vfs.mount('/vfs-test-cb-stat'); + + fs.stat('/vfs-test-cb-stat/cb-stat.txt', (err, stats) => { + assert.ifError(err); + assert.ok(stats.isFile()); + done(); + }); + }); + + it('fs.stat calls back with ENOENT for missing VFS files', (_, done) => { + vfs = create(); + vfs.mount('/vfs-test-cb-stat-miss'); + + fs.stat('/vfs-test-cb-stat-miss/nope.txt', (err) => { + assert.ok(err); + assert.strictEqual(err.code, 'ENOENT'); + done(); + }); + }); + + it('fs.lstat calls back with stats for VFS files', (_, done) => { + vfs = create(); + vfs.writeFileSync('/cb-lstat.txt', 'data'); + vfs.mount('/vfs-test-cb-lstat'); + + fs.lstat('/vfs-test-cb-lstat/cb-lstat.txt', (err, stats) => { + assert.ifError(err); + assert.ok(stats.isFile()); + done(); + }); + }); + + it('fs.readFile calls back with VFS content', (_, done) => { + vfs = create(); + vfs.writeFileSync('/cb-read.txt', 'callback content'); + vfs.mount('/vfs-test-cb-readfile'); + + fs.readFile('/vfs-test-cb-readfile/cb-read.txt', 'utf8', (err, content) => { + assert.ifError(err); + assert.strictEqual(content, 'callback content'); + done(); + }); + }); + + it('fs.readFile calls back with ENOENT for missing VFS files', (_, done) => { + vfs = create(); + vfs.mount('/vfs-test-cb-readfile-miss'); + + fs.readFile('/vfs-test-cb-readfile-miss/nope.txt', 'utf8', (err) => { + assert.ok(err); + assert.strictEqual(err.code, 'ENOENT'); + done(); + }); + }); + + it('fs.createReadStream returns a readable stream for VFS files', (_, done) => { + vfs = create(); + vfs.writeFileSync('/stream.txt', 'streamed data'); + vfs.mount('/vfs-test-cb-stream'); + + const chunks = []; + const stream = fs.createReadStream('/vfs-test-cb-stream/stream.txt'); + stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('end', () => { + assert.strictEqual(Buffer.concat(chunks).toString(), 'streamed data'); + done(); + }); + stream.on('error', done); + }); +}); + describe('Module hooks — fs.promises patches', () => { let vfs; From ede4dc1e0aa1e2488b5038f548e30d874f5581ed Mon Sep 17 00:00:00 2001 From: robertsLando Date: Wed, 8 Apr 2026 16:53:53 +0200 Subject: [PATCH 3/3] fix: add callback fs.readdir, fs.readlink, and fs.realpath patches Libraries like glob, graceful-fs, and enhanced-resolve use the callback forms of these methods. Without patches they bypass the VFS entirely. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/module_hooks.js | 66 +++++++++++++++++++++++++++++++++++++++++++ test/fs-hooks.test.js | 38 +++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/lib/module_hooks.js b/lib/module_hooks.js index dce50db..734d4f7 100644 --- a/lib/module_hooks.js +++ b/lib/module_hooks.js @@ -1033,6 +1033,72 @@ function installFsPatches() { return originalCreateReadStream.call(fs, path, options); }; + const originalReaddir = fs.readdir; + fs.readdir = function readdir(path, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + if (typeof path === 'string') { + try { + const vfsResult = findVFSForReaddir(path, options); + if (vfsResult !== null) { + if (callback) process.nextTick(callback, null, vfsResult.entries); + return; + } + } catch (err) { + if (callback) process.nextTick(callback, err); + return; + } + } + return originalReaddir.call(fs, path, options, callback); + }; + + const originalReadlink = fs.readlink; + fs.readlink = function readlink(path, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + if (typeof path === 'string') { + try { + const vfsResult = findVFSForRealpath(path); + if (vfsResult !== null) { + if (callback) process.nextTick(callback, null, vfsResult.realpath); + return; + } + } catch (err) { + if (callback) process.nextTick(callback, err); + return; + } + } + return originalReadlink.call(fs, path, options, callback); + }; + + const originalRealpath = fs.realpath; + fs.realpath = function realpath(path, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + if (typeof path === 'string') { + try { + const vfsResult = findVFSForRealpath(path); + if (vfsResult !== null) { + if (callback) process.nextTick(callback, null, vfsResult.realpath); + return; + } + } catch (err) { + if (callback) process.nextTick(callback, err); + return; + } + } + return originalRealpath.call(fs, path, options, callback); + }; + if (originalRealpath.native) { + fs.realpath.native = originalRealpath.native; + } + // --- fs.promises patches --- // Patched directly on the shared object so require('fs/promises') also // picks up the changes (it returns the same reference as fs.promises). diff --git a/test/fs-hooks.test.js b/test/fs-hooks.test.js index af626b5..d50f7ef 100644 --- a/test/fs-hooks.test.js +++ b/test/fs-hooks.test.js @@ -201,6 +201,44 @@ describe('Module hooks — fs callback patches', () => { }); }); + it('fs.readdir calls back with VFS directory entries', (_, done) => { + vfs = create(); + vfs.writeFileSync('/cbdir/a.txt', 'a'); + vfs.writeFileSync('/cbdir/b.txt', 'b'); + vfs.mount('/vfs-test-cb-readdir'); + + fs.readdir('/vfs-test-cb-readdir/cbdir', (err, entries) => { + assert.ifError(err); + assert.deepStrictEqual(entries.sort(), ['a.txt', 'b.txt']); + done(); + }); + }); + + it('fs.readlink calls back with VFS symlink target', (_, done) => { + vfs = create(); + vfs.writeFileSync('/cb-link-target.txt', 'data'); + vfs.symlinkSync('/cb-link-target.txt', '/cb-link.txt'); + vfs.mount('/vfs-test-cb-readlink'); + + fs.readlink('/vfs-test-cb-readlink/cb-link.txt', (err, target) => { + assert.ifError(err); + assert.strictEqual(target, '/vfs-test-cb-readlink/cb-link-target.txt'); + done(); + }); + }); + + it('fs.realpath calls back with resolved VFS path', (_, done) => { + vfs = create(); + vfs.writeFileSync('/cb-real.txt', 'data'); + vfs.mount('/vfs-test-cb-realpath'); + + fs.realpath('/vfs-test-cb-realpath/cb-real.txt', (err, resolved) => { + assert.ifError(err); + assert.strictEqual(resolved, '/vfs-test-cb-realpath/cb-real.txt'); + done(); + }); + }); + it('fs.createReadStream returns a readable stream for VFS files', (_, done) => { vfs = create(); vfs.writeFileSync('/stream.txt', 'streamed data');