From 51b033a1ba04f370d4be6d00e69241825ba2f701 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 1 Jun 2026 17:54:03 +0200 Subject: [PATCH] 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. When no VFS is mounted, the wrappers take a null-check fast path with zero overhead. Hooks: - loaderStat / loaderReadFile / toRealPath / loaderLegacyMainResolve / loaderGetFormatOfExtensionlessFile in lib/internal/modules/helpers.js, consumed by cjs/loader.js, esm/resolve.js, esm/load.js and esm/get_format.js. - loaderReadPackageJSON / loaderGetNearestParentPackageJSON / loaderGetPackageScopeConfig / loaderGetPackageType, consumed by package_json_reader.js. - setLoaderFsOverrides / setLoaderPackageOverrides install / clear all hooks; clearRealpathCache exposes the helpers.js realpath cache so deregister can flush it. lib/internal/vfs/setup.js installs the overrides on first registerVFS and clears every JS-side loader cache (CJS _pathCache, CJS stat cache, realpath cache, package.json cache) on every deregister. The overrides themselves are uninstalled when the last VFS is removed so the fast path is fully restored. legacyMainResolve / extensionless-format behavior matches the C++ binding; package.json validation matches src/node_modules.cc (silently omit non-string main, throw on non-string name/type, etc). The "DO NOT depend on patchability" warnings in esm/load.js and esm/resolve.js are preserved and now point at node:vfs and module.registerHooks() as the formal hook mechanisms. Tests cover require / import / module-hooks / package.json / cache invalidation / cleanup-cycle scenarios under --experimental-vfs. Signed-off-by: Matteo Collina --- lib/internal/modules/cjs/loader.js | 19 +- lib/internal/modules/esm/get_format.js | 4 +- lib/internal/modules/esm/load.js | 12 +- lib/internal/modules/esm/resolve.js | 24 +- lib/internal/modules/helpers.js | 178 ++++- lib/internal/modules/package_json_reader.js | 29 +- lib/internal/vfs/setup.js | 423 +++++++++- test/parallel/test-vfs-import.mjs | 142 ++++ .../parallel/test-vfs-invalid-package-json.js | 45 ++ .../parallel/test-vfs-module-hooks-cleanup.js | 115 +++ 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 ++++++++++ 14 files changed, 2285 insertions(+), 43 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-cleanup.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..5718f906f9d69d 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,13 @@ 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 + // 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); + // Prefer module.registerHooks(), node:vfs, or other more formal fs hooks + // released in the future. loaderReadFile is the toggleable hook used by + // node:vfs and is not part of the public API. + 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..107782a192db15 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,13 @@ 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 + // 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, - }); + // Prefer module.registerHooks(), node:vfs, or other more formal fs hooks + // released in the future. toRealPath is the toggleable hook used by + // node:vfs and is not part of the public API. + 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..9f92496e38ea35 100644 --- a/lib/internal/modules/helpers.js +++ b/lib/internal/modules/helpers.js @@ -27,20 +27,22 @@ 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'); 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,178 @@ 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. + * Missing or nullish fields disable that hook (fast-path restored). + * @param {{ stat?: Function, readFile?: Function, realpath?: Function, + * legacyMainResolve?: Function, getFormatOfExtensionlessFile?: Function }} overrides + */ +function setLoaderFsOverrides(overrides = kEmptyObject) { + _loaderStat = overrides.stat ?? null; + _loaderReadFile = overrides.readFile ?? null; + _loaderRealpath = overrides.realpath ?? null; + _loaderLegacyMainResolve = overrides.legacyMainResolve ?? null; + _loaderGetFormatOfExtensionlessFile = overrides.getFormatOfExtensionlessFile ?? null; +} + +/** + * 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. + * Missing or nullish fields disable that hook (fast-path restored). + * @param {{ + * readPackageJSON?: Function, + * getNearestParentPackageJSON?: Function, + * getPackageScopeConfig?: Function, + * getPackageType?: Function, + * }} overrides + */ +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(); +} + +/** + * 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[]} */ @@ -516,6 +679,7 @@ function getCompileCacheDir() { module.exports = { addBuiltinLibsToObject, assertBufferSource, + clearRealpathCache, constants, enableCompileCache, flushCompileCache, @@ -524,10 +688,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..4443c6f6bd89e4 100644 --- a/lib/internal/vfs/setup.js +++ b/lib/internal/vfs/setup.js @@ -1,27 +1,34 @@ 'use strict'; const { + ArrayIsArray, 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'); 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) => { @@ -82,11 +89,7 @@ function registerVFS(vfs) { ArrayPrototypePush(activeVFSList, vfs); debug('register mount=%s active=%d', newMount, activeVFSList.length); if (!hooksInstalled) { - vfsHandlerObj = createVfsHandlers(); - setVfsHandlers(vfsHandlerObj); - hooksInstalled = true; - } else if (vfsState.handlers === null) { - setVfsHandlers(vfsHandlerObj); + installHooks(); } } @@ -95,11 +98,192 @@ function deregisterVFS(vfs) { if (index === -1) return; ArrayPrototypeSplice(activeVFSList, index, 1); debug('deregister active=%d', activeVFSList.length); + // 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) { - setVfsHandlers(null); + 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(); + const helpers = require('internal/modules/helpers'); + helpers.clearRealpathCache(); + const { clearPackageJSONCache } = require('internal/modules/package_json_reader'); + clearPackageJSONCache(); + // 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. +} + +/** + * 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]. + * + * 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) { + 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); + } + + return [name, main, type, imports, exports, filePath]; +} + +/** + * 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, 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, sentinel: pjsonPath }; + } catch { + // SyntaxError or other errors, continue walking + } + } + } + lastDir = currentDir; + currentDir = dirname(currentDir); + } + return { sentinel }; +} + function findVFSForExists(filename) { const normalized = resolve(filename); for (let i = 0; i < activeVFSList.length; i++) { @@ -655,6 +839,229 @@ 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; + } + + // 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) { + let result; + try { + 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 + }, + }); + + 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; + let content; + try { + 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) { + // 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); + }, + 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.parsed !== undefined) { + 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.parsed !== undefined) { + return serializePackageJSON(found.parsed, found.pjsonPath); + } + // 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); + }, + 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.parsed !== undefined) { + // 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; + } + } + 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; +} + +/** + * 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-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-cleanup.js b/test/parallel/test-vfs-module-hooks-cleanup.js new file mode 100644 index 00000000000000..acf15fbb25e762 --- /dev/null +++ b/test/parallel/test-vfs-module-hooks-cleanup.js @@ -0,0 +1,115 @@ +// Flags: --experimental-vfs +'use strict'; + +// Regression coverage for VFS-to-module-loader hook cleanup, package.json +// validation parity with the C++ binding, and cache scoping across +// register/deregister cycles. + +const common = 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(); + const fs = require('fs'); + assert.strictEqual(typeof fs.readFileSync, 'function'); +} + +// 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'); + v.writeFileSync('/pkg/package.json', 'null'); + v.writeFileSync('/pkg/index.js', 'module.exports = 1'); + v.mount('/mnt-null-pjson'); + assert.throws( + () => require('/mnt-null-pjson/pkg'), + { code: 'ERR_INVALID_PACKAGE_CONFIG' }, + ); + v.unmount(); +} + +// 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": 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. 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 }); + v.writeFileSync( + '/app/node_modules/badpkg/package.json', '{"main": "./nope.js"}'); + v.writeFileSync('/app/entry.mjs', "import 'badpkg';"); + v.mount('/mnt-legacy-err'); + await assert.rejects( + () => import('/mnt-legacy-err/app/entry.mjs'), + (err) => { + assert.strictEqual(err.code, 'ERR_MODULE_NOT_FOUND'); + assert.match(err.message, /nope\.js/); + assert.match(err.message, /^Cannot find package /); + assert.strictEqual(err.url, undefined); + return true; + }, + ); + v.unmount(); +})().then(common.mustCall()); 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' }); +}