From 5b25abcbd1fe00bf3c103e18ff9b0485024d9870 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 30 May 2026 06:55:25 +0200 Subject: [PATCH 1/4] vfs: integrate with CJS and ESM module loaders Route loader fs and package.json operations through toggleable wrappers so the VFS can resolve and load modules from mounted paths. --- lib/internal/modules/cjs/loader.js | 19 +- lib/internal/modules/esm/get_format.js | 4 +- lib/internal/modules/esm/load.js | 8 +- lib/internal/modules/esm/resolve.js | 20 +- lib/internal/modules/helpers.js | 164 +++- lib/internal/modules/package_json_reader.js | 29 +- lib/internal/vfs/setup.js | 299 +++++++- test/parallel/test-vfs-import.mjs | 142 ++++ .../parallel/test-vfs-invalid-package-json.js | 45 ++ test/parallel/test-vfs-module-hooks.mjs | 725 ++++++++++++++++++ test/parallel/test-vfs-package-json-cache.js | 23 + test/parallel/test-vfs-package-json.js | 184 +++++ test/parallel/test-vfs-require.js | 405 ++++++++++ 13 files changed, 2027 insertions(+), 40 deletions(-) create mode 100644 test/parallel/test-vfs-import.mjs create mode 100644 test/parallel/test-vfs-invalid-package-json.js create mode 100644 test/parallel/test-vfs-module-hooks.mjs create mode 100644 test/parallel/test-vfs-package-json-cache.js create mode 100644 test/parallel/test-vfs-package-json.js create mode 100644 test/parallel/test-vfs-require.js diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 801ab9caecc2aa..acec49920eaf61 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -113,6 +113,7 @@ const kFormat = Symbol('kFormat'); // Set first due to cycle with ESM loader functions. module.exports = { + clearStatCache, kModuleSource, kModuleExport, kModuleExportNames, @@ -155,14 +156,14 @@ const { } = internalBinding('contextify'); const assert = require('internal/assert'); -const fs = require('fs'); const path = require('path'); -const internalFsBinding = internalBinding('fs'); const { safeGetenv } = internalBinding('credentials'); const { getCjsConditions, getCjsConditionsArray, initializeCjsConditions, + loaderReadFile, + loaderStat, loadBuiltinModule, makeRequireFunction, setHasStartedUserCJSExecution, @@ -272,7 +273,7 @@ function stat(filename) { const result = statCache.get(filename); if (result !== undefined) { return result; } } - const result = internalFsBinding.internalModuleStat(filename); + const result = loaderStat(filename); if (statCache !== null && result >= 0) { // Only set cache when `internalModuleStat(filename)` succeeds. statCache.set(filename, result); @@ -280,6 +281,16 @@ function stat(filename) { return result; } +/** + * Clear the stat cache. Called when VFS instances are unmounted + * to prevent stale stat results from being returned. + */ +function clearStatCache() { + if (statCache !== null) { + statCache = new SafeMap(); + } +} + let _stat = stat; ObjectDefineProperty(Module, '_stat', { __proto__: null, @@ -1201,7 +1212,7 @@ function defaultLoadImpl(filename, format) { case 'module-typescript': case 'commonjs-typescript': case 'typescript': { - return fs.readFileSync(filename, 'utf8'); + return loaderReadFile(filename, 'utf8'); } case 'builtin': return null; diff --git a/lib/internal/modules/esm/get_format.js b/lib/internal/modules/esm/get_format.js index 4f334c7d88c336..507c336d6744fe 100644 --- a/lib/internal/modules/esm/get_format.js +++ b/lib/internal/modules/esm/get_format.js @@ -10,7 +10,6 @@ const { } = primordials; const { getOptionValue } = require('internal/options'); const { getValidatedPath } = require('internal/fs/utils'); -const fsBindings = internalBinding('fs'); const { internal: internalConstants } = internalBinding('constants'); const extensionFormatMap = { @@ -59,7 +58,8 @@ function mimeToFormat(mime) { */ function getFormatOfExtensionlessFile(url) { const path = getValidatedPath(url); - switch (fsBindings.getFormatOfExtensionlessFile(path)) { + const { loaderGetFormatOfExtensionlessFile } = require('internal/modules/helpers'); + switch (loaderGetFormatOfExtensionlessFile(path)) { case internalConstants.EXTENSIONLESS_FORMAT_WASM: return 'wasm'; default: diff --git a/lib/internal/modules/esm/load.js b/lib/internal/modules/esm/load.js index 94879761553e02..1a788c0041a752 100644 --- a/lib/internal/modules/esm/load.js +++ b/lib/internal/modules/esm/load.js @@ -9,7 +9,7 @@ const { const { defaultGetFormat } = require('internal/modules/esm/get_format'); const { validateAttributes, emitImportAssertionWarning } = require('internal/modules/esm/assert'); -const fs = require('fs'); +const { loaderReadFile } = require('internal/modules/helpers'); const { Buffer: { from: BufferFrom } } = require('buffer'); @@ -34,11 +34,7 @@ function getSourceSync(url, context) { const responseURL = href; let source; if (protocol === 'file:') { - // If you are reading this code to figure out how to patch Node.js module loading - // behavior - DO NOT depend on the patchability in new code: Node.js - // internals may stop going through the JavaScript fs module entirely. - // Prefer module.registerHooks() or other more formal fs hooks released in the future. - source = fs.readFileSync(url); + source = loaderReadFile(url); } else if (protocol === 'data:') { const result = dataURLProcessor(url); if (result === 'failure') { diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js index b028f44f013886..1da2795c117e68 100644 --- a/lib/internal/modules/esm/resolve.js +++ b/lib/internal/modules/esm/resolve.js @@ -9,7 +9,6 @@ const { ObjectPrototypeHasOwnProperty, RegExpPrototypeExec, RegExpPrototypeSymbolReplace, - SafeMap, SafeSet, String, StringPrototypeEndsWith, @@ -23,16 +22,13 @@ const { encodeURIComponent, } = primordials; const assert = require('internal/assert'); -const internalFS = require('internal/fs/utils'); const { BuiltinModule } = require('internal/bootstrap/realm'); -const fs = require('fs'); const { getOptionValue } = require('internal/options'); // Do not eagerly grab .manifest, it may be in TDZ const { sep, posix: { relative: relativePosixPath }, resolve } = require('path'); const { URL, pathToFileURL, fileURLToPath, isURL, URLParse } = require('internal/url'); const { getCWDURL, setOwnProperty } = require('internal/util'); const { canParse: URLCanParse } = internalBinding('url'); -const { legacyMainResolve: FSLegacyMainResolve } = internalBinding('fs'); const { ERR_INPUT_TYPE_NOT_ALLOWED, ERR_INVALID_ARG_TYPE, @@ -49,7 +45,7 @@ const { const { defaultGetFormatWithoutErrors } = require('internal/modules/esm/get_format'); const { getConditionsSet } = require('internal/modules/esm/utils'); const packageJsonReader = require('internal/modules/package_json_reader'); -const internalFsBinding = internalBinding('fs'); +const { loaderLegacyMainResolve, loaderStat, toRealPath } = require('internal/modules/helpers'); /** * @typedef {import('internal/modules/esm/package_config.js').PackageConfig} PackageConfig @@ -149,8 +145,6 @@ function emitLegacyIndexDeprecation(url, path, pkgPath, base, main) { } } -const realpathCache = new SafeMap(); - const legacyMainResolveExtensions = [ '', '.js', @@ -198,7 +192,7 @@ function legacyMainResolve(packageJSONUrl, packageConfig, base) { const baseStringified = isURL(base) ? base.href : base; - const resolvedOption = FSLegacyMainResolve(pkgPath, packageConfig.main, baseStringified); + const resolvedOption = loaderLegacyMainResolve(pkgPath, packageConfig.main, baseStringified); const maybeMain = resolvedOption <= legacyMainResolveExtensionsIndexes.kResolvedByMainIndexNode ? packageConfig.main || './' : ''; @@ -244,7 +238,7 @@ function finalizeResolution(resolved, base, preserveSymlinks) { throw err; } - const stats = internalFsBinding.internalModuleStat( + const stats = loaderStat( StringPrototypeEndsWith(path, '/') ? StringPrototypeSlice(path, -1) : path, ); @@ -273,13 +267,7 @@ function finalizeResolution(resolved, base, preserveSymlinks) { } if (!preserveSymlinks) { - // If you are reading this code to figure out how to patch Node.js module loading - // behavior - DO NOT depend on the patchability in new code: Node.js - // internals may stop going through the JavaScript fs module entirely. - // Prefer module.registerHooks() or other more formal fs hooks released in the future. - const real = fs.realpathSync(path, { - [internalFS.realpathCacheKey]: realpathCache, - }); + const real = toRealPath(path); const { search, hash } = resolved; resolved = pathToFileURL(real + (StringPrototypeEndsWith(path, sep) ? '/' : '')); diff --git a/lib/internal/modules/helpers.js b/lib/internal/modules/helpers.js index 839ce9af4bb678..51a4e188ad4681 100644 --- a/lib/internal/modules/helpers.js +++ b/lib/internal/modules/helpers.js @@ -34,13 +34,15 @@ const { emitWarningSync } = require('internal/process/warning'); const lazyTmpdir = getLazy(() => require('os').tmpdir()); const { join } = path; +const internalFsBinding = internalBinding('fs'); const { canParse: URLCanParse } = internalBinding('url'); +const modulesBinding = internalBinding('modules'); const { enableCompileCache: _enableCompileCache, getCompileCacheDir: _getCompileCacheDir, compileCacheStatus: _compileCacheStatus, flushCompileCache, -} = internalBinding('modules'); +} = modulesBinding; const lazyCJSLoader = getLazy(() => require('internal/modules/cjs/loader')); let debug = require('internal/util/debuglog').debuglog('module', (fn) => { @@ -56,17 +58,167 @@ let debug = require('internal/util/debuglog').debuglog('module', (fn) => { * @type {Map} */ const realpathCache = new SafeMap(); +// Toggleable loader fs overrides for VFS support. +// When null, the fast path (no VFS) is taken with zero overhead. +let _loaderStat = null; +let _loaderReadFile = null; +let _loaderRealpath = null; +let _loaderLegacyMainResolve = null; +let _loaderGetFormatOfExtensionlessFile = null; + +/** + * Set override functions for the module loader's fs operations. + * @param {{ stat?: Function, readFile?: Function, realpath?: Function, + * legacyMainResolve?: Function, getFormatOfExtensionlessFile?: Function }} overrides + */ +function setLoaderFsOverrides({ stat, readFile, realpath, legacyMainResolve, getFormatOfExtensionlessFile }) { + _loaderStat = stat; + _loaderReadFile = readFile; + _loaderRealpath = realpath; + _loaderLegacyMainResolve = legacyMainResolve; + _loaderGetFormatOfExtensionlessFile = getFormatOfExtensionlessFile; +} + +/** + * Wrapper for internalModuleStat that supports VFS toggle. + * @param {string} filename Absolute path to stat + * @returns {number} + */ +function loaderStat(filename) { + if (_loaderStat !== null) { return _loaderStat(filename); } + return internalFsBinding.internalModuleStat(filename); +} + +/** + * Wrapper for fs.readFileSync that supports VFS toggle. + * @param {string|URL} filename Path to read + * @param {string|object} options Read options + * @returns {string|Buffer} + */ +function loaderReadFile(filename, options) { + if (_loaderReadFile !== null) { + const result = _loaderReadFile(filename, options); + if (result !== undefined) { return result; } + } + return fs.readFileSync(filename, options); +} + /** * Resolves the path of a given `require` specifier, following symlinks. * @param {string} requestPath The `require` specifier * @returns {string} */ function toRealPath(requestPath) { + if (_loaderRealpath !== null) { + const result = _loaderRealpath(requestPath); + if (result !== undefined) { return result; } + } return fs.realpathSync(requestPath, { [internalFS.realpathCacheKey]: realpathCache, }); } +/** + * Wrapper for internalBinding('fs').legacyMainResolve that supports VFS toggle. + * @param {string} pkgPath The package directory path + * @param {string} main The package main field + * @param {string} base The base URL string + * @returns {number} + */ +function loaderLegacyMainResolve(pkgPath, main, base) { + if (_loaderLegacyMainResolve !== null) { + const result = _loaderLegacyMainResolve(pkgPath, main, base); + if (result !== undefined) { return result; } + } + return internalFsBinding.legacyMainResolve(pkgPath, main, base); +} + +/** + * Wrapper for internalBinding('fs').getFormatOfExtensionlessFile that supports VFS toggle. + * @param {string} path The file path + * @returns {number} + */ +function loaderGetFormatOfExtensionlessFile(path) { + if (_loaderGetFormatOfExtensionlessFile !== null) { + const result = _loaderGetFormatOfExtensionlessFile(path); + if (result !== undefined) { return result; } + } + return internalFsBinding.getFormatOfExtensionlessFile(path); +} + +// Toggleable overrides for package.json C++ methods (VFS support). +let _loaderReadPackageJSON = null; +let _loaderGetNearestParentPackageJSON = null; +let _loaderGetPackageScopeConfig = null; +let _loaderGetPackageType = null; + +/** + * Set override functions for the module loader's package.json operations. + * @param {{ + * readPackageJSON?: Function, + * getNearestParentPackageJSON?: Function, + * getPackageScopeConfig?: Function, + * getPackageType?: Function, + * }} overrides + */ +function setLoaderPackageOverrides(overrides) { + _loaderReadPackageJSON = overrides.readPackageJSON; + _loaderGetNearestParentPackageJSON = overrides.getNearestParentPackageJSON; + _loaderGetPackageScopeConfig = overrides.getPackageScopeConfig; + _loaderGetPackageType = overrides.getPackageType; +} + +/** + * Wrapper for modulesBinding.readPackageJSON that supports VFS toggle. + * @param {string} jsonPath + * @param {boolean} isESM + * @param {string} base + * @param {string} specifier + * @returns {object|undefined} + */ +function loaderReadPackageJSON(jsonPath, isESM, base, specifier) { + if (_loaderReadPackageJSON !== null) { + return _loaderReadPackageJSON(jsonPath, isESM, base, specifier); + } + return modulesBinding.readPackageJSON(jsonPath, isESM, base, specifier); +} + +/** + * Wrapper for modulesBinding.getNearestParentPackageJSON that supports VFS toggle. + * @param {string} checkPath + * @returns {object|undefined} + */ +function loaderGetNearestParentPackageJSON(checkPath) { + if (_loaderGetNearestParentPackageJSON !== null) { + return _loaderGetNearestParentPackageJSON(checkPath); + } + return modulesBinding.getNearestParentPackageJSON(checkPath); +} + +/** + * Wrapper for modulesBinding.getPackageScopeConfig that supports VFS toggle. + * @param {string} resolved + * @returns {object|string} + */ +function loaderGetPackageScopeConfig(resolved) { + if (_loaderGetPackageScopeConfig !== null) { + return _loaderGetPackageScopeConfig(resolved); + } + return modulesBinding.getPackageScopeConfig(resolved); +} + +/** + * Wrapper for modulesBinding.getPackageType that supports VFS toggle. + * @param {string} url + * @returns {string|undefined} + */ +function loaderGetPackageType(url) { + if (_loaderGetPackageType !== null) { + return _loaderGetPackageType(url); + } + return modulesBinding.getPackageType(url); +} + /** @type {Set} */ let cjsConditions; /** @type {string[]} */ @@ -524,10 +676,20 @@ module.exports = { getCjsConditionsArray, getCompileCacheDir, initializeCjsConditions, + loaderGetFormatOfExtensionlessFile, + loaderGetNearestParentPackageJSON, + loaderGetPackageScopeConfig, + loaderGetPackageType, + loaderLegacyMainResolve, + loaderReadFile, + loaderReadPackageJSON, + loaderStat, loadBuiltinModuleForEmbedder, loadBuiltinModule, makeRequireFunction, normalizeReferrerURL, + setLoaderFsOverrides, + setLoaderPackageOverrides, stringify, stripBOM, toRealPath, diff --git a/lib/internal/modules/package_json_reader.js b/lib/internal/modules/package_json_reader.js index 6c6bf0383bc338..13aa36edb39bd9 100644 --- a/lib/internal/modules/package_json_reader.js +++ b/lib/internal/modules/package_json_reader.js @@ -24,10 +24,15 @@ const { }, } = require('internal/errors'); const { kEmptyObject } = require('internal/util'); -const modulesBinding = internalBinding('modules'); const path = require('path'); const { validateString } = require('internal/validators'); -const internalFsBinding = internalBinding('fs'); +const { + loaderGetNearestParentPackageJSON, + loaderGetPackageScopeConfig, + loaderGetPackageType, + loaderReadPackageJSON, + loaderStat, +} = require('internal/modules/helpers'); /** @@ -122,7 +127,7 @@ const requiresJSONParse = (value) => (value !== undefined && (value[0] === '[' | function read(jsonPath, { base, specifier, isESM } = kEmptyObject) { // This function will be called by both CJS and ESM, so we need to make sure // non-null attributes are converted to strings. - const parsed = modulesBinding.readPackageJSON( + const parsed = loaderReadPackageJSON( jsonPath, isESM, base == null ? undefined : `${base}`, @@ -170,7 +175,7 @@ function getNearestParentPackageJSON(checkPath) { return deserializedPackageJSONCache.get(parentPackageJSONPath); } - const result = modulesBinding.getNearestParentPackageJSON(checkPath); + const result = loaderGetNearestParentPackageJSON(checkPath); const packageConfig = deserializePackageJSON(checkPath, result); moduleToParentPackageJSONCache.set(checkPath, packageConfig.path); @@ -190,7 +195,7 @@ function getNearestParentPackageJSON(checkPath) { * @returns {import('typings/internalBinding/modules').PackageConfig} - The package configuration. */ function getPackageScopeConfig(resolved) { - const result = modulesBinding.getPackageScopeConfig(`${resolved}`); + const result = loaderGetPackageScopeConfig(`${resolved}`); if (ArrayIsArray(result)) { const { data, exists, path } = deserializePackageJSON(`${resolved}`, result); @@ -219,7 +224,7 @@ function getPackageScopeConfig(resolved) { * @returns {string} */ function getPackageType(url) { - const type = modulesBinding.getPackageType(`${url}`); + const type = loaderGetPackageType(`${url}`); return type ?? 'none'; } @@ -280,7 +285,7 @@ function getPackageJSONURL(specifier, base) { let packageJSONPath = fileURLToPath(packageJSONUrl); let lastPath; do { - const stat = internalFsBinding.internalModuleStat( + const stat = loaderStat( StringPrototypeSlice(packageJSONPath, 0, packageJSONPath.length - 13), ); // Check for !stat.isDirectory() @@ -353,6 +358,15 @@ function findPackageJSON(specifier, base = 'data:') { return pkg?.path; } +/** + * Clears all package.json caches. Called by VFS on unmount to prevent + * stale entries from paths that were resolved while a VFS was mounted. + */ +function clearPackageJSONCache() { + moduleToParentPackageJSONCache.clear(); + deserializedPackageJSONCache.clear(); +} + module.exports = { read, getNearestParentPackageJSON, @@ -360,4 +374,5 @@ module.exports = { getPackageType, getPackageJSONURL, findPackageJSON, + clearPackageJSONCache, }; diff --git a/lib/internal/vfs/setup.js b/lib/internal/vfs/setup.js index fe06f578b2f6ca..51cb5088eb8035 100644 --- a/lib/internal/vfs/setup.js +++ b/lib/internal/vfs/setup.js @@ -4,19 +4,25 @@ const { ArrayPrototypeIndexOf, ArrayPrototypePush, ArrayPrototypeSplice, + JSONParse, + JSONStringify, PromiseResolve, + String, + StringPrototypeEndsWith, StringPrototypeStartsWith, } = primordials; const { Buffer } = require('buffer'); -const { resolve, sep } = require('path'); +const { dirname, resolve, sep } = require('path'); const { fileURLToPath, URL } = require('internal/url'); const { kEmptyObject } = require('internal/util'); const { validateObject } = require('internal/validators'); const { codes: { ERR_INVALID_ARG_TYPE, + ERR_INVALID_PACKAGE_CONFIG, ERR_INVALID_STATE, + ERR_MODULE_NOT_FOUND, }, } = require('internal/errors'); const { createENOENT, createEXDEV } = require('internal/vfs/errors'); @@ -82,9 +88,7 @@ function registerVFS(vfs) { ArrayPrototypePush(activeVFSList, vfs); debug('register mount=%s active=%d', newMount, activeVFSList.length); if (!hooksInstalled) { - vfsHandlerObj = createVfsHandlers(); - setVfsHandlers(vfsHandlerObj); - hooksInstalled = true; + installHooks(); } else if (vfsState.handlers === null) { setVfsHandlers(vfsHandlerObj); } @@ -98,6 +102,121 @@ function deregisterVFS(vfs) { if (activeVFSList.length === 0) { setVfsHandlers(null); } + // Clear CJS loader and package.json caches to avoid stale entries from + // paths that were resolved while the VFS was mounted. + const cjsLoader = require('internal/modules/cjs/loader'); + cjsLoader.Module._pathCache = { __proto__: null }; + cjsLoader.clearStatCache(); + const { clearPackageJSONCache } = require('internal/modules/package_json_reader'); + clearPackageJSONCache(); +} + +/** + * Returns the stat result code for a VFS path. + * @param {object} vfs The VFS instance + * @param {string} filePath The path to check + * @returns {number} 0 for file, 1 for directory, -2 for not found + */ +function vfsStat(vfs, filePath) { + try { + const stats = vfs.statSync(filePath); + if (stats.isDirectory()) return 1; + return 0; + } catch { + return -2; + } +} + +/** + * Checks all active VFS instances for a file/directory stat. + * @param {string} filename The absolute path to check + * @returns {{ vfs: object, result: number }|null} + */ +function findVFSForStat(filename) { + const normalized = resolve(filename); + for (let i = 0; i < activeVFSList.length; i++) { + const vfs = activeVFSList[i]; + if (vfs.shouldHandle(normalized)) { + return { vfs, result: vfsStat(vfs, normalized) }; + } + } + return null; +} + +/** + * Checks all active VFS instances for file content. + * @param {string} filename The absolute path to read + * @param {string|object} options Read options + * @returns {{ vfs: object, content: Buffer|string }|null} + */ +function findVFSForRead(filename, options) { + const normalized = resolve(filename); + for (let i = 0; i < activeVFSList.length; i++) { + const vfs = activeVFSList[i]; + if (vfs.shouldHandle(normalized)) { + if (vfs.existsSync(normalized) && vfsStat(vfs, normalized) === 0) { + return { vfs, content: vfs.readFileSync(normalized, options) }; + } + // Path inside mount but missing/not-a-file: synthesize ENOENT so the + // loader doesn't fall through and read a real-fs file with the same path. + throw createENOENT('open', filename); + } + } + return null; +} + +/** + * Serialize a parsed package.json object into the C++ tuple format + * expected by deserializePackageJSON: [name, main, type, imports, exports, filePath]. + * @param {object} parsed The parsed package.json content + * @param {string} filePath The path to the package.json file + * @returns {Array} Serialized package config tuple + */ +function serializePackageJSON(parsed, filePath) { + const name = parsed.name; + const main = parsed.main; + const type = parsed.type ?? 'none'; + const imports = parsed.imports !== undefined ? + (typeof parsed.imports === 'string' ? + parsed.imports : JSONStringify(parsed.imports)) : + undefined; + const exports = parsed.exports !== undefined ? + (typeof parsed.exports === 'string' ? + parsed.exports : JSONStringify(parsed.exports)) : + undefined; + return [name, main, type, imports, exports, filePath]; +} + +/** + * Walk up directories in VFS looking for package.json. + * @param {string} startPath Normalized absolute path to start from + * @returns {{ vfs: object, pjsonPath: string, parsed: object }|null} + */ +function findVFSPackageJSON(startPath) { + let currentDir = dirname(startPath); + let lastDir; + while (currentDir !== lastDir) { + if (StringPrototypeEndsWith(currentDir, '/node_modules') || + StringPrototypeEndsWith(currentDir, '\\node_modules')) { + break; + } + const pjsonPath = resolve(currentDir, 'package.json'); + for (let i = 0; i < activeVFSList.length; i++) { + const vfs = activeVFSList[i]; + if (vfs.shouldHandle(pjsonPath) && vfsStat(vfs, pjsonPath) === 0) { + try { + const content = vfs.readFileSync(pjsonPath, 'utf8'); + const parsed = JSONParse(content); + return { vfs, pjsonPath, parsed }; + } catch { + // SyntaxError or other errors, continue walking + } + } + } + lastDir = currentDir; + currentDir = dirname(currentDir); + } + return null; } function findVFSForExists(filename) { @@ -655,6 +774,178 @@ function createVfsHandlers() { }; } +/** + * Install toggleable loader overrides so that the module loader's + * internal fs operations (stat, readFile, realpath) are redirected + * to VFS when appropriate. + */ +function installModuleLoaderOverrides() { + const { + setLoaderFsOverrides, + setLoaderPackageOverrides, + } = require('internal/modules/helpers'); + const internalFsBinding = internalBinding('fs'); + const nativeModulesBinding = internalBinding('modules'); + + setLoaderFsOverrides({ + stat(filename) { + const result = findVFSForStat(filename); + if (result !== null) return result.result; + return internalFsBinding.internalModuleStat(filename); + }, + readFile(filename, options) { + const pathStr = typeof filename === 'string' ? filename : + (filename instanceof URL ? fileURLToPath(filename) : String(filename)); + const result = findVFSForRead(pathStr, options); + return result !== null ? result.content : undefined; + }, + realpath(filename) { + return findVFSWith(filename, 'realpath', (vfs, n) => vfs.realpathSync(n)); + }, + legacyMainResolve(pkgPath, main, base) { + const normalized = resolve(pkgPath); + let handled = false; + for (let i = 0; i < activeVFSList.length; i++) { + if (activeVFSList[i].shouldHandle(normalized)) { + handled = true; + break; + } + } + if (!handled) return undefined; + + // Extension index mapping (matches C++ legacyMainResolve): + // 0-6: try main + extension, then main + /index.ext + // 7-9: try pkgPath + ./index.ext + const mainExts = ['', '.js', '.json', '.node', + '/index.js', '/index.json', '/index.node']; + const indexExts = ['./index.js', './index.json', './index.node']; + + if (main) { + for (let i = 0; i < mainExts.length; i++) { + const candidate = resolve(pkgPath, main + mainExts[i]); + if (findVFSForStat(candidate)?.result === 0) return i; + } + } + for (let i = 0; i < indexExts.length; i++) { + const candidate = resolve(pkgPath, indexExts[i]); + if (findVFSForStat(candidate)?.result === 0) return 7 + i; + } + + throw new ERR_MODULE_NOT_FOUND(pkgPath, base, 'package'); + }, + getFormatOfExtensionlessFile(filePath) { + try { + const result = findVFSForRead(filePath, null); + if (result === null) return undefined; + const content = result.content; + // Wasm magic bytes: 0x00 0x61 0x73 0x6d + if (content && content.length >= 4 && + content[0] === 0x00 && content[1] === 0x61 && + content[2] === 0x73 && content[3] === 0x6d) { + return 1; // EXTENSIONLESS_FORMAT_WASM + } + return 0; // EXTENSIONLESS_FORMAT_JAVASCRIPT + } catch { + return 0; + } + }, + }); + + setLoaderPackageOverrides({ + readPackageJSON(jsonPath, isESM, base, specifier) { + const normalized = resolve(jsonPath); + for (let i = 0; i < activeVFSList.length; i++) { + const vfs = activeVFSList[i]; + if (!vfs.shouldHandle(normalized)) continue; + if (vfsStat(vfs, normalized) !== 0) return undefined; + try { + const content = vfs.readFileSync(normalized, 'utf8'); + const parsed = JSONParse(content); + return serializePackageJSON(parsed, normalized); + } catch (err) { + if (err?.name === 'SyntaxError' && isESM) { + throw new ERR_INVALID_PACKAGE_CONFIG(normalized, base, err.message); + } + return undefined; + } + } + return nativeModulesBinding.readPackageJSON(jsonPath, isESM, base, specifier); + }, + getNearestParentPackageJSON(checkPath) { + const normalized = resolve(checkPath); + for (let i = 0; i < activeVFSList.length; i++) { + if (activeVFSList[i].shouldHandle(normalized)) { + const found = findVFSPackageJSON(normalized); + if (found !== null) { + return serializePackageJSON(found.parsed, found.pjsonPath); + } + return undefined; + } + } + return nativeModulesBinding.getNearestParentPackageJSON(checkPath); + }, + getPackageScopeConfig(resolved) { + let filePath; + if (StringPrototypeStartsWith(resolved, 'file:')) { + try { + filePath = fileURLToPath(resolved); + } catch { + return nativeModulesBinding.getPackageScopeConfig(resolved); + } + } else { + filePath = resolved; + } + const normalized = resolve(filePath); + for (let i = 0; i < activeVFSList.length; i++) { + if (activeVFSList[i].shouldHandle(normalized)) { + const found = findVFSPackageJSON(normalized); + if (found !== null) { + return serializePackageJSON(found.parsed, found.pjsonPath); + } + return resolve(dirname(normalized), 'package.json'); + } + } + return nativeModulesBinding.getPackageScopeConfig(resolved); + }, + getPackageType(url) { + let filePath; + if (StringPrototypeStartsWith(url, 'file:')) { + try { + filePath = fileURLToPath(url); + } catch { + return nativeModulesBinding.getPackageType(url); + } + } else { + filePath = url; + } + const normalized = resolve(filePath); + for (let i = 0; i < activeVFSList.length; i++) { + if (activeVFSList[i].shouldHandle(normalized)) { + const found = findVFSPackageJSON(normalized); + if (found !== null) { + const type = found.parsed.type; + if (type === 'module' || type === 'commonjs') return type; + } + return undefined; + } + } + return nativeModulesBinding.getPackageType(url); + }, + }); +} + +/** + * Install all VFS hooks: module loader overrides and fs handlers. + */ +function installHooks() { + if (hooksInstalled) return; + debug('install hooks'); + installModuleLoaderOverrides(); + vfsHandlerObj = createVfsHandlers(); + setVfsHandlers(vfsHandlerObj); + hooksInstalled = true; +} + module.exports = { registerVFS, deregisterVFS, diff --git a/test/parallel/test-vfs-import.mjs b/test/parallel/test-vfs-import.mjs new file mode 100644 index 00000000000000..9b5a661d1a4b0d --- /dev/null +++ b/test/parallel/test-vfs-import.mjs @@ -0,0 +1,142 @@ +// Flags: --experimental-vfs +import '../common/index.mjs'; +import assert from 'assert'; +import vfs from 'node:vfs'; + +// NOTE: Each test uses a unique mount path because ESM imports are cached +// by URL — unmounting does not clear the V8 module cache, so reusing a +// mount path would return stale cached modules from earlier tests. + +// Test importing a simple virtual ES module +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/hello.mjs', 'export const message = "hello from vfs";'); + myVfs.mount('/esm-named'); + + const { message } = await import('/esm-named/hello.mjs'); + assert.strictEqual(message, 'hello from vfs'); + + myVfs.unmount(); +} + +// Test importing a virtual module with default export +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/default.mjs', 'export default { name: "test", value: 42 };'); + myVfs.mount('/esm-default'); + + const mod = await import('/esm-default/default.mjs'); + assert.strictEqual(mod.default.name, 'test'); + assert.strictEqual(mod.default.value, 42); + + myVfs.unmount(); +} + +// Test importing a virtual module that imports another virtual module +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/utils.mjs', 'export function add(a, b) { return a + b; }'); + myVfs.writeFileSync('/main.mjs', ` + import { add } from '/esm-chain/utils.mjs'; + export const result = add(10, 20); + `); + myVfs.mount('/esm-chain'); + + const { result } = await import('/esm-chain/main.mjs'); + assert.strictEqual(result, 30); + + myVfs.unmount(); +} + +// Test importing with relative paths +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/lib', { recursive: true }); + myVfs.writeFileSync('/lib/helper.mjs', 'export const helper = () => "helped";'); + myVfs.writeFileSync('/lib/index.mjs', ` + import { helper } from './helper.mjs'; + export const output = helper(); + `); + myVfs.mount('/esm-relative'); + + const { output } = await import('/esm-relative/lib/index.mjs'); + assert.strictEqual(output, 'helped'); + + myVfs.unmount(); +} + +// Test importing JSON from VFS (with import assertion) +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/data.json', JSON.stringify({ items: [1, 2, 3], enabled: true })); + myVfs.mount('/esm-json'); + + const data = await import('/esm-json/data.json', { with: { type: 'json' } }); + assert.deepStrictEqual(data.default.items, [1, 2, 3]); + assert.strictEqual(data.default.enabled, true); + + myVfs.unmount(); +} + +// Test that real modules still work when VFS is mounted +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/test.mjs', 'export const x = 1;'); + myVfs.mount('/esm-builtin'); + + // Import from node: should still work + const assertMod = await import('node:assert'); + assert.strictEqual(typeof assertMod.strictEqual, 'function'); + + myVfs.unmount(); +} + +// Test mixed CJS and ESM - ESM importing from VFS while CJS also works +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/esm-module.mjs', 'export const esmValue = "esm";'); + myVfs.writeFileSync('/cjs-module.js', 'module.exports = { cjsValue: "cjs" };'); + myVfs.mount('/esm-mixed'); + + const { esmValue } = await import('/esm-mixed/esm-module.mjs'); + assert.strictEqual(esmValue, 'esm'); + + // CJS require should also work (via createRequire) + const { createRequire } = await import('module'); + const require = createRequire(import.meta.url); + const { cjsValue } = require('/esm-mixed/cjs-module.js'); + assert.strictEqual(cjsValue, 'cjs'); + + myVfs.unmount(); +} + +// Test ESM bare specifier resolution from VFS node_modules. +// This sets up a proper node_modules structure inside VFS and imports +// using a bare specifier (e.g., import 'my-vfs-pkg') instead of an +// absolute path. This exercises the ESM default resolver's +// internalModuleStat and getPackageJSONURL code paths. +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/my-vfs-pkg', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/my-vfs-pkg/package.json', JSON.stringify({ + name: 'my-vfs-pkg', + type: 'module', + exports: { '.': './index.mjs' }, + })); + myVfs.writeFileSync( + '/app/node_modules/my-vfs-pkg/index.mjs', + 'export const fromVfs = true;', + ); + // The importing module must also live inside the VFS mount so that + // node_modules resolution walks upward from a VFS path. + myVfs.writeFileSync( + '/app/entry.mjs', + "export { fromVfs } from 'my-vfs-pkg';", + ); + myVfs.mount('/esm-bare'); + + const { fromVfs } = await import('/esm-bare/app/entry.mjs'); + assert.strictEqual(fromVfs, true); + + myVfs.unmount(); +} diff --git a/test/parallel/test-vfs-invalid-package-json.js b/test/parallel/test-vfs-invalid-package-json.js new file mode 100644 index 00000000000000..89c9bc23cb71d1 --- /dev/null +++ b/test/parallel/test-vfs-invalid-package-json.js @@ -0,0 +1,45 @@ +// Flags: --experimental-vfs --expose-internals +'use strict'; + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// Test that invalid package.json in VFS falls through to index.js +// (bad JSON is skipped, like a missing package.json). +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/pkg', { recursive: true }); + myVfs.writeFileSync('/pkg/package.json', '{ invalid json'); + myVfs.writeFileSync('/pkg/index.js', 'module.exports = 42;'); + myVfs.mount('/mnt'); + + assert.strictEqual(require('/mnt/pkg'), 42); + + myVfs.unmount(); +} + +// Test that valid package.json still works +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/pkg2', { recursive: true }); + myVfs.writeFileSync('/pkg2/package.json', '{"main": "lib.js"}'); + myVfs.writeFileSync('/pkg2/lib.js', 'module.exports = 99;'); + myVfs.mount('/mnt2'); + + assert.strictEqual(require('/mnt2/pkg2'), 99); + + myVfs.unmount(); +} + +// Test that missing package.json (ENOENT) still falls through to index.js +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/pkg3', { recursive: true }); + myVfs.writeFileSync('/pkg3/index.js', 'module.exports = 77;'); + myVfs.mount('/mnt3'); + + assert.strictEqual(require('/mnt3/pkg3'), 77); + + myVfs.unmount(); +} diff --git a/test/parallel/test-vfs-module-hooks.mjs b/test/parallel/test-vfs-module-hooks.mjs new file mode 100644 index 00000000000000..2e511f9bac8e09 --- /dev/null +++ b/test/parallel/test-vfs-module-hooks.mjs @@ -0,0 +1,725 @@ +// Flags: --experimental-vfs +import '../common/index.mjs'; +import assert from 'assert'; +import { createRequire } from 'module'; +import vfs from 'node:vfs'; + +const require = createRequire(import.meta.url); + +// NOTE: Each test uses a different mount path (/mh1, /mh2, etc.) +// because ESM imports are cached by URL. + +// ================================================================= +// Test: CJS bare specifier resolution with exports string shorthand +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/str-pkg', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/str-pkg/package.json', JSON.stringify({ + name: 'str-pkg', + exports: './main.js', + })); + myVfs.writeFileSync( + '/app/node_modules/str-pkg/main.js', + 'module.exports = { strExport: true };', + ); + myVfs.writeFileSync( + '/app/entry.js', + "module.exports = require('str-pkg');", + ); + myVfs.mount('/mh1'); + + const result = require('/mh1/app/entry.js'); + assert.strictEqual(result.strExport, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Conditional exports with import/require/default conditions +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/cond-pkg', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/cond-pkg/package.json', JSON.stringify({ + name: 'cond-pkg', + exports: { + import: './esm.mjs', + require: './cjs.js', + default: './default.js', + }, + })); + myVfs.writeFileSync( + '/app/node_modules/cond-pkg/esm.mjs', + 'export const source = "esm";', + ); + myVfs.writeFileSync( + '/app/node_modules/cond-pkg/cjs.js', + 'module.exports = { source: "cjs" };', + ); + myVfs.writeFileSync( + '/app/node_modules/cond-pkg/default.js', + 'module.exports = { source: "default" };', + ); + // ESM entry that imports via bare specifier + myVfs.writeFileSync( + '/app/esm-entry.mjs', + "export { source } from 'cond-pkg';", + ); + // CJS entry that requires via bare specifier + myVfs.writeFileSync( + '/app/cjs-entry.js', + "module.exports = require('cond-pkg');", + ); + myVfs.mount('/mh2'); + + // ESM import should get the 'import' condition + const esmResult = await import('/mh2/app/esm-entry.mjs'); + assert.strictEqual(esmResult.source, 'esm'); + + // CJS require should get the 'require' condition + const cjsResult = require('/mh2/app/cjs-entry.js'); + assert.strictEqual(cjsResult.source, 'cjs'); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Subpath exports map +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/sub-pkg/lib', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/sub-pkg/package.json', JSON.stringify({ + name: 'sub-pkg', + exports: { + '.': './lib/index.mjs', + './feature': './lib/feature.mjs', + }, + })); + myVfs.writeFileSync( + '/app/node_modules/sub-pkg/lib/index.mjs', + 'export const main = true;', + ); + myVfs.writeFileSync( + '/app/node_modules/sub-pkg/lib/feature.mjs', + 'export const feature = true;', + ); + myVfs.writeFileSync( + '/app/entry.mjs', + ` + import { main } from 'sub-pkg'; + import { feature } from 'sub-pkg/feature'; + export { main, feature }; + `, + ); + myVfs.mount('/mh3'); + + const result = await import('/mh3/app/entry.mjs'); + assert.strictEqual(result.main, true); + assert.strictEqual(result.feature, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Subpath exports with conditional object target +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/sub-cond-pkg', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/sub-cond-pkg/package.json', JSON.stringify({ + name: 'sub-cond-pkg', + exports: { + '.': { + import: './esm.mjs', + require: './cjs.js', + }, + }, + })); + myVfs.writeFileSync( + '/app/node_modules/sub-cond-pkg/esm.mjs', + 'export const fromSubCond = "esm";', + ); + myVfs.writeFileSync( + '/app/node_modules/sub-cond-pkg/cjs.js', + 'module.exports = { fromSubCond: "cjs" };', + ); + myVfs.writeFileSync( + '/app/entry2.mjs', + "export { fromSubCond } from 'sub-cond-pkg';", + ); + myVfs.mount('/mh4'); + + const result = await import('/mh4/app/entry2.mjs'); + assert.strictEqual(result.fromSubCond, 'esm'); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Nested conditional exports (e.g. node → import/require) +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/nested-cond-pkg', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/nested-cond-pkg/package.json', JSON.stringify({ + name: 'nested-cond-pkg', + exports: { + node: { + import: './node-esm.mjs', + require: './node-cjs.js', + }, + default: './fallback.js', + }, + })); + myVfs.writeFileSync( + '/app/node_modules/nested-cond-pkg/node-esm.mjs', + 'export const nested = "node-esm";', + ); + myVfs.writeFileSync( + '/app/node_modules/nested-cond-pkg/node-cjs.js', + 'module.exports = { nested: "node-cjs" };', + ); + myVfs.writeFileSync( + '/app/node_modules/nested-cond-pkg/fallback.js', + 'module.exports = { nested: "fallback" };', + ); + myVfs.writeFileSync( + '/app/entry3.mjs', + "export { nested } from 'nested-cond-pkg';", + ); + myVfs.mount('/mh5'); + + const result = await import('/mh5/app/entry3.mjs'); + assert.strictEqual(result.nested, 'node-esm'); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Package without exports, using main field +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/main-pkg', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/main-pkg/package.json', JSON.stringify({ + name: 'main-pkg', + main: './lib/entry.js', + })); + myVfs.mkdirSync('/app/node_modules/main-pkg/lib', { recursive: true }); + myVfs.writeFileSync( + '/app/node_modules/main-pkg/lib/entry.js', + 'module.exports = { fromMain: true };', + ); + myVfs.writeFileSync( + '/app/entry4.js', + "module.exports = require('main-pkg');", + ); + myVfs.mount('/mh6'); + + const result = require('/mh6/app/entry4.js'); + assert.strictEqual(result.fromMain, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Package without exports/main, fallback to index.js +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/index-pkg', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/index-pkg/package.json', JSON.stringify({ + name: 'index-pkg', + })); + myVfs.writeFileSync( + '/app/node_modules/index-pkg/index.js', + 'module.exports = { fromIndex: true };', + ); + myVfs.writeFileSync( + '/app/entry5.js', + "module.exports = require('index-pkg');", + ); + myVfs.mount('/mh7'); + + const result = require('/mh7/app/entry5.js'); + assert.strictEqual(result.fromIndex, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Extension resolution (require without file extension) +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/lib', { recursive: true }); + // File without .js extension in specifier + myVfs.writeFileSync('/lib/utils.js', 'module.exports = { ext: "js" };'); + myVfs.writeFileSync( + '/lib/main.js', + "module.exports = require('/mh8/lib/utils');", + ); + myVfs.mount('/mh8'); + + const result = require('/mh8/lib/main.js'); + assert.strictEqual(result.ext, 'js'); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Extension resolution with .json +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/data.json', JSON.stringify({ ext: 'json' })); + myVfs.writeFileSync( + '/reader.js', + "module.exports = require('/mh9/data');", + ); + myVfs.mount('/mh9'); + + const result = require('/mh9/reader.js'); + assert.strictEqual(result.ext, 'json'); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Scoped package resolution (@scope/pkg) +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/@myorg/mylib', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/@myorg/mylib/package.json', JSON.stringify({ + name: '@myorg/mylib', + type: 'module', + exports: './index.mjs', + })); + myVfs.writeFileSync( + '/app/node_modules/@myorg/mylib/index.mjs', + 'export const scoped = true;', + ); + myVfs.writeFileSync( + '/app/scoped-entry.mjs', + "export { scoped } from '@myorg/mylib';", + ); + myVfs.mount('/mh11'); + + const result = await import('/mh11/app/scoped-entry.mjs'); + assert.strictEqual(result.scoped, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Scoped package with subpath +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/@myorg/utils/lib', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/@myorg/utils/package.json', JSON.stringify({ + name: '@myorg/utils', + exports: { + '.': './lib/index.mjs', + './helpers': './lib/helpers.mjs', + }, + })); + myVfs.writeFileSync( + '/app/node_modules/@myorg/utils/lib/index.mjs', + 'export const main = true;', + ); + myVfs.writeFileSync( + '/app/node_modules/@myorg/utils/lib/helpers.mjs', + 'export const helpers = true;', + ); + myVfs.writeFileSync( + '/app/scoped-sub-entry.mjs', + ` + import { helpers } from '@myorg/utils/helpers'; + export { helpers }; + `, + ); + myVfs.mount('/mh12'); + + const result = await import('/mh12/app/scoped-sub-entry.mjs'); + assert.strictEqual(result.helpers, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: .js file with type: module in package.json → ESM format +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/package.json', JSON.stringify({ type: 'module' })); + myVfs.writeFileSync('/mod.js', 'export const fromModule = true;'); + myVfs.mount('/mh13'); + + const result = await import('/mh13/mod.js'); + assert.strictEqual(result.fromModule, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: .cjs always treated as CommonJS regardless of package type +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/package.json', JSON.stringify({ type: 'module' })); + myVfs.writeFileSync('/helper.cjs', 'module.exports = { cjsAlways: true };'); + myVfs.writeFileSync( + '/use-cjs.js', + ` + import { createRequire } from 'module'; + const require = createRequire(import.meta.url); + const result = require('/mh14/helper.cjs'); + export const cjsAlways = result.cjsAlways; + `, + ); + myVfs.mount('/mh14'); + + const result = await import('/mh14/use-cjs.js'); + assert.strictEqual(result.cjsAlways, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: file: URL specifier +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/fileurl.mjs', 'export const fromFileUrl = true;'); + myVfs.mount('/mh15'); + + const result = await import('file:///mh15/fileurl.mjs'); + assert.strictEqual(result.fromFileUrl, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Package with main field requiring extension resolution +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/dir-pkg', { recursive: true }); + myVfs.writeFileSync('/dir-pkg/package.json', JSON.stringify({ + name: 'dir-pkg', + main: './entry', + })); + myVfs.writeFileSync( + '/dir-pkg/entry.js', + 'module.exports = { dirPkg: true };', + ); + myVfs.mount('/mh16'); + + // Main field has no extension - tryExtensions should resolve entry → entry.js + const result = require('/mh16/dir-pkg'); + assert.strictEqual(result.dirPkg, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Bare specifier with package subpath (no exports, direct file) +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/direct-pkg/lib', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/direct-pkg/package.json', JSON.stringify({ + name: 'direct-pkg', + })); + myVfs.writeFileSync( + '/app/node_modules/direct-pkg/lib/util.js', + 'module.exports = { directSub: true };', + ); + myVfs.writeFileSync( + '/app/entry-sub.js', + "module.exports = require('direct-pkg/lib/util.js');", + ); + myVfs.mount('/mh17'); + + const result = require('/mh17/app/entry-sub.js'); + assert.strictEqual(result.directSub, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Bare specifier subpath with extension resolution +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/ext-pkg/lib', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/ext-pkg/package.json', JSON.stringify({ + name: 'ext-pkg', + })); + myVfs.writeFileSync( + '/app/node_modules/ext-pkg/lib/util.js', + 'module.exports = { extSub: true };', + ); + myVfs.writeFileSync( + '/app/entry-ext.js', + // No .js extension - should be resolved by tryExtensions + "module.exports = require('ext-pkg/lib/util');", + ); + myVfs.mount('/mh18'); + + const result = require('/mh18/app/entry-ext.js'); + assert.strictEqual(result.extSub, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Bare specifier main field with extension resolution +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/main-ext-pkg', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/main-ext-pkg/package.json', JSON.stringify({ + name: 'main-ext-pkg', + main: './entry', + })); + myVfs.writeFileSync( + '/app/node_modules/main-ext-pkg/entry.js', + 'module.exports = { mainExt: true };', + ); + myVfs.writeFileSync( + '/app/entry-main-ext.js', + "module.exports = require('main-ext-pkg');", + ); + myVfs.mount('/mh19'); + + const result = require('/mh19/app/entry-main-ext.js'); + assert.strictEqual(result.mainExt, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: exports with array value (fallback array) +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/arr-exp-pkg', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/arr-exp-pkg/package.json', JSON.stringify({ + name: 'arr-exp-pkg', + exports: { + '.': ['./index.js', './fallback.js'], + }, + main: './index.js', + })); + myVfs.writeFileSync( + '/app/node_modules/arr-exp-pkg/index.js', + 'module.exports = { arrExport: true };', + ); + myVfs.writeFileSync( + '/app/entry-arr.js', + "module.exports = require('arr-exp-pkg');", + ); + myVfs.mount('/mh22'); + + // Array target in exports: canonical resolver tries each entry in order + const result = require('/mh22/app/entry-arr.js'); + assert.strictEqual(result.arrExport, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Exports with "default" condition +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/default-pkg', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/default-pkg/package.json', JSON.stringify({ + name: 'default-pkg', + exports: { + '.': { + browser: './browser.js', + default: './default.mjs', + }, + }, + })); + myVfs.writeFileSync( + '/app/node_modules/default-pkg/default.mjs', + 'export const fromDefault = true;', + ); + myVfs.writeFileSync( + '/app/entry-default.mjs', + "export { fromDefault } from 'default-pkg';", + ); + myVfs.mount('/mh23'); + + // 'browser' condition not active in Node, 'default' should match + const result = await import('/mh23/app/entry-default.mjs'); + assert.strictEqual(result.fromDefault, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Package.json type "commonjs" explicitly set for .js +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/package.json', JSON.stringify({ type: 'commonjs' })); + myVfs.writeFileSync('/explicit-cjs.js', 'module.exports = { explicitCjs: true };'); + myVfs.mount('/mh24'); + + const result = require('/mh24/explicit-cjs.js'); + assert.strictEqual(result.explicitCjs, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: .js file with no package.json → defaults to commonjs +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/no-pkg.js', 'module.exports = { noPkg: true };'); + myVfs.mount('/mh25'); + + const result = require('/mh25/no-pkg.js'); + assert.strictEqual(result.noPkg, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Package.json type walk stops at node_modules boundary +// ================================================================= +{ + const myVfs = vfs.create(); + // Root has type: module + myVfs.writeFileSync('/package.json', JSON.stringify({ type: 'module' })); + // But file is inside node_modules with no local package.json + myVfs.mkdirSync('/node_modules/inner', { recursive: true }); + myVfs.writeFileSync( + '/node_modules/inner/index.js', + 'module.exports = { inner: true };', + ); + myVfs.mount('/mh26'); + + // The walk should stop at node_modules, not inherit type:module from root + const result = require('/mh26/node_modules/inner/index.js'); + assert.strictEqual(result.inner, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Invalid package.json in directory resolution +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/bad-json-dir', { recursive: true }); + myVfs.writeFileSync('/bad-json-dir/package.json', '{ invalid json }'); + myVfs.writeFileSync( + '/bad-json-dir/index.js', + 'module.exports = { fallbackIndex: true };', + ); + myVfs.mount('/mh28'); + + // Should fall through to index.js after failing to parse package.json + const result = require('/mh28/bad-json-dir'); + assert.strictEqual(result.fallbackIndex, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Invalid package.json in type walk-up +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/package.json', '{ broken json }'); + myVfs.writeFileSync('/no-type.js', 'module.exports = { noType: true };'); + myVfs.mount('/mh29'); + + // Should treat as 'none' (commonjs) since package.json is invalid + const result = require('/mh29/no-type.js'); + assert.strictEqual(result.noType, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: Scoped package without slash (just @scope/name) +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/@solo/pkg', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/@solo/pkg/package.json', JSON.stringify({ + name: '@solo/pkg', + main: './index.js', + })); + myVfs.writeFileSync( + '/app/node_modules/@solo/pkg/index.js', + 'module.exports = { solo: true };', + ); + myVfs.writeFileSync( + '/app/entry-solo.js', + "module.exports = require('@solo/pkg');", + ); + myVfs.mount('/mh30'); + + const result = require('/mh30/app/entry-solo.js'); + assert.strictEqual(result.solo, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: node: builtin passthrough +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/use-builtin.mjs', ` + import path from 'node:path'; + export const sep = path.sep; + `); + myVfs.mount('/mh31'); + + const result = await import('/mh31/use-builtin.mjs'); + assert.strictEqual(typeof result.sep, 'string'); + + myVfs.unmount(); +} + +// ================================================================= +// Test: JSON import with type assertion +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/data.json', JSON.stringify({ preformat: true })); + myVfs.mount('/mh32'); + + const result = await import('/mh32/data.json', { with: { type: 'json' } }); + assert.strictEqual(result.default.preformat, true); + + myVfs.unmount(); +} + +// ================================================================= +// Test: File with unknown extension → defaults to commonjs +// ================================================================= +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/data.txt', 'module.exports = { txt: true };'); + myVfs.mount('/mh33'); + + // .txt extension → falls back to 'commonjs' via VFS_FORMAT_MAP default + const result = require('/mh33/data.txt'); + assert.strictEqual(result.txt, true); + + myVfs.unmount(); +} diff --git a/test/parallel/test-vfs-package-json-cache.js b/test/parallel/test-vfs-package-json-cache.js new file mode 100644 index 00000000000000..0c8c61e1facdc8 --- /dev/null +++ b/test/parallel/test-vfs-package-json-cache.js @@ -0,0 +1,23 @@ +// Flags: --experimental-vfs +'use strict'; + +// Package.json caches must be cleared on VFS unmount. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.mkdirSync('/pkg'); +myVfs.writeFileSync('/pkg/package.json', '{"name":"test","type":"module"}'); +myVfs.writeFileSync('/pkg/index.js', 'module.exports = 42'); +myVfs.mount('/mnt_pjcache'); + +// Access the file so caches are populated +assert.ok(fs.existsSync('/mnt_pjcache/pkg/package.json')); + +// After unmount, cache should be cleared (no stale entries) +myVfs.unmount(); + +assert.strictEqual(fs.existsSync('/mnt_pjcache/pkg/package.json'), false); diff --git a/test/parallel/test-vfs-package-json.js b/test/parallel/test-vfs-package-json.js new file mode 100644 index 00000000000000..c9ffe063e99d12 --- /dev/null +++ b/test/parallel/test-vfs-package-json.js @@ -0,0 +1,184 @@ +// Flags: --experimental-vfs --expose-internals +'use strict'; + +require('../common'); +const assert = require('assert'); +const path = require('path'); +const vfs = require('node:vfs'); + +// Test 1: read() reads package.json from VFS +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/pkg', { recursive: true }); + myVfs.writeFileSync('/pkg/package.json', JSON.stringify({ + name: 'test-pkg', + type: 'module', + main: './index.js', + exports: { '.': './index.js' }, + })); + myVfs.mount('/mnt/read-test'); + + const packageJsonReader = require('internal/modules/package_json_reader'); + const result = packageJsonReader.read( + path.resolve('/mnt/read-test/pkg/package.json'), {}, + ); + + assert.strictEqual(result.exists, true); + assert.strictEqual(result.name, 'test-pkg'); + assert.strictEqual(result.type, 'module'); + assert.strictEqual(result.main, './index.js'); + assert.deepStrictEqual(result.exports, { '.': './index.js' }); + assert.strictEqual( + result.pjsonPath, + path.resolve('/mnt/read-test/pkg/package.json'), + ); + + // Non-existent package.json returns exists: false + const missing = packageJsonReader.read( + path.resolve('/mnt/read-test/nope/package.json'), {}, + ); + assert.strictEqual(missing.exists, false); + + myVfs.unmount(); +} + +// Test 2: getNearestParentPackageJSON() walks up VFS directories +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/src/lib/deep', { recursive: true }); + myVfs.writeFileSync('/app/package.json', JSON.stringify({ + name: 'my-app', + type: 'module', + })); + myVfs.writeFileSync('/app/src/lib/deep/module.js', ''); + myVfs.mount('/mnt/parent-test'); + + const packageJsonReader = require('internal/modules/package_json_reader'); + const result = packageJsonReader.getNearestParentPackageJSON( + path.resolve('/mnt/parent-test/app/src/lib/deep/module.js'), + ); + + assert.ok(result); + assert.strictEqual(result.exists, true); + assert.strictEqual(result.data.name, 'my-app'); + assert.strictEqual(result.data.type, 'module'); + assert.strictEqual( + result.path, + path.resolve('/mnt/parent-test/app/package.json'), + ); + + myVfs.unmount(); +} + +// Test 3: getPackageScopeConfig() returns correct scope from VFS +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/project/src', { recursive: true }); + myVfs.writeFileSync('/project/package.json', JSON.stringify({ + name: 'my-project', + type: 'commonjs', + exports: { '.': './main.js' }, + })); + myVfs.writeFileSync('/project/src/index.js', ''); + myVfs.mount('/mnt/scope-test'); + + const packageJsonReader = require('internal/modules/package_json_reader'); + const { pathToFileURL } = require('url'); + const scopeUrl = pathToFileURL( + path.resolve('/mnt/scope-test/project/src/index.js'), + ).href; + const result = packageJsonReader.getPackageScopeConfig(scopeUrl); + + assert.strictEqual(result.exists, true); + assert.strictEqual(result.type, 'commonjs'); + assert.strictEqual(result.name, 'my-project'); + assert.strictEqual( + result.pjsonPath, + path.resolve('/mnt/scope-test/project/package.json'), + ); + + // Path with no package.json returns exists: false + const myVfs2 = vfs.create(); + myVfs2.mkdirSync('/empty/src', { recursive: true }); + myVfs2.writeFileSync('/empty/src/file.js', ''); + myVfs2.mount('/mnt/scope-empty'); + + const emptyUrl = pathToFileURL( + path.resolve('/mnt/scope-empty/empty/src/file.js'), + ).href; + const emptyResult = packageJsonReader.getPackageScopeConfig(emptyUrl); + assert.strictEqual(emptyResult.exists, false); + + myVfs2.unmount(); + myVfs.unmount(); +} + +// Test 4: getPackageType() returns correct type from VFS +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/esm-app', { recursive: true }); + myVfs.writeFileSync('/esm-app/package.json', JSON.stringify({ + type: 'module', + })); + myVfs.writeFileSync('/esm-app/index.js', ''); + myVfs.mount('/mnt/type-test'); + + const packageJsonReader = require('internal/modules/package_json_reader'); + const { pathToFileURL } = require('url'); + const typeUrl = pathToFileURL( + path.resolve('/mnt/type-test/esm-app/index.js'), + ).href; + const type = packageJsonReader.getPackageType(typeUrl); + assert.strictEqual(type, 'module'); + + // No package.json => 'none' + const myVfs2 = vfs.create(); + myVfs2.mkdirSync('/bare', { recursive: true }); + myVfs2.writeFileSync('/bare/file.js', ''); + myVfs2.mount('/mnt/type-empty'); + + const noneUrl = pathToFileURL( + path.resolve('/mnt/type-empty/bare/file.js'), + ).href; + const noneType = packageJsonReader.getPackageType(noneUrl); + assert.strictEqual(noneType, 'none'); + + myVfs2.unmount(); + myVfs.unmount(); +} + +// Test 5: End-to-end CJS require with package.json type detection +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/cjs-app', { recursive: true }); + myVfs.writeFileSync('/cjs-app/package.json', JSON.stringify({ + name: 'cjs-app', + type: 'commonjs', + })); + myVfs.writeFileSync('/cjs-app/main.js', + 'module.exports = { format: "cjs", ok: true };'); + myVfs.mount('/mnt/e2e-cjs'); + + const result = require('/mnt/e2e-cjs/cjs-app/main.js'); + assert.strictEqual(result.format, 'cjs'); + assert.strictEqual(result.ok, true); + + myVfs.unmount(); +} + +// Test 6: End-to-end ESM import with VFS package type +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/esm', { recursive: true }); + myVfs.writeFileSync('/esm/package.json', JSON.stringify({ + type: 'module', + })); + myVfs.writeFileSync('/esm/mod.mjs', 'export const x = 42;'); + myVfs.mount('/mnt/e2e-esm'); + + // Use .mjs to ensure ESM treatment regardless of package type + const mod = require('/mnt/e2e-esm/esm/mod.mjs'); + assert.strictEqual(mod.x, 42); + + myVfs.unmount(); +} diff --git a/test/parallel/test-vfs-require.js b/test/parallel/test-vfs-require.js new file mode 100644 index 00000000000000..8c6c49c73ada5d --- /dev/null +++ b/test/parallel/test-vfs-require.js @@ -0,0 +1,405 @@ +// Flags: --experimental-vfs +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +// Test requiring a simple virtual module +// VFS internal path: /hello.js +// Mount point: /virtual +// External path: /virtual/hello.js +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/hello.js', 'module.exports = "hello from vfs";'); + myVfs.mount('/virtual'); + + const result = require('/virtual/hello.js'); + assert.strictEqual(result, 'hello from vfs'); + + myVfs.unmount(); +} + +// Test requiring a virtual module that exports an object +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/config.js', ` + module.exports = { + name: 'test-config', + version: '1.0.0', + getValue: function() { return 42; } + }; + `); + myVfs.mount('/virtual2'); + + const config = require('/virtual2/config.js'); + assert.strictEqual(config.name, 'test-config'); + assert.strictEqual(config.version, '1.0.0'); + assert.strictEqual(config.getValue(), 42); + + myVfs.unmount(); +} + +// Test requiring a virtual module that requires another virtual module +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/utils.js', ` + module.exports = { + add: function(a, b) { return a + b; } + }; + `); + myVfs.writeFileSync('/main.js', ` + const utils = require('/virtual3/utils.js'); + module.exports = { + sum: utils.add(10, 20) + }; + `); + myVfs.mount('/virtual3'); + + const main = require('/virtual3/main.js'); + assert.strictEqual(main.sum, 30); + + myVfs.unmount(); +} + +// Test requiring a JSON file from VFS +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/data.json', JSON.stringify({ + items: [1, 2, 3], + enabled: true, + })); + myVfs.mount('/virtual4'); + + const data = require('/virtual4/data.json'); + assert.deepStrictEqual(data.items, [1, 2, 3]); + assert.strictEqual(data.enabled, true); + + myVfs.unmount(); +} + +// Test virtual package.json resolution +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/my-package', { recursive: true }); + myVfs.writeFileSync('/my-package/package.json', JSON.stringify({ + name: 'my-package', + main: 'index.js', + })); + myVfs.writeFileSync('/my-package/index.js', ` + module.exports = { loaded: true }; + `); + myVfs.mount('/virtual5'); + + const pkg = require('/virtual5/my-package'); + assert.strictEqual(pkg.loaded, true); + + myVfs.unmount(); +} + +// Test that real modules still work when VFS is mounted +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/test.js', 'module.exports = 1;'); + myVfs.mount('/virtual6'); + + // require('assert') should still work (builtin) + assert.strictEqual(typeof assert.strictEqual, 'function'); + + // Real file requires should still work + const commonMod = require('../common'); + assert.ok(commonMod); + + myVfs.unmount(); +} + +// Test require with relative paths inside VFS module +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/lib', { recursive: true }); + myVfs.writeFileSync('/lib/helper.js', ` + module.exports = { help: function() { return 'helped'; } }; + `); + myVfs.writeFileSync('/lib/index.js', ` + const helper = require('./helper.js'); + module.exports = helper.help(); + `); + myVfs.mount('/virtual8'); + + const result = require('/virtual8/lib/index.js'); + assert.strictEqual(result, 'helped'); + + myVfs.unmount(); +} + +// Test fs.readFileSync interception when VFS is active +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'virtual content'); + myVfs.mount('/virtual9'); + + const content = fs.readFileSync('/virtual9/file.txt', 'utf8'); + assert.strictEqual(content, 'virtual content'); + + myVfs.unmount(); +} + +// Test requiring an ESM .mjs module from VFS +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/esm.mjs', 'export const msg = "hello from esm";'); + myVfs.mount('/virtual11'); + + const mod = require('/virtual11/esm.mjs'); + assert.strictEqual(mod.msg, 'hello from esm'); + + myVfs.unmount(); +} + +// Test requiring an ESM .mjs module with default export from VFS +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/esm-default.mjs', 'export default function() { return 42; }'); + myVfs.mount('/virtual12'); + + const mod = require('/virtual12/esm-default.mjs'); + assert.strictEqual(mod.default(), 42); + + myVfs.unmount(); +} + +// Test require(esm): .js file detected as ESM via VFS package.json type:module +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app', { recursive: true }); + myVfs.writeFileSync('/app/package.json', JSON.stringify({ + name: 'esm-app', + type: 'module', + })); + myVfs.writeFileSync('/app/lib.js', + 'export const value = 42;' + + ' export function hello() { return "hi"; }'); + myVfs.mount('/virtual13'); + + const mod = require('/virtual13/app/lib.js'); + assert.strictEqual(mod.value, 42); + assert.strictEqual(mod.hello(), 'hi'); + + myVfs.unmount(); +} + +// Test require(esm): nested .js walks up to parent package.json in VFS +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/project/src/utils', { recursive: true }); + myVfs.writeFileSync('/project/package.json', JSON.stringify({ + name: 'nested-esm', + type: 'module', + })); + myVfs.writeFileSync('/project/src/utils/math.js', + 'export const add = (a, b) => a + b;' + + ' export default 99;'); + myVfs.mount('/virtual14'); + + const mod = require('/virtual14/project/src/utils/math.js'); + assert.strictEqual(mod.add(3, 4), 7); + assert.strictEqual(mod.default, 99); + + myVfs.unmount(); +} + +// Test require(esm): .js without type field stays CJS +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/cjs-app', { recursive: true }); + myVfs.writeFileSync('/cjs-app/package.json', JSON.stringify({ + name: 'cjs-app', + })); + myVfs.writeFileSync('/cjs-app/index.js', + 'module.exports = { cjs: true };'); + myVfs.mount('/virtual15'); + + const mod = require('/virtual15/cjs-app/index.js'); + assert.strictEqual(mod.cjs, true); + + myVfs.unmount(); +} + +// Test require(esm): ESM .js that imports another ESM .js in VFS +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/multi/src', { recursive: true }); + myVfs.writeFileSync('/multi/package.json', JSON.stringify({ + type: 'module', + })); + myVfs.writeFileSync('/multi/src/dep.js', 'export const X = 100;'); + myVfs.writeFileSync('/multi/src/main.js', + 'import { X } from "./dep.js";' + + ' export const result = X + 1;'); + myVfs.mount('/virtual16'); + + const mod = require('/virtual16/multi/src/main.js'); + assert.strictEqual(mod.result, 101); + + myVfs.unmount(); +} + +// Test require(esm): .mjs without any package.json loads as ESM +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/no-pkg.mjs', + 'export const x = 1; export default "hello";'); + myVfs.mount('/virtual17'); + + const mod = require('/virtual17/no-pkg.mjs'); + assert.strictEqual(mod.x, 1); + assert.strictEqual(mod.default, 'hello'); + + myVfs.unmount(); +} + +// Test require(esm): .mjs with package.json that has no type field +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app', { recursive: true }); + myVfs.writeFileSync('/app/package.json', + JSON.stringify({ name: 'no-type' })); + myVfs.writeFileSync('/app/lib.mjs', 'export const val = 42;'); + myVfs.mount('/virtual18'); + + const mod = require('/virtual18/app/lib.mjs'); + assert.strictEqual(mod.val, 42); + + myVfs.unmount(); +} + +// Test require(esm): .mjs in type:commonjs package still loads as ESM +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/cjs-pkg', { recursive: true }); + myVfs.writeFileSync('/cjs-pkg/package.json', JSON.stringify({ + name: 'cjs-pkg', + type: 'commonjs', + })); + myVfs.writeFileSync('/cjs-pkg/esm.mjs', 'export const z = 99;'); + myVfs.mount('/virtual19'); + + const mod = require('/virtual19/cjs-pkg/esm.mjs'); + assert.strictEqual(mod.z, 99); + + myVfs.unmount(); +} + +// Test CJS: package with "main" field resolves through VFS +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/pkg/lib', { recursive: true }); + myVfs.writeFileSync('/pkg/package.json', JSON.stringify({ + name: 'legacy-main-pkg', + main: './lib/entry', + })); + myVfs.writeFileSync('/pkg/lib/entry.js', 'module.exports = "legacy-main";'); + myVfs.mount('/virtual20'); + + const result = require('/virtual20/pkg'); + assert.strictEqual(result, 'legacy-main'); + + myVfs.unmount(); +} + +// Test CJS: package with no "main" field resolves index.js +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/pkg2', { recursive: true }); + myVfs.writeFileSync('/pkg2/package.json', JSON.stringify({ + name: 'no-main-pkg', + })); + myVfs.writeFileSync('/pkg2/index.js', 'module.exports = "index-fallback";'); + myVfs.mount('/virtual21'); + + const result = require('/virtual21/pkg2'); + assert.strictEqual(result, 'index-fallback'); + + myVfs.unmount(); +} + +// Test ESM legacyMainResolve: import() a VFS package with "main" (no "exports") +// This triggers the ESM legacyMainResolve path in resolve.js via bare specifier +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app/node_modules/esm-legacy-main/lib', { recursive: true }); + myVfs.writeFileSync('/app/node_modules/esm-legacy-main/package.json', JSON.stringify({ + name: 'esm-legacy-main', + type: 'module', + main: './lib/entry.js', + })); + myVfs.writeFileSync('/app/node_modules/esm-legacy-main/lib/entry.js', + 'export const value = "esm-legacy-main";'); + myVfs.writeFileSync('/app/main.mjs', + 'export { value } from "esm-legacy-main";'); + myVfs.mount('/virtual20b'); + + import('/virtual20b/app/main.mjs').then(common.mustCall((mod) => { + assert.strictEqual(mod.value, 'esm-legacy-main'); + myVfs.unmount(); + })); +} + +// Test ESM legacyMainResolve: import() a VFS package with no "main" (index.js fallback) +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/app2/node_modules/esm-nomain', { recursive: true }); + myVfs.writeFileSync('/app2/node_modules/esm-nomain/package.json', JSON.stringify({ + name: 'esm-nomain', + type: 'module', + })); + myVfs.writeFileSync('/app2/node_modules/esm-nomain/index.js', + 'export const value = "esm-index-fallback";'); + myVfs.writeFileSync('/app2/main.mjs', + 'export { value } from "esm-nomain";'); + myVfs.mount('/virtual21b'); + + import('/virtual21b/app2/main.mjs').then(common.mustCall((mod) => { + assert.strictEqual(mod.value, 'esm-index-fallback'); + myVfs.unmount(); + })); +} + +// Test getFormatOfExtensionlessFile: extensionless JS file in type:module package +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/esm-pkg', { recursive: true }); + myVfs.writeFileSync('/esm-pkg/package.json', JSON.stringify({ + name: 'esm-pkg', + type: 'module', + })); + myVfs.writeFileSync('/esm-pkg/entry', 'export const x = 123;'); + myVfs.mount('/virtual22'); + + // Use import() to trigger ESM loader path for extensionless file detection + import('/virtual22/esm-pkg/entry').then(common.mustCall((mod) => { + assert.strictEqual(mod.x, 123); + myVfs.unmount(); + })); +} + +// Test that unmounting stops interception +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/unmount-test.js', 'module.exports = "before unmount";'); + myVfs.mount('/virtual10'); + + const result = require('/virtual10/unmount-test.js'); + assert.strictEqual(result, 'before unmount'); + + myVfs.unmount(); + + // After unmounting, the file should not be found + assert.throws(() => { + // Clear require cache first — the cache key is the platform-resolved path + delete require.cache[path.resolve('/virtual10/unmount-test.js')]; + require('/virtual10/unmount-test.js'); + }, { code: 'MODULE_NOT_FOUND' }); +} From 901b2bcf37c165a1d04336458d6a441822357034 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 30 May 2026 09:06:41 +0200 Subject: [PATCH 2/4] fixup! vfs: integrate with CJS and ESM module loaders - Normalize setLoaderFsOverrides / setLoaderPackageOverrides to coerce missing fields to null so calling with {} actually clears them. - Add clearRealpathCache helper, called from deregisterVFS so paths that overlap a removed mount are re-resolved against the real filesystem. - Uninstall loader overrides when the last VFS unmounts so the fast path is restored; reset hooksInstalled so a future mount reinstalls. - findVFSPackageJSON now reports the topmost candidate path as a sentinel; getPackageScopeConfig returns it instead of dirname so the caller's "not found" cache mirrors the C++ binding's contract. - legacyMainResolve now throws ERR_MODULE_NOT_FOUND with the resolved initial candidate path (pkgPath + main) to match the C++ message. - getFormatOfExtensionlessFile only swallows ENOENT; EACCES/ELOOP and any other error propagate instead of being silently labeled as JS. - Validate package.json field types in serializePackageJSON and let the schema error escape the readPackageJSON override unswallowed. - Add test-vfs-module-hooks-cleanup covering register/deregister cycles, malformed schema rejection, and missing-main error propagation. --- lib/internal/modules/helpers.js | 36 +++-- lib/internal/vfs/setup.js | 152 ++++++++++++++---- .../parallel/test-vfs-module-hooks-cleanup.js | 71 ++++++++ 3 files changed, 214 insertions(+), 45 deletions(-) create mode 100644 test/parallel/test-vfs-module-hooks-cleanup.js diff --git a/lib/internal/modules/helpers.js b/lib/internal/modules/helpers.js index 51a4e188ad4681..9f92496e38ea35 100644 --- a/lib/internal/modules/helpers.js +++ b/lib/internal/modules/helpers.js @@ -27,7 +27,7 @@ const { pathToFileURL, fileURLToPath, URL } = require('internal/url'); const assert = require('internal/assert'); const { getOptionValue } = require('internal/options'); -const { setOwnProperty, getLazy } = require('internal/util'); +const { kEmptyObject, setOwnProperty, getLazy } = require('internal/util'); const { inspect } = require('internal/util/inspect'); const { emitWarningSync } = require('internal/process/warning'); @@ -68,15 +68,16 @@ let _loaderGetFormatOfExtensionlessFile = null; /** * Set override functions for the module loader's fs operations. + * Missing or nullish fields disable that hook (fast-path restored). * @param {{ stat?: Function, readFile?: Function, realpath?: Function, * legacyMainResolve?: Function, getFormatOfExtensionlessFile?: Function }} overrides */ -function setLoaderFsOverrides({ stat, readFile, realpath, legacyMainResolve, getFormatOfExtensionlessFile }) { - _loaderStat = stat; - _loaderReadFile = readFile; - _loaderRealpath = realpath; - _loaderLegacyMainResolve = legacyMainResolve; - _loaderGetFormatOfExtensionlessFile = getFormatOfExtensionlessFile; +function setLoaderFsOverrides(overrides = kEmptyObject) { + _loaderStat = overrides.stat ?? null; + _loaderReadFile = overrides.readFile ?? null; + _loaderRealpath = overrides.realpath ?? null; + _loaderLegacyMainResolve = overrides.legacyMainResolve ?? null; + _loaderGetFormatOfExtensionlessFile = overrides.getFormatOfExtensionlessFile ?? null; } /** @@ -154,6 +155,7 @@ let _loaderGetPackageType = null; /** * Set override functions for the module loader's package.json operations. + * Missing or nullish fields disable that hook (fast-path restored). * @param {{ * readPackageJSON?: Function, * getNearestParentPackageJSON?: Function, @@ -161,11 +163,20 @@ let _loaderGetPackageType = null; * getPackageType?: Function, * }} overrides */ -function setLoaderPackageOverrides(overrides) { - _loaderReadPackageJSON = overrides.readPackageJSON; - _loaderGetNearestParentPackageJSON = overrides.getNearestParentPackageJSON; - _loaderGetPackageScopeConfig = overrides.getPackageScopeConfig; - _loaderGetPackageType = overrides.getPackageType; +function setLoaderPackageOverrides(overrides = kEmptyObject) { + _loaderReadPackageJSON = overrides.readPackageJSON ?? null; + _loaderGetNearestParentPackageJSON = overrides.getNearestParentPackageJSON ?? null; + _loaderGetPackageScopeConfig = overrides.getPackageScopeConfig ?? null; + _loaderGetPackageType = overrides.getPackageType ?? null; +} + +/** + * Clear the realpath cache used by toRealPath's fallback path. + * Called when VFS instances are unmounted so that paths that overlap a + * removed mount are re-resolved against the real filesystem next time. + */ +function clearRealpathCache() { + realpathCache.clear(); } /** @@ -668,6 +679,7 @@ function getCompileCacheDir() { module.exports = { addBuiltinLibsToObject, assertBufferSource, + clearRealpathCache, constants, enableCompileCache, flushCompileCache, diff --git a/lib/internal/vfs/setup.js b/lib/internal/vfs/setup.js index 51cb5088eb8035..f406c62f80db19 100644 --- a/lib/internal/vfs/setup.js +++ b/lib/internal/vfs/setup.js @@ -27,7 +27,7 @@ const { } = require('internal/errors'); const { createENOENT, createEXDEV } = require('internal/vfs/errors'); const { getVirtualFd, closeVirtualFd } = require('internal/vfs/fd'); -const { assertEncoding, vfsState, setVfsHandlers } = require('internal/fs/utils'); +const { assertEncoding, setVfsHandlers } = require('internal/fs/utils'); const permission = require('internal/process/permission'); const { getOptionValue } = require('internal/options'); let debug = require('internal/util/debuglog').debuglog('vfs', (fn) => { @@ -89,8 +89,6 @@ function registerVFS(vfs) { debug('register mount=%s active=%d', newMount, activeVFSList.length); if (!hooksInstalled) { installHooks(); - } else if (vfsState.handlers === null) { - setVfsHandlers(vfsHandlerObj); } } @@ -99,16 +97,18 @@ function deregisterVFS(vfs) { if (index === -1) return; ArrayPrototypeSplice(activeVFSList, index, 1); debug('deregister active=%d', activeVFSList.length); - if (activeVFSList.length === 0) { - setVfsHandlers(null); - } // Clear CJS loader and package.json caches to avoid stale entries from // paths that were resolved while the VFS was mounted. const cjsLoader = require('internal/modules/cjs/loader'); cjsLoader.Module._pathCache = { __proto__: null }; cjsLoader.clearStatCache(); + const helpers = require('internal/modules/helpers'); + helpers.clearRealpathCache(); const { clearPackageJSONCache } = require('internal/modules/package_json_reader'); clearPackageJSONCache(); + if (activeVFSList.length === 0) { + uninstallHooks(); + } } /** @@ -165,22 +165,62 @@ function findVFSForRead(filename, options) { return null; } +/** + * Validate that a value coming out of a parsed package.json field is a + * string when present. Mirrors the C++ binding's "must be a string" + * checks so the deserializer downstream isn't handed garbage. + * @param {*} value + * @param {string} field + * @param {string} filePath + */ +function validateOptionalString(value, field, filePath) { + if (value !== undefined && value !== null && typeof value !== 'string') { + throw new ERR_INVALID_PACKAGE_CONFIG( + filePath, undefined, `"${field}" must be a string`); + } +} + +/** + * Validate that imports/exports is the expected shape: a string, or an + * object/array. Mirrors the C++ binding's accepted types. + * @param {*} value + * @param {string} field + * @param {string} filePath + */ +function validateExportsOrImports(value, field, filePath) { + if (value === undefined || value === null) return; + const t = typeof value; + if (t !== 'string' && t !== 'object') { + throw new ERR_INVALID_PACKAGE_CONFIG( + filePath, undefined, + `"${field}" must be a string, object, or array`); + } +} + /** * Serialize a parsed package.json object into the C++ tuple format * expected by deserializePackageJSON: [name, main, type, imports, exports, filePath]. + * Throws ERR_INVALID_PACKAGE_CONFIG when a field has the wrong type, to + * match the native binding's validation contract. * @param {object} parsed The parsed package.json content * @param {string} filePath The path to the package.json file * @returns {Array} Serialized package config tuple */ function serializePackageJSON(parsed, filePath) { + validateOptionalString(parsed.name, 'name', filePath); + validateOptionalString(parsed.main, 'main', filePath); + validateOptionalString(parsed.type, 'type', filePath); + validateExportsOrImports(parsed.imports, 'imports', filePath); + validateExportsOrImports(parsed.exports, 'exports', filePath); + const name = parsed.name; const main = parsed.main; const type = parsed.type ?? 'none'; - const imports = parsed.imports !== undefined ? + const imports = parsed.imports !== undefined && parsed.imports !== null ? (typeof parsed.imports === 'string' ? parsed.imports : JSONStringify(parsed.imports)) : undefined; - const exports = parsed.exports !== undefined ? + const exports = parsed.exports !== undefined && parsed.exports !== null ? (typeof parsed.exports === 'string' ? parsed.exports : JSONStringify(parsed.exports)) : undefined; @@ -188,26 +228,32 @@ function serializePackageJSON(parsed, filePath) { } /** - * Walk up directories in VFS looking for package.json. + * Walk up directories in VFS looking for package.json. Always returns an + * object. When a package.json is found `.parsed` is populated; otherwise + * `.sentinel` is the last candidate path checked (highest reached before + * walking past the mount or hitting node_modules) - used as the "not found" + * marker matching the C++ binding's contract for getPackageScopeConfig. * @param {string} startPath Normalized absolute path to start from - * @returns {{ vfs: object, pjsonPath: string, parsed: object }|null} + * @returns {{ vfs?: object, pjsonPath?: string, parsed?: object, sentinel: string }} */ function findVFSPackageJSON(startPath) { let currentDir = dirname(startPath); let lastDir; + let sentinel = resolve(currentDir, 'package.json'); while (currentDir !== lastDir) { if (StringPrototypeEndsWith(currentDir, '/node_modules') || StringPrototypeEndsWith(currentDir, '\\node_modules')) { break; } const pjsonPath = resolve(currentDir, 'package.json'); + sentinel = pjsonPath; for (let i = 0; i < activeVFSList.length; i++) { const vfs = activeVFSList[i]; if (vfs.shouldHandle(pjsonPath) && vfsStat(vfs, pjsonPath) === 0) { try { const content = vfs.readFileSync(pjsonPath, 'utf8'); const parsed = JSONParse(content); - return { vfs, pjsonPath, parsed }; + return { vfs, pjsonPath, parsed, sentinel: pjsonPath }; } catch { // SyntaxError or other errors, continue walking } @@ -216,7 +262,7 @@ function findVFSPackageJSON(startPath) { lastDir = currentDir; currentDir = dirname(currentDir); } - return null; + return { sentinel }; } function findVFSForExists(filename) { @@ -831,23 +877,33 @@ function installModuleLoaderOverrides() { if (findVFSForStat(candidate)?.result === 0) return 7 + i; } - throw new ERR_MODULE_NOT_FOUND(pkgPath, base, 'package'); + // Match the C++ binding's message shape: the first arg is the + // initial candidate path (pkgPath + main) when main is set, + // otherwise the package directory itself. + const initial = main ? resolve(pkgPath, main) : pkgPath; + throw new ERR_MODULE_NOT_FOUND(initial, base, 'package'); }, getFormatOfExtensionlessFile(filePath) { + let result; try { - const result = findVFSForRead(filePath, null); - if (result === null) return undefined; - const content = result.content; - // Wasm magic bytes: 0x00 0x61 0x73 0x6d - if (content && content.length >= 4 && - content[0] === 0x00 && content[1] === 0x61 && - content[2] === 0x73 && content[3] === 0x6d) { - return 1; // EXTENSIONLESS_FORMAT_WASM - } - return 0; // EXTENSIONLESS_FORMAT_JAVASCRIPT - } catch { - return 0; + result = findVFSForRead(filePath, null); + } catch (e) { + // findVFSForRead synthesizes ENOENT for missing paths inside a + // mount. Treat that as JS (the caller will surface the real + // error when it later tries to load source). Propagate every + // other code (EACCES, ELOOP, etc). + if (e?.code === 'ENOENT') return 0; // EXTENSIONLESS_FORMAT_JAVASCRIPT + throw e; + } + if (result === null) return undefined; + const content = result.content; + // Wasm magic bytes: 0x00 0x61 0x73 0x6d + if (content && content.length >= 4 && + content[0] === 0x00 && content[1] === 0x61 && + content[2] === 0x73 && content[3] === 0x6d) { + return 1; // EXTENSIONLESS_FORMAT_WASM } + return 0; // EXTENSIONLESS_FORMAT_JAVASCRIPT }, }); @@ -858,16 +914,27 @@ function installModuleLoaderOverrides() { const vfs = activeVFSList[i]; if (!vfs.shouldHandle(normalized)) continue; if (vfsStat(vfs, normalized) !== 0) return undefined; + let content; try { - const content = vfs.readFileSync(normalized, 'utf8'); - const parsed = JSONParse(content); - return serializePackageJSON(parsed, normalized); + content = vfs.readFileSync(normalized, 'utf8'); + } catch { + // Treat read errors as "no package.json" - same as native. + return undefined; + } + let parsed; + try { + parsed = JSONParse(content); } catch (err) { - if (err?.name === 'SyntaxError' && isESM) { + // ESM raises ERR_INVALID_PACKAGE_CONFIG on malformed JSON; + // CJS silently ignores it (legacy behavior). + if (isESM) { throw new ERR_INVALID_PACKAGE_CONFIG(normalized, base, err.message); } return undefined; } + // serializePackageJSON may throw ERR_INVALID_PACKAGE_CONFIG for + // wrong-type fields - intentionally not caught. + return serializePackageJSON(parsed, normalized); } return nativeModulesBinding.readPackageJSON(jsonPath, isESM, base, specifier); }, @@ -876,7 +943,7 @@ function installModuleLoaderOverrides() { for (let i = 0; i < activeVFSList.length; i++) { if (activeVFSList[i].shouldHandle(normalized)) { const found = findVFSPackageJSON(normalized); - if (found !== null) { + if (found.parsed !== undefined) { return serializePackageJSON(found.parsed, found.pjsonPath); } return undefined; @@ -899,10 +966,12 @@ function installModuleLoaderOverrides() { for (let i = 0; i < activeVFSList.length; i++) { if (activeVFSList[i].shouldHandle(normalized)) { const found = findVFSPackageJSON(normalized); - if (found !== null) { + if (found.parsed !== undefined) { return serializePackageJSON(found.parsed, found.pjsonPath); } - return resolve(dirname(normalized), 'package.json'); + // No package.json found anywhere up the tree - return the + // topmost path that was checked. Matches the C++ binding contract. + return found.sentinel; } } return nativeModulesBinding.getPackageScopeConfig(resolved); @@ -922,7 +991,7 @@ function installModuleLoaderOverrides() { for (let i = 0; i < activeVFSList.length; i++) { if (activeVFSList[i].shouldHandle(normalized)) { const found = findVFSPackageJSON(normalized); - if (found !== null) { + if (found.parsed !== undefined) { const type = found.parsed.type; if (type === 'module' || type === 'commonjs') return type; } @@ -946,6 +1015,23 @@ function installHooks() { hooksInstalled = true; } +/** + * Tear down all VFS hooks when the last instance is deregistered. The + * fast path in the loader wrappers is restored so subsequent require/ + * import calls pay zero overhead until another VFS is mounted. + */ +function uninstallHooks() { + if (!hooksInstalled) return; + debug('uninstall hooks'); + const { setLoaderFsOverrides, setLoaderPackageOverrides } = + require('internal/modules/helpers'); + setLoaderFsOverrides(); + setLoaderPackageOverrides(); + setVfsHandlers(null); + vfsHandlerObj = undefined; + hooksInstalled = false; +} + module.exports = { registerVFS, deregisterVFS, diff --git a/test/parallel/test-vfs-module-hooks-cleanup.js b/test/parallel/test-vfs-module-hooks-cleanup.js new file mode 100644 index 00000000000000..efea19c10cbe25 --- /dev/null +++ b/test/parallel/test-vfs-module-hooks-cleanup.js @@ -0,0 +1,71 @@ +// Flags: --experimental-vfs +'use strict'; + +// Regression coverage for VFS↔module-loader hook cleanup and error +// propagation. Pairs with the fixes from the adversarial review. + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// 1) After a full deregister, a fresh register re-installs hooks cleanly: +// the second mount must be visible to require(). +{ + const a = vfs.create(); + a.writeFileSync('/m1.js', 'module.exports = "first"'); + a.mount('/mnt-cycle-1'); + assert.strictEqual(require('/mnt-cycle-1/m1.js'), 'first'); + a.unmount(); + + const b = vfs.create(); + b.writeFileSync('/m2.js', 'module.exports = "second"'); + b.mount('/mnt-cycle-2'); + assert.strictEqual(require('/mnt-cycle-2/m2.js'), 'second'); + b.unmount(); +} + +// 2) After the last VFS is removed, a real-fs require still works (no +// stale module-loader override masking real files). +{ + const v = vfs.create(); + v.writeFileSync('/x.js', 'module.exports = 1'); + v.mount('/mnt-cleanup'); + require('/mnt-cleanup/x.js'); + v.unmount(); + // Real fs path: pull a builtin to exercise the loader fast path. + const fs = require('fs'); + assert.strictEqual(typeof fs.readFileSync, 'function'); +} + +// 3) Malformed package.json schema is rejected (not silently accepted) +// by the CJS loader when it tries to read `main`. The fix makes +// serializePackageJSON throw ERR_INVALID_PACKAGE_CONFIG and the +// readPackageJSON override no longer swallows it. +{ + const v = vfs.create(); + v.mkdirSync('/pkg'); + // `main` must be a string; passing a number must be rejected. + v.writeFileSync('/pkg/package.json', '{"main": 42}'); + v.writeFileSync('/pkg/index.js', 'module.exports = 1'); + v.mount('/mnt-bad-schema'); + assert.throws( + () => require('/mnt-bad-schema/pkg'), + { code: 'ERR_INVALID_PACKAGE_CONFIG' }, + ); + v.unmount(); +} + +// 4) Resolving a package whose `main` points at a missing file in the +// VFS yields MODULE_NOT_FOUND (CJS legacy error code), exercising +// the legacyMainResolve override's fallback path. +{ + const v = vfs.create(); + v.mkdirSync('/pkg'); + v.writeFileSync('/pkg/package.json', '{"main": "./nope.js"}'); + v.mount('/mnt-legacy-err'); + assert.throws( + () => require('/mnt-legacy-err/pkg'), + { code: 'MODULE_NOT_FOUND' }, + ); + v.unmount(); +} From 053b842e7ccdcf76a2733f1570ef1ca41f773405 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 30 May 2026 10:04:09 +0200 Subject: [PATCH 3/4] fixup! vfs: integrate with CJS and ESM module loaders Address the round-2 adversarial review: - ERR_MODULE_NOT_FOUND: pass undefined for the third (`exactUrl`) arg so the message uses "package" and err.url isn't overwritten. The constructor enforces strict arity; the arg must be present. - serializePackageJSON now matches the C++ binding's laxity: non-string `main` is silently omitted (matches USE(value.get_string)), unknown `type` strings fall back to 'none', non-string `name` / `type` throw ERR_INVALID_PACKAGE_CONFIG, top-level null / non-object roots throw too. Old `validateOptionalString` helper removed. - ESM LoadCache is now cleared from clearLoaderCaches() so dynamic import() after re-mount sees fresh module jobs. - deregisterVFS still flushes loader caches on every unmount (we can't tell which entries belonged to the VFS going away), but only uninstalls the loader override hooks when the last VFS is removed. - test-vfs-module-hooks-cleanup: dropped the assertion that the strict schema rejects `{"main": 42}` (would diverge from real-fs behavior); added a top-level-null case, a non-string-main lenient case, a multi- mount partial-deregister case, and rewrote the legacyMainResolve case to use bare-specifier resolution from an ESM entry inside the VFS so the override is actually exercised. --- lib/internal/vfs/setup.js | 140 ++++++++++-------- .../parallel/test-vfs-module-hooks-cleanup.js | 84 ++++++++--- 2 files changed, 146 insertions(+), 78 deletions(-) diff --git a/lib/internal/vfs/setup.js b/lib/internal/vfs/setup.js index f406c62f80db19..dd09886c3e12b3 100644 --- a/lib/internal/vfs/setup.js +++ b/lib/internal/vfs/setup.js @@ -1,6 +1,7 @@ 'use strict'; const { + ArrayIsArray, ArrayPrototypeIndexOf, ArrayPrototypePush, ArrayPrototypeSplice, @@ -97,8 +98,20 @@ function deregisterVFS(vfs) { if (index === -1) return; ArrayPrototypeSplice(activeVFSList, index, 1); debug('deregister active=%d', activeVFSList.length); - // Clear CJS loader and package.json caches to avoid stale entries from - // paths that were resolved while the VFS was mounted. + // Loader/path caches are shared across all VFSes and we can't tell + // which entries belonged to the one going away, so flush them on + // every unmount. The cost is bounded; correctness wins. + clearLoaderCaches(); + if (activeVFSList.length === 0) { + uninstallHooks(); + } +} + +/** + * Clear every JS-reachable loader cache that could hold a VFS-resolved + * entry. Called from deregisterVFS only when the last VFS unmounts. + */ +function clearLoaderCaches() { const cjsLoader = require('internal/modules/cjs/loader'); cjsLoader.Module._pathCache = { __proto__: null }; cjsLoader.clearStatCache(); @@ -106,8 +119,14 @@ function deregisterVFS(vfs) { helpers.clearRealpathCache(); const { clearPackageJSONCache } = require('internal/modules/package_json_reader'); clearPackageJSONCache(); - if (activeVFSList.length === 0) { - uninstallHooks(); + // ESM module-job cache: dynamic import('/v/foo.mjs') would otherwise + // serve the stale module after a re-mount with different content. + // The resolveCache is a private field and not reachable from here; + // clearing loadCache is enough to force a re-resolve+re-load of any + // module that was held by a job. + const esmLoader = require('internal/modules/esm/loader'); + if (esmLoader.isCascadedLoaderInitialized()) { + esmLoader.getOrInitializeCascadedLoader().loadCache?.clear(); } } @@ -165,65 +184,67 @@ function findVFSForRead(filename, options) { return null; } -/** - * Validate that a value coming out of a parsed package.json field is a - * string when present. Mirrors the C++ binding's "must be a string" - * checks so the deserializer downstream isn't handed garbage. - * @param {*} value - * @param {string} field - * @param {string} filePath - */ -function validateOptionalString(value, field, filePath) { - if (value !== undefined && value !== null && typeof value !== 'string') { - throw new ERR_INVALID_PACKAGE_CONFIG( - filePath, undefined, `"${field}" must be a string`); - } -} - -/** - * Validate that imports/exports is the expected shape: a string, or an - * object/array. Mirrors the C++ binding's accepted types. - * @param {*} value - * @param {string} field - * @param {string} filePath - */ -function validateExportsOrImports(value, field, filePath) { - if (value === undefined || value === null) return; - const t = typeof value; - if (t !== 'string' && t !== 'object') { - throw new ERR_INVALID_PACKAGE_CONFIG( - filePath, undefined, - `"${field}" must be a string, object, or array`); - } -} - /** * Serialize a parsed package.json object into the C++ tuple format * expected by deserializePackageJSON: [name, main, type, imports, exports, filePath]. - * Throws ERR_INVALID_PACKAGE_CONFIG when a field has the wrong type, to - * match the native binding's validation contract. + * + * Matches the native binding's validation in src/node_modules.cc so we + * neither over- nor under-validate compared to the real-fs path: + * - parsed itself must be a non-null object (throws otherwise) + * - "name" must be a string when present (throws otherwise) + * - "main" non-strings are silently omitted + * - "type" must be a string when present (throws otherwise); only + * "module" / "commonjs" are kept, others default to "none" + * - "imports" / "exports" accept string / object / array; other + * types are silently ignored * @param {object} parsed The parsed package.json content * @param {string} filePath The path to the package.json file * @returns {Array} Serialized package config tuple */ function serializePackageJSON(parsed, filePath) { - validateOptionalString(parsed.name, 'name', filePath); - validateOptionalString(parsed.main, 'main', filePath); - validateOptionalString(parsed.type, 'type', filePath); - validateExportsOrImports(parsed.imports, 'imports', filePath); - validateExportsOrImports(parsed.exports, 'exports', filePath); + if (parsed === null || typeof parsed !== 'object' || ArrayIsArray(parsed)) { + throw new ERR_INVALID_PACKAGE_CONFIG(filePath, undefined, ''); + } + + let name; + if (parsed.name !== undefined && parsed.name !== null) { + if (typeof parsed.name !== 'string') { + throw new ERR_INVALID_PACKAGE_CONFIG( + filePath, undefined, '"name" must be a string'); + } + name = parsed.name; + } + + let main; + if (typeof parsed.main === 'string') { + main = parsed.main; + } + + let type = 'none'; + if (parsed.type !== undefined && parsed.type !== null) { + if (typeof parsed.type !== 'string') { + throw new ERR_INVALID_PACKAGE_CONFIG( + filePath, undefined, '"type" must be a string'); + } + if (parsed.type === 'module' || parsed.type === 'commonjs') { + type = parsed.type; + } + } + + let imports; + if (typeof parsed.imports === 'string') { + imports = parsed.imports; + } else if (typeof parsed.imports === 'object' && parsed.imports !== null) { + imports = JSONStringify(parsed.imports); + } + + let exports; + if (typeof parsed.exports === 'string') { + exports = parsed.exports; + } else if (typeof parsed.exports === 'object' && parsed.exports !== null) { + exports = JSONStringify(parsed.exports); + } - const name = parsed.name; - const main = parsed.main; - const type = parsed.type ?? 'none'; - const imports = parsed.imports !== undefined && parsed.imports !== null ? - (typeof parsed.imports === 'string' ? - parsed.imports : JSONStringify(parsed.imports)) : - undefined; - const exports = parsed.exports !== undefined && parsed.exports !== null ? - (typeof parsed.exports === 'string' ? - parsed.exports : JSONStringify(parsed.exports)) : - undefined; return [name, main, type, imports, exports, filePath]; } @@ -877,11 +898,14 @@ function installModuleLoaderOverrides() { if (findVFSForStat(candidate)?.result === 0) return 7 + i; } - // Match the C++ binding's message shape: the first arg is the - // initial candidate path (pkgPath + main) when main is set, - // otherwise the package directory itself. + // Match the C++ binding's message shape: first arg is the initial + // candidate path (pkgPath + main) when main is set, otherwise the + // package directory itself. The third arg `exactUrl` must be + // undefined (not a string) so the message uses the "package" word + // and err.url is not overwritten. ERR_MODULE_NOT_FOUND enforces + // strict arity, so we have to pass undefined explicitly. const initial = main ? resolve(pkgPath, main) : pkgPath; - throw new ERR_MODULE_NOT_FOUND(initial, base, 'package'); + throw new ERR_MODULE_NOT_FOUND(initial, base, undefined); }, getFormatOfExtensionlessFile(filePath) { let result; diff --git a/test/parallel/test-vfs-module-hooks-cleanup.js b/test/parallel/test-vfs-module-hooks-cleanup.js index efea19c10cbe25..44a9bcf0024be0 100644 --- a/test/parallel/test-vfs-module-hooks-cleanup.js +++ b/test/parallel/test-vfs-module-hooks-cleanup.js @@ -1,10 +1,11 @@ // Flags: --experimental-vfs 'use strict'; -// Regression coverage for VFS↔module-loader hook cleanup and error -// propagation. Pairs with the fixes from the adversarial review. +// Regression coverage for VFS-to-module-loader hook cleanup, package.json +// validation parity with the C++ binding, and cache scoping across +// register/deregister cycles. -require('../common'); +const common = require('../common'); const assert = require('assert'); const vfs = require('node:vfs'); @@ -32,40 +33,83 @@ const vfs = require('node:vfs'); v.mount('/mnt-cleanup'); require('/mnt-cleanup/x.js'); v.unmount(); - // Real fs path: pull a builtin to exercise the loader fast path. const fs = require('fs'); assert.strictEqual(typeof fs.readFileSync, 'function'); } -// 3) Malformed package.json schema is rejected (not silently accepted) -// by the CJS loader when it tries to read `main`. The fix makes -// serializePackageJSON throw ERR_INVALID_PACKAGE_CONFIG and the -// readPackageJSON override no longer swallows it. +// 3) Top-level non-object package.json is rejected with +// ERR_INVALID_PACKAGE_CONFIG (matches the native binding's +// throw_invalid_package_config path for non-object roots). { const v = vfs.create(); v.mkdirSync('/pkg'); - // `main` must be a string; passing a number must be rejected. - v.writeFileSync('/pkg/package.json', '{"main": 42}'); + v.writeFileSync('/pkg/package.json', 'null'); v.writeFileSync('/pkg/index.js', 'module.exports = 1'); - v.mount('/mnt-bad-schema'); + v.mount('/mnt-null-pjson'); assert.throws( - () => require('/mnt-bad-schema/pkg'), + () => require('/mnt-null-pjson/pkg'), { code: 'ERR_INVALID_PACKAGE_CONFIG' }, ); v.unmount(); } -// 4) Resolving a package whose `main` points at a missing file in the -// VFS yields MODULE_NOT_FOUND (CJS legacy error code), exercising -// the legacyMainResolve override's fallback path. +// 4) Non-string `main` is silently omitted, matching the native binding +// (`USE(value.get_string(...))` in src/node_modules.cc). A package +// with `{"main": 42}` and a sibling index.js must still resolve. { const v = vfs.create(); v.mkdirSync('/pkg'); - v.writeFileSync('/pkg/package.json', '{"main": "./nope.js"}'); + v.writeFileSync('/pkg/package.json', '{"main": 42}'); + v.writeFileSync('/pkg/index.js', 'module.exports = "via-index"'); + v.mount('/mnt-lax-main'); + assert.strictEqual(require('/mnt-lax-main/pkg'), 'via-index'); + v.unmount(); +} + +// 5) Partial deregister of a multi-mount setup leaves the still-mounted +// VFS fully functional. Guards against the prior "nuke caches before +// checking activeVFSList.length === 0" sledgehammer. +{ + const a = vfs.create(); + a.writeFileSync('/a.js', 'module.exports = "a"'); + a.mount('/mnt-multi-a'); + const b = vfs.create(); + b.writeFileSync('/b.js', 'module.exports = "b"'); + b.mount('/mnt-multi-b'); + + assert.strictEqual(require('/mnt-multi-a/a.js'), 'a'); + assert.strictEqual(require('/mnt-multi-b/b.js'), 'b'); + + // Deregister one; the other must still resolve. + a.unmount(); + assert.strictEqual(require('/mnt-multi-b/b.js'), 'b'); + + b.unmount(); +} + +// 6) ESM legacyMainResolve override produces ERR_MODULE_NOT_FOUND with +// the resolved candidate path (not the bare package directory) when +// `main` points at a missing file. Driven through bare-specifier +// resolution from a VFS entry file - that is the only code path +// that calls loaderLegacyMainResolve. +(async () => { + const v = vfs.create(); + v.mkdirSync('/app/node_modules/badpkg', { recursive: true }); + v.writeFileSync( + '/app/node_modules/badpkg/package.json', '{"main": "./nope.js"}'); + v.writeFileSync('/app/entry.mjs', "import 'badpkg';"); v.mount('/mnt-legacy-err'); - assert.throws( - () => require('/mnt-legacy-err/pkg'), - { code: 'MODULE_NOT_FOUND' }, + await assert.rejects( + () => import('/mnt-legacy-err/app/entry.mjs'), + (err) => { + assert.strictEqual(err.code, 'ERR_MODULE_NOT_FOUND'); + // The first arg to ERR_MODULE_NOT_FOUND must be the candidate + // file path. The previous bug also passed a bogus third + // "package" argument that wrote into err.url. + assert.match(err.message, /nope\.js/); + assert.notStrictEqual(err.url, 'package'); + return true; + }, ); v.unmount(); -} +})().then(common.mustCall()); From b51fe1f5ec15ae9e2da8e6209208ae68aaf41c38 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 30 May 2026 12:50:15 +0200 Subject: [PATCH 4/4] fixup! vfs: integrate with CJS and ESM module loaders Address the round-3 adversarial review: - legacyMainResolve override: when `main` is missing/non-string, use pkgPath/index.js as the candidate path in the thrown ERR_MODULE_NOT_FOUND, matching src/node_file.cc:3927-4005. Previously the bare pkgPath was used, diverging from the native binding. - getPackageType override: route through serializePackageJSON so a malformed `type` (non-string) throws ERR_INVALID_PACKAGE_CONFIG just like readPackageJSON and getPackageScopeConfig already do. Closes the divergence where the same package.json answered differently depending on which override was consulted. - clearLoaderCaches no longer clears the ESM cascaded loader's loadCache. Clearing it mid-flight can let a second ModuleJob be created for the same URL, breaking module identity. The trade-off is documented in a comment: ESM modules loaded from a VFS path remain cached after unmount, consistent with how Node.js caches ESM modules everywhere else. - test-vfs-module-hooks-cleanup: tighten the legacyMainResolve case to assert err.url is undefined and the message starts with "Cannot find package " (previously only asserted err.url !== 'package', which is a weak negative). --- lib/internal/vfs/setup.js | 40 +++++++++++-------- .../parallel/test-vfs-module-hooks-cleanup.js | 10 ++--- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/lib/internal/vfs/setup.js b/lib/internal/vfs/setup.js index dd09886c3e12b3..4443c6f6bd89e4 100644 --- a/lib/internal/vfs/setup.js +++ b/lib/internal/vfs/setup.js @@ -119,15 +119,13 @@ function clearLoaderCaches() { helpers.clearRealpathCache(); const { clearPackageJSONCache } = require('internal/modules/package_json_reader'); clearPackageJSONCache(); - // ESM module-job cache: dynamic import('/v/foo.mjs') would otherwise - // serve the stale module after a re-mount with different content. - // The resolveCache is a private field and not reachable from here; - // clearing loadCache is enough to force a re-resolve+re-load of any - // module that was held by a job. - const esmLoader = require('internal/modules/esm/loader'); - if (esmLoader.isCascadedLoaderInitialized()) { - esmLoader.getOrInitializeCascadedLoader().loadCache?.clear(); - } + // The ESM cascaded loader's loadCache is intentionally NOT cleared here: + // clearing it mid-flight (while another import() is awaiting nested + // resolution) would let a second ModuleJob be created for the same URL, + // breaking module identity. The trade-off is that an ESM module already + // loaded from a VFS path remains cached after unmount and across a + // re-mount with different content, consistent with how ESM caches + // modules everywhere else in Node.js. } /** @@ -898,13 +896,17 @@ function installModuleLoaderOverrides() { if (findVFSForStat(candidate)?.result === 0) return 7 + i; } - // Match the C++ binding's message shape: first arg is the initial - // candidate path (pkgPath + main) when main is set, otherwise the - // package directory itself. The third arg `exactUrl` must be - // undefined (not a string) so the message uses the "package" word - // and err.url is not overwritten. ERR_MODULE_NOT_FOUND enforces - // strict arity, so we have to pass undefined explicitly. - const initial = main ? resolve(pkgPath, main) : pkgPath; + // Match the C++ binding's message shape (src/node_file.cc:3927-4005): + // when `main` is a non-empty string, the initial candidate path is + // pkgPath/main; otherwise the binding sets it to pkgPath/index.js + // (the first extensionless fallback + ".js"). The third arg + // `exactUrl` must be undefined - not a string - so the message uses + // the "package" word and err.url is not overwritten. + // ERR_MODULE_NOT_FOUND enforces strict arity in getMessage(), so + // the undefined has to be passed explicitly. + const initial = main ? + resolve(pkgPath, main) : + resolve(pkgPath, 'index.js'); throw new ERR_MODULE_NOT_FOUND(initial, base, undefined); }, getFormatOfExtensionlessFile(filePath) { @@ -1016,7 +1018,11 @@ function installModuleLoaderOverrides() { if (activeVFSList[i].shouldHandle(normalized)) { const found = findVFSPackageJSON(normalized); if (found.parsed !== undefined) { - const type = found.parsed.type; + // Route through serializePackageJSON so a malformed `type` + // (non-string) throws ERR_INVALID_PACKAGE_CONFIG, matching + // the native binding and the other two package.json + // overrides. The serialized tuple is [name, main, type, ...]. + const type = serializePackageJSON(found.parsed, found.pjsonPath)[2]; if (type === 'module' || type === 'commonjs') return type; } return undefined; diff --git a/test/parallel/test-vfs-module-hooks-cleanup.js b/test/parallel/test-vfs-module-hooks-cleanup.js index 44a9bcf0024be0..acf15fbb25e762 100644 --- a/test/parallel/test-vfs-module-hooks-cleanup.js +++ b/test/parallel/test-vfs-module-hooks-cleanup.js @@ -91,7 +91,9 @@ const vfs = require('node:vfs'); // the resolved candidate path (not the bare package directory) when // `main` points at a missing file. Driven through bare-specifier // resolution from a VFS entry file - that is the only code path -// that calls loaderLegacyMainResolve. +// that calls loaderLegacyMainResolve. Also asserts err.url is +// undefined (not a bogus value) - the previous bug wrote 'package' +// into err.url by passing a string as the third constructor arg. (async () => { const v = vfs.create(); v.mkdirSync('/app/node_modules/badpkg', { recursive: true }); @@ -103,11 +105,9 @@ const vfs = require('node:vfs'); () => import('/mnt-legacy-err/app/entry.mjs'), (err) => { assert.strictEqual(err.code, 'ERR_MODULE_NOT_FOUND'); - // The first arg to ERR_MODULE_NOT_FOUND must be the candidate - // file path. The previous bug also passed a bogus third - // "package" argument that wrote into err.url. assert.match(err.message, /nope\.js/); - assert.notStrictEqual(err.url, 'package'); + assert.match(err.message, /^Cannot find package /); + assert.strictEqual(err.url, undefined); return true; }, );