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..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' }); +}