From c7e8efca370fa871675fc3028c643092ca692b70 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Tue, 7 Apr 2026 15:57:38 +0200 Subject: [PATCH 1/4] fix: resolve multiple module resolution issues in VFS hooks This fixes several module resolution issues found when using VFS with real-world applications (e.g., mongoose, pino, got): 1. **Built-in module shadowing**: Bare specifiers like `require('buffer')` were resolved to userland polyfills in node_modules instead of Node.js built-ins. Added `isNodeBuiltin()` check before VFS resolution for both ESM and CJS hooks. 2. **File-before-directory resolution order**: When both `schema.js` and `schema/` directory exist, `require('./schema')` incorrectly resolved to `schema/index.js` instead of `schema.js`. Reordered to try file extensions before directory entry resolution, matching Node.js CJS resolution order. 3. **require.resolve() not intercepted**: `Module.registerHooks()` in Node.js 22 does not intercept `require.resolve()` calls. The `_resolveFilename` patch is now always installed alongside `registerHooks`, not as a mutually exclusive fallback. 4. **Trailing slash in specifiers**: `require('process/')` produced `packageSubpath: './'` which didn't match the `=== '.'` check, preventing entry point resolution. 5. **Main pointing to directory**: When `package.json` has `"main": "dist/source"` and that path is a directory, the resolver now tries `index.js` inside it (matching Node.js behavior). Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/module_hooks.js | 85 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 66 insertions(+), 19 deletions(-) diff --git a/lib/module_hooks.js b/lib/module_hooks.js index e4cb823..beb8394 100644 --- a/lib/module_hooks.js +++ b/lib/module_hooks.js @@ -399,6 +399,11 @@ function resolveDirectoryEntry(vfs, dirPath, context) { const format = getVFSFormat(vfs, withExt); return { url, format, shortCircuit: true }; } + // main points to a directory — try index files inside it + if (vfs.internalModuleStat(mainPath) === 1) { + const mainIndexResult = tryIndexFiles(vfs, mainPath); + if (mainIndexResult) return mainIndexResult; + } } } catch { // Invalid package.json @@ -417,8 +422,10 @@ function parsePackageName(specifier) { } const packageName = separatorIndex === -1 ? specifier : specifier.slice(0, separatorIndex); - const packageSubpath = separatorIndex === -1 ? + let packageSubpath = separatorIndex === -1 ? '.' : '.' + specifier.slice(separatorIndex); + // Normalize './' to '.' so require('process/') resolves the entry point + if (packageSubpath === './') packageSubpath = '.'; return { packageName, packageSubpath }; } @@ -438,18 +445,18 @@ function resolveVFSPath(checkPath, context, nextResolve, specifier) { const stat = vfs.internalModuleStat(normalized); + // Exact file match if (stat === 0) { const url = vfsPathToURL(normalized); const format = getVFSFormat(vfs, normalized); return { url, format, shortCircuit: true }; } - if (stat === 1) { - const resolved = resolveDirectoryEntry(vfs, normalized, context); - if (resolved) return resolved; - } - - if (stat !== 1) { + // Try extensions BEFORE directory resolution to match Node.js CJS + // resolution order: file.js > file.json > dir/package.json#main > dir/index.js + // This handles cases like require('./schema') where both schema.js + // and schema/ directory exist — the file should win. + { const withExt = tryExtensions(vfs, normalized); if (withExt) { const url = vfsPathToURL(withExt); @@ -457,6 +464,11 @@ function resolveVFSPath(checkPath, context, nextResolve, specifier) { return { url, format, shortCircuit: true }; } } + + if (stat === 1) { + const resolved = resolveDirectoryEntry(vfs, normalized, context); + if (resolved) return resolved; + } } return nextResolve(specifier, context); @@ -465,7 +477,6 @@ function resolveVFSPath(checkPath, context, nextResolve, specifier) { function resolvePackageInVFS(vfs, startDir, packageName, packageSubpath, context) { let currentDir = startDir; let lastDir; - while (currentDir !== lastDir) { const pkgDir = normalizeVFSPath( joinVFSParts(currentDir, 'node_modules', packageName)); @@ -500,6 +511,11 @@ function resolvePackageInVFS(vfs, startDir, packageName, packageSubpath, context const format = getVFSFormat(vfs, withExt); return { url, format, shortCircuit: true }; } + // main points to a directory — try index files inside it + if (vfs.internalModuleStat(mainPath) === 1) { + const mainIndexResult = tryIndexFiles(vfs, mainPath); + if (mainIndexResult) return mainIndexResult; + } } const indexResult = tryIndexFiles(vfs, pkgDir); if (indexResult) return indexResult; @@ -555,6 +571,17 @@ function resolveCJSPackageInVFS(vfs, startDir, packageName, packageSubpath) { } const withExt = tryExtensions(vfs, mainPath); if (withExt) return withExt; + // main points to a directory — try index files inside it + if (vfs.internalModuleStat(mainPath) === 1) { + const mainIndexFiles = ['index.js', 'index.json', 'index.node']; + for (const idx of mainIndexFiles) { + const candidate = normalizeVFSPath( + joinVFSParts(mainPath, idx)); + if (vfs.internalModuleStat(candidate) === 0) { + return candidate; + } + } + } } } catch { /* ignore */ } } @@ -584,7 +611,7 @@ function resolveCJSPackageInVFS(vfs, startDir, packageName, packageSubpath) { } function resolveBareSpecifier(specifier, context, nextResolve) { - if (specifier[0] === '#') { + if (specifier[0] === '#' || isNodeBuiltin(specifier)) { return nextResolve(specifier, context); } @@ -634,8 +661,17 @@ function resolveBareSpecifier(specifier, context, nextResolve) { // === Hooks === +function isNodeBuiltin(name) { + const Module = require('node:module'); + if (typeof Module.isBuiltin === 'function') { + return Module.isBuiltin(name); + } + const bare = name.startsWith('node:') ? name.slice(5) : name; + return Module.builtinModules.indexOf(bare) !== -1; +} + function vfsResolveHook(specifier, context, nextResolve) { - if (specifier.startsWith('node:')) { + if (specifier.startsWith('node:') || isNodeBuiltin(specifier)) { return nextResolve(specifier, context); } @@ -700,16 +736,17 @@ function vfsLoadHook(url, context, nextLoad) { function installModuleHooks() { const Module = require('node:module'); - // Use Module.registerHooks if available (Node.js 23.5+) + // Use Module.registerHooks if available (Node.js 23.5+) for ESM support. + // Note: registerHooks does NOT intercept require.resolve() in Node.js 22, + // so we always install the _resolveFilename patch below as well. if (typeof Module.registerHooks === 'function') { Module.registerHooks({ resolve: vfsResolveHook, load: vfsLoadHook, }); - return; } - // Fallback: patch Module._resolveFilename for CJS + // Always patch Module._resolveFilename for CJS require.resolve() support const origResolveFilename = Module._resolveFilename; Module._resolveFilename = function(request, parent, isMain, options) { if (request.startsWith('node:')) { @@ -734,6 +771,11 @@ function installModuleHooks() { const stat = vfs.internalModuleStat(normalized); if (stat === 0) return normalized; + // Try extensions BEFORE directory resolution to match Node.js + // resolution order: file.js > file.json > dir/package.json#main > dir/index.js + const withExt = tryExtensions(vfs, normalized); + if (withExt) return withExt; + if (stat === 1) { // Try package.json main / index files const pjsonPath = normalizeVFSPath(resolve(normalized, 'package.json')); @@ -744,8 +786,16 @@ function installModuleHooks() { if (parsed.main) { const mainPath = normalizeVFSPath(resolve(normalized, parsed.main)); if (vfs.internalModuleStat(mainPath) === 0) return mainPath; - const withExt = tryExtensions(vfs, mainPath); - if (withExt) return withExt; + const mainWithExt = tryExtensions(vfs, mainPath); + if (mainWithExt) return mainWithExt; + // main points to a directory — try index files inside it + if (vfs.internalModuleStat(mainPath) === 1) { + const mainIdxFiles = ['index.js', 'index.json', 'index.node']; + for (const idx of mainIdxFiles) { + const c = normalizeVFSPath(resolve(mainPath, idx)); + if (vfs.internalModuleStat(c) === 0) return c; + } + } } } catch { // ignore @@ -757,14 +807,11 @@ function installModuleHooks() { if (vfs.internalModuleStat(candidate) === 0) return candidate; } } - - const withExt = tryExtensions(vfs, normalized); - if (withExt) return withExt; } } // Bare specifier - walk node_modules - if (!isAbsolute(request) && request[0] !== '.') { + if (!isAbsolute(request) && request[0] !== '.' && !isNodeBuiltin(request)) { const parentDir = parent?.filename ? dirname(parent.filename) : process.cwd(); const parentNorm = normalizeVFSPath(parentDir); const { packageName, packageSubpath } = parsePackageName(request); From 7446e2ef4b3301e356dd77ae1f1288aa63edf832 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Tue, 7 Apr 2026 16:13:42 +0200 Subject: [PATCH 2/4] refactor: DRY module resolution helpers and add tests - Extract CJS_INDEX_FILES constant and tryCJSIndexFiles() helper to eliminate 4 duplicated inline index-file loops - Extract makeResolveResult() helper to replace ~15 repeated { url, format, shortCircuit } object constructions - Cache NodeModule reference and use Set for O(1) builtin lookups in isNodeBuiltin() instead of require() + indexOf on every call - Remove unnecessary bare block in resolveVFSPath - Document double-resolution trade-off when registerHooks and _resolveFilename coexist, and ESM vs CJS index file divergence - Add test/module_resolution.test.js covering all 5 fixes: built-in shadowing, file-before-directory, require.resolve(), trailing slash, and main-points-to-directory Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/module_hooks.js | 149 ++++++++++++++------------------- test/module_resolution.test.js | 138 ++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 87 deletions(-) create mode 100644 test/module_resolution.test.js diff --git a/lib/module_hooks.js b/lib/module_hooks.js index beb8394..73c1241 100644 --- a/lib/module_hooks.js +++ b/lib/module_hooks.js @@ -7,6 +7,8 @@ const { pathToFileURL, fileURLToPath } = require('node:url'); const { createENOENT } = require('./errors.js'); const kEmptyObject = Object.freeze(Object.create(null)); +const NodeModule = require('node:module'); +const builtinSet = new Set(NodeModule.builtinModules); function normalizeVFSPath(inputPath) { // Strip sentinel V: drive used for VFS URLs on Windows @@ -275,6 +277,19 @@ function getVFSFormat(vfs, filePath) { return VFS_FORMAT_MAP[ext] ?? 'commonjs'; } +// CJS index files follow Node.js require() resolution order. +// ESM index files (in tryIndexFiles) additionally include .mjs/.cjs +// because ESM hooks handle all module formats. +const CJS_INDEX_FILES = ['index.js', 'index.json', 'index.node']; + +function makeResolveResult(vfs, filePath) { + return { + url: vfsPathToURL(filePath), + format: getVFSFormat(vfs, filePath), + shortCircuit: true, + }; +} + function tryExtensions(vfs, basePath) { const extensions = ['.js', '.json', '.node', '.mjs', '.cjs']; for (let i = 0; i < extensions.length; i++) { @@ -291,9 +306,18 @@ function tryIndexFiles(vfs, dirPath) { for (let i = 0; i < indexFiles.length; i++) { const candidate = normalizeVFSPath(resolve(dirPath, indexFiles[i])); if (vfs.internalModuleStat(candidate) === 0) { - const url = vfsPathToURL(candidate); - const format = getVFSFormat(vfs, candidate); - return { url, format, shortCircuit: true }; + return makeResolveResult(vfs, candidate); + } + } + return null; +} + +function tryCJSIndexFiles(vfs, dirPath) { + for (let i = 0; i < CJS_INDEX_FILES.length; i++) { + const candidate = normalizeVFSPath( + joinVFSParts(dirPath, CJS_INDEX_FILES[i])); + if (vfs.internalModuleStat(candidate) === 0) { + return candidate; } } return null; @@ -308,9 +332,7 @@ function resolveConditions(vfs, pkgDir, condMap, conditions) { if (typeof value === 'string') { const resolved = normalizeVFSPath(resolve(pkgDir, value)); if (vfs.internalModuleStat(resolved) === 0) { - const url = vfsPathToURL(resolved); - const format = getVFSFormat(vfs, resolved); - return { url, format, shortCircuit: true }; + return makeResolveResult(vfs, resolved); } continue; } @@ -331,9 +353,7 @@ function resolvePackageExports(vfs, pkgDir, packageSubpath, exports, context) { if (packageSubpath === '.') { const resolved = normalizeVFSPath(resolve(pkgDir, exports)); if (vfs.internalModuleStat(resolved) === 0) { - const url = vfsPathToURL(resolved); - const format = getVFSFormat(vfs, resolved); - return { url, format, shortCircuit: true }; + return makeResolveResult(vfs, resolved); } } return null; @@ -358,9 +378,7 @@ function resolvePackageExports(vfs, pkgDir, packageSubpath, exports, context) { if (typeof target === 'string') { const resolved = normalizeVFSPath(resolve(pkgDir, target)); if (vfs.internalModuleStat(resolved) === 0) { - const url = vfsPathToURL(resolved); - const format = getVFSFormat(vfs, resolved); - return { url, format, shortCircuit: true }; + return makeResolveResult(vfs, resolved); } return null; } @@ -389,16 +407,10 @@ function resolveDirectoryEntry(vfs, dirPath, context) { if (parsed.main) { const mainPath = normalizeVFSPath(resolve(dirPath, parsed.main)); if (vfs.internalModuleStat(mainPath) === 0) { - const url = vfsPathToURL(mainPath); - const format = getVFSFormat(vfs, mainPath); - return { url, format, shortCircuit: true }; + return makeResolveResult(vfs, mainPath); } const withExt = tryExtensions(vfs, mainPath); - if (withExt) { - const url = vfsPathToURL(withExt); - const format = getVFSFormat(vfs, withExt); - return { url, format, shortCircuit: true }; - } + if (withExt) return makeResolveResult(vfs, withExt); // main points to a directory — try index files inside it if (vfs.internalModuleStat(mainPath) === 1) { const mainIndexResult = tryIndexFiles(vfs, mainPath); @@ -447,23 +459,15 @@ function resolveVFSPath(checkPath, context, nextResolve, specifier) { // Exact file match if (stat === 0) { - const url = vfsPathToURL(normalized); - const format = getVFSFormat(vfs, normalized); - return { url, format, shortCircuit: true }; + return makeResolveResult(vfs, normalized); } // Try extensions BEFORE directory resolution to match Node.js CJS // resolution order: file.js > file.json > dir/package.json#main > dir/index.js // This handles cases like require('./schema') where both schema.js // and schema/ directory exist — the file should win. - { - const withExt = tryExtensions(vfs, normalized); - if (withExt) { - const url = vfsPathToURL(withExt); - const format = getVFSFormat(vfs, withExt); - return { url, format, shortCircuit: true }; - } - } + const withExt = tryExtensions(vfs, normalized); + if (withExt) return makeResolveResult(vfs, withExt); if (stat === 1) { const resolved = resolveDirectoryEntry(vfs, normalized, context); @@ -501,16 +505,10 @@ function resolvePackageInVFS(vfs, startDir, packageName, packageSubpath, context const mainPath = normalizeVFSPath( joinVFSParts(pkgDir, parsed.main)); if (vfs.internalModuleStat(mainPath) === 0) { - const url = vfsPathToURL(mainPath); - const format = getVFSFormat(vfs, mainPath); - return { url, format, shortCircuit: true }; + return makeResolveResult(vfs, mainPath); } const withExt = tryExtensions(vfs, mainPath); - if (withExt) { - const url = vfsPathToURL(withExt); - const format = getVFSFormat(vfs, withExt); - return { url, format, shortCircuit: true }; - } + if (withExt) return makeResolveResult(vfs, withExt); // main points to a directory — try index files inside it if (vfs.internalModuleStat(mainPath) === 1) { const mainIndexResult = tryIndexFiles(vfs, mainPath); @@ -523,16 +521,10 @@ function resolvePackageInVFS(vfs, startDir, packageName, packageSubpath, context const subResolved = normalizeVFSPath( joinVFSParts(pkgDir, packageSubpath)); if (vfs.internalModuleStat(subResolved) === 0) { - const url = vfsPathToURL(subResolved); - const format = getVFSFormat(vfs, subResolved); - return { url, format, shortCircuit: true }; + return makeResolveResult(vfs, subResolved); } const withExt = tryExtensions(vfs, subResolved); - if (withExt) { - const url = vfsPathToURL(withExt); - const format = getVFSFormat(vfs, withExt); - return { url, format, shortCircuit: true }; - } + if (withExt) return makeResolveResult(vfs, withExt); } } catch { // Invalid package.json, continue walking @@ -573,26 +565,14 @@ function resolveCJSPackageInVFS(vfs, startDir, packageName, packageSubpath) { if (withExt) return withExt; // main points to a directory — try index files inside it if (vfs.internalModuleStat(mainPath) === 1) { - const mainIndexFiles = ['index.js', 'index.json', 'index.node']; - for (const idx of mainIndexFiles) { - const candidate = normalizeVFSPath( - joinVFSParts(mainPath, idx)); - if (vfs.internalModuleStat(candidate) === 0) { - return candidate; - } - } + const mainIdx = tryCJSIndexFiles(vfs, mainPath); + if (mainIdx) return mainIdx; } } } catch { /* ignore */ } } - const indexFiles = ['index.js', 'index.json', 'index.node']; - for (const idx of indexFiles) { - const candidate = normalizeVFSPath( - joinVFSParts(pkgDir, idx)); - if (vfs.internalModuleStat(candidate) === 0) { - return candidate; - } - } + const idxResult = tryCJSIndexFiles(vfs, pkgDir); + if (idxResult) return idxResult; } else { const subResolved = normalizeVFSPath( joinVFSParts(pkgDir, packageSubpath)); @@ -662,12 +642,11 @@ function resolveBareSpecifier(specifier, context, nextResolve) { // === Hooks === function isNodeBuiltin(name) { - const Module = require('node:module'); - if (typeof Module.isBuiltin === 'function') { - return Module.isBuiltin(name); + if (typeof NodeModule.isBuiltin === 'function') { + return NodeModule.isBuiltin(name); } const bare = name.startsWith('node:') ? name.slice(5) : name; - return Module.builtinModules.indexOf(bare) !== -1; + return builtinSet.has(bare); } function vfsResolveHook(specifier, context, nextResolve) { @@ -734,21 +713,23 @@ function vfsLoadHook(url, context, nextLoad) { } function installModuleHooks() { - const Module = require('node:module'); - // Use Module.registerHooks if available (Node.js 23.5+) for ESM support. // Note: registerHooks does NOT intercept require.resolve() in Node.js 22, // so we always install the _resolveFilename patch below as well. - if (typeof Module.registerHooks === 'function') { - Module.registerHooks({ + // When both are active, require() calls resolve through both paths — + // _resolveFilename runs first, then registerHooks' resolve hook sees the + // already-resolved path and short-circuits (stat === 0). The overhead is + // minimal since VFS stat checks are in-memory. + if (typeof NodeModule.registerHooks === 'function') { + NodeModule.registerHooks({ resolve: vfsResolveHook, load: vfsLoadHook, }); } // Always patch Module._resolveFilename for CJS require.resolve() support - const origResolveFilename = Module._resolveFilename; - Module._resolveFilename = function(request, parent, isMain, options) { + const origResolveFilename = NodeModule._resolveFilename; + NodeModule._resolveFilename = function(request, parent, isMain, options) { if (request.startsWith('node:')) { return origResolveFilename.call(this, request, parent, isMain, options); } @@ -790,22 +771,16 @@ function installModuleHooks() { if (mainWithExt) return mainWithExt; // main points to a directory — try index files inside it if (vfs.internalModuleStat(mainPath) === 1) { - const mainIdxFiles = ['index.js', 'index.json', 'index.node']; - for (const idx of mainIdxFiles) { - const c = normalizeVFSPath(resolve(mainPath, idx)); - if (vfs.internalModuleStat(c) === 0) return c; - } + const mainIdx = tryCJSIndexFiles(vfs, mainPath); + if (mainIdx) return mainIdx; } } } catch { // ignore } } - const indexFiles = ['index.js', 'index.json', 'index.node']; - for (const idx of indexFiles) { - const candidate = normalizeVFSPath(resolve(normalized, idx)); - if (vfs.internalModuleStat(candidate) === 0) return candidate; - } + const idxResult = tryCJSIndexFiles(vfs, normalized); + if (idxResult) return idxResult; } } } @@ -846,8 +821,8 @@ function installModuleHooks() { }; // Patch Module._extensions to read from VFS - const origJsHandler = Module._extensions['.js']; - Module._extensions['.js'] = function(module, filename) { + const origJsHandler = NodeModule._extensions['.js']; + NodeModule._extensions['.js'] = function(module, filename) { const normalized = normalizeVFSPath(filename); for (let i = 0; i < activeVFSList.length; i++) { const vfs = activeVFSList[i]; @@ -860,8 +835,8 @@ function installModuleHooks() { return origJsHandler.call(this, module, filename); }; - const origJsonHandler = Module._extensions['.json']; - Module._extensions['.json'] = function(module, filename) { + const origJsonHandler = NodeModule._extensions['.json']; + NodeModule._extensions['.json'] = function(module, filename) { const normalized = normalizeVFSPath(filename); for (let i = 0; i < activeVFSList.length; i++) { const vfs = activeVFSList[i]; diff --git a/test/module_resolution.test.js b/test/module_resolution.test.js new file mode 100644 index 0000000..d061d92 --- /dev/null +++ b/test/module_resolution.test.js @@ -0,0 +1,138 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { create, SqliteProvider } = require('../index.js'); + +// Helper: create a mounted VFS with given files, run fn, then clean up. +function withVFS(files, fn) { + const provider = new SqliteProvider(); + const vfs = create(provider); + for (const [path, content] of Object.entries(files)) { + const dir = path.slice(0, path.lastIndexOf('/')); + if (dir && dir !== '/') { + vfs.mkdirSync(dir, { recursive: true }); + } + vfs.writeFileSync(path, content); + } + vfs.mount('/'); + try { + fn(vfs); + } finally { + // Clean up require cache for all VFS paths + for (const key of Object.keys(require.cache)) { + if (key.startsWith('/node_modules/') || key.startsWith('/app/')) { + delete require.cache[key]; + } + } + vfs.unmount(); + provider.close(); + } +} + +describe('Module resolution — built-in shadowing', () => { + it('require("buffer") resolves to Node.js built-in, not userland polyfill', () => { + withVFS({ + '/node_modules/buffer/index.js': + 'module.exports = { __vfsPolyfill: true };', + '/node_modules/buffer/package.json': + '{"name":"buffer","main":"index.js"}', + }, () => { + const buf = require('buffer'); + // Node.js built-in buffer module exports Buffer constructor + assert.ok(buf.Buffer, 'should have Buffer from built-in'); + assert.strictEqual(buf.__vfsPolyfill, undefined, + 'should NOT resolve to userland polyfill'); + }); + }); + + it('require("node:buffer") still resolves to built-in', () => { + withVFS({ + '/node_modules/buffer/index.js': + 'module.exports = { __vfsPolyfill: true };', + '/node_modules/buffer/package.json': + '{"name":"buffer","main":"index.js"}', + }, () => { + const buf = require('node:buffer'); + assert.ok(buf.Buffer); + assert.strictEqual(buf.__vfsPolyfill, undefined); + }); + }); +}); + +describe('Module resolution — file-before-directory', () => { + it('require("./schema") resolves to schema.js when both schema.js and schema/ exist', () => { + withVFS({ + '/app/schema.js': + 'module.exports = "file";', + '/app/schema/index.js': + 'module.exports = "directory";', + '/app/entry.js': + 'module.exports = require("./schema");', + '/app/package.json': + '{"name":"app","main":"entry.js"}', + }, () => { + const result = require('/app/entry.js'); + assert.strictEqual(result, 'file', + 'file.js should take precedence over directory/index.js'); + }); + }); +}); + +describe('Module resolution — require.resolve() interception', () => { + it('require.resolve() resolves packages inside VFS', () => { + withVFS({ + '/node_modules/vfs-resolve-test/index.js': + 'module.exports = 42;', + '/node_modules/vfs-resolve-test/package.json': + '{"name":"vfs-resolve-test","main":"index.js"}', + }, () => { + const resolved = require.resolve('vfs-resolve-test'); + assert.strictEqual(resolved, '/node_modules/vfs-resolve-test/index.js'); + }); + }); +}); + +describe('Module resolution — trailing slash in specifiers', () => { + it('require("process/") resolves the package entry point', () => { + // Simulates the pattern used by readable-stream: require('process/') + // where packageSubpath becomes './' and must be normalized to '.' + withVFS({ + '/node_modules/vfs-trailing-slash/index.js': + 'module.exports = { trailingSlash: true };', + '/node_modules/vfs-trailing-slash/package.json': + '{"name":"vfs-trailing-slash","main":"index.js"}', + }, () => { + const mod = require('vfs-trailing-slash/'); + assert.deepStrictEqual(mod, { trailingSlash: true }); + }); + }); +}); + +describe('Module resolution — main pointing to directory', () => { + it('resolves index.js when package.json main points to a directory', () => { + // Simulates packages like got v11: "main": "dist/source" + // where dist/source is a directory containing index.js + withVFS({ + '/node_modules/vfs-main-dir/package.json': + '{"name":"vfs-main-dir","main":"dist/source"}', + '/node_modules/vfs-main-dir/dist/source/index.js': + 'module.exports = { mainDir: true };', + }, () => { + const mod = require('vfs-main-dir'); + assert.deepStrictEqual(mod, { mainDir: true }); + }); + }); + + it('resolves index.json when main directory has no index.js', () => { + withVFS({ + '/node_modules/vfs-main-dir-json/package.json': + '{"name":"vfs-main-dir-json","main":"lib"}', + '/node_modules/vfs-main-dir-json/lib/index.json': + '{"fromJson":true}', + }, () => { + const mod = require('vfs-main-dir-json'); + assert.deepStrictEqual(mod, { fromJson: true }); + }); + }); +}); From f2ebe9b4532a734ca2052200bf575da7fae24fbc Mon Sep 17 00:00:00 2001 From: robertsLando Date: Wed, 8 Apr 2026 11:52:23 +0200 Subject: [PATCH 3/4] feat: add wildcard exports, #imports, CJS exports field, and CJS subpath directory support - Wildcard pattern support in package.json exports (e.g. "./bindings/*") for both ESM and CJS resolution, per Node.js subpath patterns spec. - Package #imports (subpath imports) with wildcard and bare specifier re-resolution support via new resolveHashImport() function. - CJS exports field resolution with dedicated resolveCJSExportsPath, resolveCJSConditions, and resolveCJSPackageExports helpers. Exports field is now checked before main/index fallback in CJS resolution. - CJS subpath directory resolution: require('pkg/subdir') now checks subdir/package.json main and subdir/index.js when subpath is a dir. - Extracted matchWildcardPattern() shared helper to eliminate 3x duplicate wildcard matching logic. - Single package.json read in resolveCJSPackageInVFS (was reading twice). - Fixed dirname() -> dirnameVFS() bug in resolveBareSpecifier for correct Windows VFS path handling. - 16 new tests covering all new resolution features. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/module_hooks.js | 299 +++++++++++++++++++++++++++++---- test/module_resolution.test.js | 264 +++++++++++++++++++++++++++++ 2 files changed, 534 insertions(+), 29 deletions(-) diff --git a/lib/module_hooks.js b/lib/module_hooks.js index 73c1241..078ad78 100644 --- a/lib/module_hooks.js +++ b/lib/module_hooks.js @@ -282,6 +282,30 @@ function getVFSFormat(vfs, filePath) { // because ESM hooks handle all module formats. const CJS_INDEX_FILES = ['index.js', 'index.json', 'index.node']; +// Match a subpath against wildcard export/import keys. +// Returns { key, patternMatch } on match, or null. +function matchWildcardPattern(keys, subpath) { + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const starIdx = key.indexOf('*'); + if (starIdx === -1) continue; + const prefix = key.slice(0, starIdx); + const suffix = key.slice(starIdx + 1); + if (subpath.startsWith(prefix) && + (suffix === '' || subpath.endsWith(suffix)) && + subpath.length >= prefix.length + suffix.length) { + return { + key, + patternMatch: subpath.slice( + prefix.length, + suffix.length > 0 ? -suffix.length : undefined, + ), + }; + } + } + return null; +} + function makeResolveResult(vfs, filePath) { return { url: vfsPathToURL(filePath), @@ -324,20 +348,25 @@ function tryCJSIndexFiles(vfs, dirPath) { } function resolveConditions(vfs, pkgDir, condMap, conditions) { + return resolveConditionsWithPattern(vfs, pkgDir, condMap, conditions, null); +} + +function resolveConditionsWithPattern(vfs, pkgDir, condMap, conditions, patternMatch) { const keys = Object.getOwnPropertyNames(condMap); for (let i = 0; i < keys.length; i++) { const key = keys[i]; if (key === 'default' || conditions.indexOf(key) !== -1) { const value = condMap[key]; if (typeof value === 'string') { - const resolved = normalizeVFSPath(resolve(pkgDir, value)); + const expanded = patternMatch !== null ? value.replace(/\*/g, patternMatch) : value; + const resolved = normalizeVFSPath(resolve(pkgDir, expanded)); if (vfs.internalModuleStat(resolved) === 0) { return makeResolveResult(vfs, resolved); } continue; } if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - const result = resolveConditions(vfs, pkgDir, value, conditions); + const result = resolveConditionsWithPattern(vfs, pkgDir, value, conditions, patternMatch); if (result) return result; continue; } @@ -372,11 +401,23 @@ function resolvePackageExports(vfs, pkgDir, packageSubpath, exports, context) { return resolveConditions(vfs, pkgDir, exports, conditions); } - const target = exports[packageSubpath]; + let target = exports[packageSubpath]; + + // Support wildcard patterns in exports (e.g. "./bindings/*") + // See: https://nodejs.org/api/packages.html#subpath-patterns + let patternMatch = null; + if (target === undefined) { + const match = matchWildcardPattern(keys, packageSubpath); + if (match) { + patternMatch = match.patternMatch; + target = exports[match.key]; + } + } if (target === undefined) return null; if (typeof target === 'string') { - const resolved = normalizeVFSPath(resolve(pkgDir, target)); + const expanded = patternMatch !== null ? target.replace(/\*/g, patternMatch) : target; + const resolved = normalizeVFSPath(resolve(pkgDir, expanded)); if (vfs.internalModuleStat(resolved) === 0) { return makeResolveResult(vfs, resolved); } @@ -385,7 +426,7 @@ function resolvePackageExports(vfs, pkgDir, packageSubpath, exports, context) { if (typeof target === 'object' && target !== null) { if (Array.isArray(target)) return null; - return resolveConditions(vfs, pkgDir, target, conditions); + return resolveConditionsWithPattern(vfs, pkgDir, target, conditions, patternMatch); } return null; @@ -539,6 +580,71 @@ function resolvePackageInVFS(vfs, startDir, packageName, packageSubpath, context return null; } +function resolveCJSExportsPath(vfs, pkgDir, target, patternMatch) { + if (typeof target !== 'string') return null; + const expanded = patternMatch !== null ? target.replace(/\*/g, patternMatch) : target; + const resolved = normalizeVFSPath(resolve(pkgDir, expanded)); + if (vfs.internalModuleStat(resolved) === 0) return resolved; + const withExt = tryExtensions(vfs, resolved); + if (withExt) return withExt; + return null; +} + +function resolveCJSConditions(vfs, pkgDir, condMap, patternMatch) { + const keys = Object.getOwnPropertyNames(condMap); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key === 'default' || key === 'require' || key === 'node') { + const value = condMap[key]; + if (typeof value === 'string') { + const result = resolveCJSExportsPath(vfs, pkgDir, value, patternMatch); + if (result) return result; + continue; + } + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const result = resolveCJSConditions(vfs, pkgDir, value, patternMatch); + if (result) return result; + continue; + } + } + } + return null; +} + +function resolveCJSPackageExports(vfs, pkgDir, packageSubpath, exports) { + if (typeof exports === 'string') { + if (packageSubpath === '.') { + return resolveCJSExportsPath(vfs, pkgDir, exports, null); + } + return null; + } + if (typeof exports !== 'object' || exports === null) return null; + const keys = Object.getOwnPropertyNames(exports); + if (keys.length === 0) return null; + const isConditional = keys[0] !== '' && keys[0][0] !== '.'; + if (isConditional) { + if (packageSubpath !== '.') return null; + return resolveCJSConditions(vfs, pkgDir, exports, null); + } + let target = exports[packageSubpath]; + let patternMatch = null; + if (target === undefined) { + const match = matchWildcardPattern(keys, packageSubpath); + if (match) { + patternMatch = match.patternMatch; + target = exports[match.key]; + } + } + if (target === undefined) return null; + if (typeof target === 'string') { + return resolveCJSExportsPath(vfs, pkgDir, target, patternMatch); + } + if (typeof target === 'object' && target !== null && !Array.isArray(target)) { + return resolveCJSConditions(vfs, pkgDir, target, patternMatch); + } + return null; +} + function resolveCJSPackageInVFS(vfs, startDir, packageName, packageSubpath) { let currentDir = startDir; let lastDir; @@ -548,39 +654,73 @@ function resolveCJSPackageInVFS(vfs, startDir, packageName, packageSubpath) { joinVFSParts(currentDir, 'node_modules', packageName)); if (vfs.shouldHandle(pkgDir) && vfs.internalModuleStat(pkgDir) === 1) { + + // Read package.json once and reuse for exports + main resolution + const pjsonPath = normalizeVFSPath( + joinVFSParts(pkgDir, 'package.json')); + let parsed = null; + if (vfs.internalModuleStat(pjsonPath) === 0) { + try { + parsed = JSON.parse(vfs.readFileSync(pjsonPath, 'utf8')); + } catch { /* ignore */ } + } + + // Try exports field first (supports wildcards and conditions) + if (parsed?.exports != null) { + const exportsResult = resolveCJSPackageExports( + vfs, pkgDir, packageSubpath, parsed.exports); + if (exportsResult) return exportsResult; + } + if (packageSubpath === '.') { - const pjsonPath = normalizeVFSPath( - joinVFSParts(pkgDir, 'package.json')); - if (vfs.internalModuleStat(pjsonPath) === 0) { - try { - const content = vfs.readFileSync(pjsonPath, 'utf8'); - const parsed = JSON.parse(content); - if (parsed.main) { - const mainPath = normalizeVFSPath( - joinVFSParts(pkgDir, parsed.main)); - if (vfs.internalModuleStat(mainPath) === 0) { - return mainPath; - } - const withExt = tryExtensions(vfs, mainPath); - if (withExt) return withExt; - // main points to a directory — try index files inside it - if (vfs.internalModuleStat(mainPath) === 1) { - const mainIdx = tryCJSIndexFiles(vfs, mainPath); - if (mainIdx) return mainIdx; - } - } - } catch { /* ignore */ } + if (parsed?.main) { + const mainPath = normalizeVFSPath( + joinVFSParts(pkgDir, parsed.main)); + if (vfs.internalModuleStat(mainPath) === 0) { + return mainPath; + } + const withExt = tryExtensions(vfs, mainPath); + if (withExt) return withExt; + // main points to a directory — try index files inside it + if (vfs.internalModuleStat(mainPath) === 1) { + const mainIdx = tryCJSIndexFiles(vfs, mainPath); + if (mainIdx) return mainIdx; + } } const idxResult = tryCJSIndexFiles(vfs, pkgDir); if (idxResult) return idxResult; } else { const subResolved = normalizeVFSPath( joinVFSParts(pkgDir, packageSubpath)); - if (vfs.internalModuleStat(subResolved) === 0) { + const subStat = vfs.internalModuleStat(subResolved); + if (subStat === 0) { return subResolved; } const withExt = tryExtensions(vfs, subResolved); if (withExt) return withExt; + // Subpath resolves to a directory — try package.json main, then index files + if (subStat === 1) { + const subPjsonPath = normalizeVFSPath( + joinVFSParts(subResolved, 'package.json')); + if (vfs.internalModuleStat(subPjsonPath) === 0) { + try { + const subParsed = JSON.parse(vfs.readFileSync(subPjsonPath, 'utf8')); + if (subParsed.main) { + const mainPath = normalizeVFSPath( + joinVFSParts(subResolved, subParsed.main)); + if (vfs.internalModuleStat(mainPath) === 0) return mainPath; + const mainExt = tryExtensions(vfs, mainPath); + if (mainExt) return mainExt; + if (vfs.internalModuleStat(mainPath) === 1) { + const mainIdx = tryCJSIndexFiles(vfs, mainPath); + if (mainIdx) return mainIdx; + } + } + } catch { /* ignore */ } + } + const subIdx = tryCJSIndexFiles(vfs, subResolved); + if (subIdx) return subIdx; + } } } lastDir = currentDir; @@ -590,11 +730,112 @@ function resolveCJSPackageInVFS(vfs, startDir, packageName, packageSubpath) { return null; } +function resolveHashImport(specifier, context, nextResolve) { + // Handle package #imports (subpath imports) for files inside the VFS + if (!context.parentURL) { + return nextResolve(specifier, context); + } + + let parentPath; + try { + parentPath = urlToPath(context.parentURL); + } catch { + return nextResolve(specifier, context); + } + + const parentNorm = normalizeVFSPath(parentPath); + let parentVfs = null; + for (let i = 0; i < activeVFSList.length; i++) { + if (activeVFSList[i].shouldHandle(parentNorm)) { + parentVfs = activeVFSList[i]; + break; + } + } + + if (!parentVfs) { + return nextResolve(specifier, context); + } + + // Walk up from parent to find nearest package.json with imports field + const conditions = context.conditions || []; + let currentDir = dirnameVFS(parentNorm); + let lastDir; + while (currentDir !== lastDir) { + const pjsonPath = normalizeVFSPath( + joinVFSParts(currentDir, 'package.json')); + if (parentVfs.shouldHandle(pjsonPath) && + parentVfs.internalModuleStat(pjsonPath) === 0) { + try { + const content = parentVfs.readFileSync(pjsonPath, 'utf8'); + const parsed = JSON.parse(content); + if (parsed.imports) { + let target = parsed.imports[specifier]; + let patternMatch = null; + + // Support wildcard patterns in imports + if (target === undefined) { + const impKeys = Object.getOwnPropertyNames(parsed.imports); + const match = matchWildcardPattern(impKeys, specifier); + if (match) { + patternMatch = match.patternMatch; + target = parsed.imports[match.key]; + } + } + + if (target !== undefined) { + if (typeof target === 'string') { + const expanded = patternMatch !== null ? target.replace(/\*/g, patternMatch) : target; + // If the target is a bare specifier (not relative), re-resolve it + if (!expanded.startsWith('.') && !expanded.startsWith('/')) { + return resolveBareSpecifier(expanded, context, nextResolve); + } + const resolved = normalizeVFSPath(resolve(currentDir, expanded)); + if (parentVfs.internalModuleStat(resolved) === 0) { + return makeResolveResult(parentVfs, resolved); + } + } + if (typeof target === 'object' && target !== null && !Array.isArray(target)) { + // Resolve conditions + const condKeys = Object.getOwnPropertyNames(target); + for (let i = 0; i < condKeys.length; i++) { + const ckey = condKeys[i]; + if (ckey === 'default' || conditions.indexOf(ckey) !== -1) { + const value = target[ckey]; + if (typeof value === 'string') { + const expanded = patternMatch !== null ? value.replace(/\*/g, patternMatch) : value; + // If the target is a bare specifier, re-resolve it + if (!expanded.startsWith('.') && !expanded.startsWith('/')) { + return resolveBareSpecifier(expanded, context, nextResolve); + } + const resolved = normalizeVFSPath(resolve(currentDir, expanded)); + if (parentVfs.internalModuleStat(resolved) === 0) { + return makeResolveResult(parentVfs, resolved); + } + } + } + } + } + } + break; // Found a package.json with imports — stop walking up + } + } catch { /* ignore invalid JSON */ } + } + lastDir = currentDir; + currentDir = dirnameVFS(currentDir); + } + + return nextResolve(specifier, context); +} + function resolveBareSpecifier(specifier, context, nextResolve) { - if (specifier[0] === '#' || isNodeBuiltin(specifier)) { + if (isNodeBuiltin(specifier)) { return nextResolve(specifier, context); } + if (specifier[0] === '#') { + return resolveHashImport(specifier, context, nextResolve); + } + if (!context.parentURL) { return nextResolve(specifier, context); } @@ -619,7 +860,7 @@ function resolveBareSpecifier(specifier, context, nextResolve) { if (parentVfs) { const result = resolvePackageInVFS( - parentVfs, dirname(parentNorm), + parentVfs, dirnameVFS(parentNorm), packageName, packageSubpath, context); if (result) return result; } else { diff --git a/test/module_resolution.test.js b/test/module_resolution.test.js index d061d92..5c0a813 100644 --- a/test/module_resolution.test.js +++ b/test/module_resolution.test.js @@ -136,3 +136,267 @@ describe('Module resolution — main pointing to directory', () => { }); }); }); + +describe('Module resolution — wildcard exports (CJS)', () => { + it('require("pkg/sub") resolves via "./*" wildcard export', () => { + withVFS({ + '/node_modules/vfs-wild/package.json': JSON.stringify({ + name: 'vfs-wild', + exports: { './*': { require: './build/cjs/*/index.js' } }, + }), + '/node_modules/vfs-wild/build/cjs/utils/index.js': + 'module.exports = { wildcard: true };', + }, () => { + const mod = require('vfs-wild/utils'); + assert.deepStrictEqual(mod, { wildcard: true }); + }); + }); + + it('direct export key takes priority over wildcard', () => { + withVFS({ + '/node_modules/vfs-wild-prio/package.json': JSON.stringify({ + name: 'vfs-wild-prio', + exports: { + './exact': { require: './exact.js' }, + './*': { require: './fallback/*.js' }, + }, + }), + '/node_modules/vfs-wild-prio/exact.js': + 'module.exports = "exact";', + '/node_modules/vfs-wild-prio/fallback/exact.js': + 'module.exports = "fallback";', + }, () => { + const mod = require('vfs-wild-prio/exact'); + assert.strictEqual(mod, 'exact'); + }); + }); + + it('wildcard with suffix matches correctly', () => { + withVFS({ + '/node_modules/vfs-wild-suffix/package.json': JSON.stringify({ + name: 'vfs-wild-suffix', + exports: { './features/*.json': './data/*.json' }, + }), + '/node_modules/vfs-wild-suffix/data/config.json': + '{"suffix":true}', + }, () => { + const mod = require('vfs-wild-suffix/features/config.json'); + assert.deepStrictEqual(mod, { suffix: true }); + }); + }); + + it('wildcard with conditional exports and string target', () => { + withVFS({ + '/node_modules/vfs-wild-str/package.json': JSON.stringify({ + name: 'vfs-wild-str', + exports: { './*': './lib/*.js' }, + }), + '/node_modules/vfs-wild-str/lib/foo.js': + 'module.exports = "str-wildcard";', + }, () => { + const mod = require('vfs-wild-str/foo'); + assert.strictEqual(mod, 'str-wildcard'); + }); + }); + + it('non-matching wildcard falls through', () => { + withVFS({ + '/node_modules/vfs-wild-miss/package.json': JSON.stringify({ + name: 'vfs-wild-miss', + exports: { './lib/*': './lib/*.js' }, + }), + '/node_modules/vfs-wild-miss/lib/thing.js': + 'module.exports = "ok";', + }, () => { + // "./other/thing" does not match "./lib/*" + assert.throws(() => require('vfs-wild-miss/other/thing')); + }); + }); +}); + +describe('Module resolution — CJS exports field', () => { + it('require("pkg/subpath") resolves via exports conditions', () => { + withVFS({ + '/node_modules/vfs-cjs-exp/package.json': JSON.stringify({ + name: 'vfs-cjs-exp', + exports: { + './sub': { require: './lib/sub.js', import: './esm/sub.js' }, + }, + }), + '/node_modules/vfs-cjs-exp/lib/sub.js': + 'module.exports = "cjs-sub";', + }, () => { + const mod = require('vfs-cjs-exp/sub'); + assert.strictEqual(mod, 'cjs-sub'); + }); + }); + + it('CJS exports takes priority over main field', () => { + withVFS({ + '/node_modules/vfs-cjs-prio/package.json': JSON.stringify({ + name: 'vfs-cjs-prio', + main: './old.js', + exports: { '.': { require: './new.js' } }, + }), + '/node_modules/vfs-cjs-prio/old.js': + 'module.exports = "old";', + '/node_modules/vfs-cjs-prio/new.js': + 'module.exports = "new";', + }, () => { + const mod = require('vfs-cjs-prio'); + assert.strictEqual(mod, 'new'); + }); + }); + + it('falls back to main when exports does not match', () => { + withVFS({ + '/node_modules/vfs-cjs-fall/package.json': JSON.stringify({ + name: 'vfs-cjs-fall', + main: './fallback.js', + exports: { './other': './other.js' }, + }), + '/node_modules/vfs-cjs-fall/fallback.js': + 'module.exports = "fallback";', + }, () => { + const mod = require('vfs-cjs-fall'); + assert.strictEqual(mod, 'fallback'); + }); + }); + + it('CJS exports with conditional string entry point', () => { + withVFS({ + '/node_modules/vfs-cjs-cond/package.json': JSON.stringify({ + name: 'vfs-cjs-cond', + exports: { require: './cjs.js', import: './esm.js' }, + }), + '/node_modules/vfs-cjs-cond/cjs.js': + 'module.exports = "cjs-entry";', + }, () => { + const mod = require('vfs-cjs-cond'); + assert.strictEqual(mod, 'cjs-entry'); + }); + }); +}); + +describe('Module resolution — CJS subpath directory', () => { + it('require("pkg/subdir") resolves subdir/index.js', () => { + withVFS({ + '/node_modules/vfs-subdir/package.json': + '{"name":"vfs-subdir"}', + '/node_modules/vfs-subdir/lib/index.js': + 'module.exports = "subdir-index";', + }, () => { + const mod = require('vfs-subdir/lib'); + assert.strictEqual(mod, 'subdir-index'); + }); + }); + + it('require("pkg/subdir") resolves subdir/package.json main', () => { + withVFS({ + '/node_modules/vfs-subdir-main/package.json': + '{"name":"vfs-subdir-main"}', + '/node_modules/vfs-subdir-main/sub/package.json': + '{"main":"./entry.js"}', + '/node_modules/vfs-subdir-main/sub/entry.js': + 'module.exports = "subdir-main";', + }, () => { + const mod = require('vfs-subdir-main/sub'); + assert.strictEqual(mod, 'subdir-main'); + }); + }); +}); + +describe('Module resolution — package #imports', () => { + it('#import resolves to a relative path', () => { + withVFS({ + '/node_modules/vfs-hash/package.json': JSON.stringify({ + name: 'vfs-hash', + imports: { '#config': './src/config.js' }, + main: './index.js', + }), + '/node_modules/vfs-hash/src/config.js': + 'module.exports = { hashImport: true };', + '/node_modules/vfs-hash/index.js': + 'module.exports = require("#config");', + }, () => { + const mod = require('vfs-hash'); + assert.deepStrictEqual(mod, { hashImport: true }); + }); + }); + + it('#import with conditions resolves based on context', () => { + withVFS({ + '/node_modules/vfs-hash-cond/package.json': JSON.stringify({ + name: 'vfs-hash-cond', + imports: { + '#util': { require: './cjs-util.js', import: './esm-util.js' }, + }, + main: './index.js', + }), + '/node_modules/vfs-hash-cond/cjs-util.js': + 'module.exports = "cjs-util";', + '/node_modules/vfs-hash-cond/index.js': + 'module.exports = require("#util");', + }, () => { + // CJS require should match the "require" condition + // or fall back to "default" + const mod = require('vfs-hash-cond'); + assert.strictEqual(mod, 'cjs-util'); + }); + }); + + it('#import with wildcard pattern', () => { + withVFS({ + '/node_modules/vfs-hash-wild/package.json': JSON.stringify({ + name: 'vfs-hash-wild', + imports: { '#bindings/*': './src/bindings/*.js' }, + main: './index.js', + }), + '/node_modules/vfs-hash-wild/src/bindings/fs.js': + 'module.exports = "fs-binding";', + '/node_modules/vfs-hash-wild/index.js': + 'module.exports = require("#bindings/fs");', + }, () => { + const mod = require('vfs-hash-wild'); + assert.strictEqual(mod, 'fs-binding'); + }); + }); + + it('#import that maps to a bare specifier re-resolves', () => { + withVFS({ + '/node_modules/vfs-hash-bare/package.json': JSON.stringify({ + name: 'vfs-hash-bare', + imports: { '#dep': 'vfs-hash-dep' }, + main: './index.js', + }), + '/node_modules/vfs-hash-bare/index.js': + 'module.exports = require("#dep");', + '/node_modules/vfs-hash-dep/package.json': + '{"name":"vfs-hash-dep","main":"./index.js"}', + '/node_modules/vfs-hash-dep/index.js': + 'module.exports = "from-dep";', + }, () => { + const mod = require('vfs-hash-bare'); + assert.strictEqual(mod, 'from-dep'); + }); + }); + + it('#import from nested file walks up to find package.json', () => { + withVFS({ + '/node_modules/vfs-hash-nested/package.json': JSON.stringify({ + name: 'vfs-hash-nested', + imports: { '#secret': './secret.js' }, + main: './index.js', + }), + '/node_modules/vfs-hash-nested/secret.js': + 'module.exports = "found";', + '/node_modules/vfs-hash-nested/index.js': + 'module.exports = require("./lib/deep");', + '/node_modules/vfs-hash-nested/lib/deep.js': + 'module.exports = require("#secret");', + }, () => { + const mod = require('vfs-hash-nested'); + assert.strictEqual(mod, 'found'); + }); + }); +}); From eee72a366f2854c1f5c1830ac67fd19784197cb3 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Wed, 8 Apr 2026 12:25:09 +0200 Subject: [PATCH 4/4] refactor: unify ESM/CJS resolution helpers and fix bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix dirname() → dirnameVFS() in getVFSPackageType for Windows VFS paths - Fix resolveHashImport nested conditions (now properly recursive) - Fix .mjs/.cjs extensions leaking into CJS resolution (parameterized) - Add CJS #imports support in _resolveFilename patch - Fix double internalModuleStat calls via resolveMainField helper - Unify resolvePackageExports/resolveCJSPackageExports into resolveExportsToPath - Unify resolveConditionsWithPattern/resolveCJSConditions into resolveConditionsToPath - Extract expandPattern, findVFSForPath, resolveMainField shared helpers - Consistent use of joinVFSParts/dirnameVFS in ESM paths - Move isNodeBuiltin to utility section, remove redundant node: check Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/module_hooks.js | 505 +++++++++++++++++---------------- test/module_resolution.test.js | 19 ++ 2 files changed, 277 insertions(+), 247 deletions(-) diff --git a/lib/module_hooks.js b/lib/module_hooks.js index 078ad78..2992140 100644 --- a/lib/module_hooks.js +++ b/lib/module_hooks.js @@ -10,6 +10,23 @@ const kEmptyObject = Object.freeze(Object.create(null)); const NodeModule = require('node:module'); const builtinSet = new Set(NodeModule.builtinModules); +// Extension and index file sets for ESM vs CJS resolution. +// ESM hooks handle all module formats so they include .mjs/.cjs. +// CJS follows Node.js require() resolution order: .js, .json, .node only. +const ESM_EXTENSIONS = ['.js', '.json', '.node', '.mjs', '.cjs']; +const CJS_EXTENSIONS = ['.js', '.json', '.node']; +const ESM_INDEX_FILES = ['index.js', 'index.mjs', 'index.cjs', 'index.json']; +const CJS_INDEX_FILES = ['index.js', 'index.json', 'index.node']; +const CJS_CONDITIONS = ['require', 'node', 'default']; + +function isNodeBuiltin(name) { + if (typeof NodeModule.isBuiltin === 'function') { + return NodeModule.isBuiltin(name); + } + const bare = name.startsWith('node:') ? name.slice(5) : name; + return builtinSet.has(bare); +} + function normalizeVFSPath(inputPath) { // Strip sentinel V: drive used for VFS URLs on Windows if (process.platform === 'win32' && /^V:[/\\]/.test(inputPath)) { @@ -243,14 +260,14 @@ const VFS_FORMAT_MAP = { }; function getVFSPackageType(vfs, filePath) { - let currentDir = dirname(filePath); + let currentDir = dirnameVFS(filePath); let lastDir; while (currentDir !== lastDir) { if (currentDir.endsWith('/node_modules') || currentDir.endsWith('\\node_modules')) { break; } - const pjsonPath = normalizeVFSPath(resolve(currentDir, 'package.json')); + const pjsonPath = normalizeVFSPath(joinVFSParts(currentDir, 'package.json')); if (vfs.shouldHandle(pjsonPath) && vfs.internalModuleStat(pjsonPath) === 0) { try { const content = vfs.readFileSync(pjsonPath, 'utf8'); @@ -264,7 +281,7 @@ function getVFSPackageType(vfs, filePath) { } } lastDir = currentDir; - currentDir = dirname(currentDir); + currentDir = dirnameVFS(currentDir); } return 'none'; } @@ -277,10 +294,7 @@ function getVFSFormat(vfs, filePath) { return VFS_FORMAT_MAP[ext] ?? 'commonjs'; } -// CJS index files follow Node.js require() resolution order. -// ESM index files (in tryIndexFiles) additionally include .mjs/.cjs -// because ESM hooks handle all module formats. -const CJS_INDEX_FILES = ['index.js', 'index.json', 'index.node']; +// === Resolution helpers === // Match a subpath against wildcard export/import keys. // Returns { key, patternMatch } on match, or null. @@ -306,6 +320,19 @@ function matchWildcardPattern(keys, subpath) { return null; } +function expandPattern(value, patternMatch) { + return patternMatch !== null ? value.replace(/\*/g, patternMatch) : value; +} + +function findVFSForPath(normalizedPath) { + for (let i = 0; i < activeVFSList.length; i++) { + if (activeVFSList[i].shouldHandle(normalizedPath)) { + return activeVFSList[i]; + } + } + return null; +} + function makeResolveResult(vfs, filePath) { return { url: vfsPathToURL(filePath), @@ -314,8 +341,7 @@ function makeResolveResult(vfs, filePath) { }; } -function tryExtensions(vfs, basePath) { - const extensions = ['.js', '.json', '.node', '.mjs', '.cjs']; +function tryExtensions(vfs, basePath, extensions) { for (let i = 0; i < extensions.length; i++) { const candidate = basePath + extensions[i]; if (vfs.internalModuleStat(candidate) === 0) { @@ -325,48 +351,53 @@ function tryExtensions(vfs, basePath) { return null; } -function tryIndexFiles(vfs, dirPath) { - const indexFiles = ['index.js', 'index.mjs', 'index.cjs', 'index.json']; +function tryIndexFiles(vfs, dirPath, indexFiles) { for (let i = 0; i < indexFiles.length; i++) { - const candidate = normalizeVFSPath(resolve(dirPath, indexFiles[i])); + const candidate = normalizeVFSPath(joinVFSParts(dirPath, indexFiles[i])); if (vfs.internalModuleStat(candidate) === 0) { - return makeResolveResult(vfs, candidate); + return candidate; } } return null; } -function tryCJSIndexFiles(vfs, dirPath) { - for (let i = 0; i < CJS_INDEX_FILES.length; i++) { - const candidate = normalizeVFSPath( - joinVFSParts(dirPath, CJS_INDEX_FILES[i])); - if (vfs.internalModuleStat(candidate) === 0) { - return candidate; - } - } +// Resolve a package.json "main" field: exact file → extensions → directory index. +// Saves the stat result to avoid double-calling internalModuleStat. +function resolveMainField(vfs, dirPath, main, extensions, indexFiles) { + const mainPath = normalizeVFSPath(joinVFSParts(dirPath, main)); + const mainStat = vfs.internalModuleStat(mainPath); + if (mainStat === 0) return mainPath; + const withExt = tryExtensions(vfs, mainPath, extensions); + if (withExt) return withExt; + if (mainStat === 1) return tryIndexFiles(vfs, mainPath, indexFiles); return null; } -function resolveConditions(vfs, pkgDir, condMap, conditions) { - return resolveConditionsWithPattern(vfs, pkgDir, condMap, conditions, null); +// Resolve a single exports/conditions string target to a path. +// When extensions is non-null, also tries appending extensions (CJS behavior). +function resolveExportTarget(vfs, pkgDir, target, patternMatch, extensions) { + if (typeof target !== 'string') return null; + const expanded = expandPattern(target, patternMatch); + const resolved = normalizeVFSPath(joinVFSParts(pkgDir, expanded)); + if (vfs.internalModuleStat(resolved) === 0) return resolved; + return extensions ? tryExtensions(vfs, resolved, extensions) : null; } -function resolveConditionsWithPattern(vfs, pkgDir, condMap, conditions, patternMatch) { +// Walk a conditions map (e.g. { "import": "...", "require": "...", "default": "..." }) +// and return the first matching path. Handles nested condition objects recursively. +function resolveConditionsToPath(vfs, pkgDir, condMap, conditions, patternMatch, extensions) { const keys = Object.getOwnPropertyNames(condMap); for (let i = 0; i < keys.length; i++) { const key = keys[i]; if (key === 'default' || conditions.indexOf(key) !== -1) { const value = condMap[key]; if (typeof value === 'string') { - const expanded = patternMatch !== null ? value.replace(/\*/g, patternMatch) : value; - const resolved = normalizeVFSPath(resolve(pkgDir, expanded)); - if (vfs.internalModuleStat(resolved) === 0) { - return makeResolveResult(vfs, resolved); - } + const result = resolveExportTarget(vfs, pkgDir, value, patternMatch, extensions); + if (result) return result; continue; } if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - const result = resolveConditionsWithPattern(vfs, pkgDir, value, conditions, patternMatch); + const result = resolveConditionsToPath(vfs, pkgDir, value, conditions, patternMatch, extensions); if (result) return result; continue; } @@ -375,22 +406,18 @@ function resolveConditionsWithPattern(vfs, pkgDir, condMap, conditions, patternM return null; } -function resolvePackageExports(vfs, pkgDir, packageSubpath, exports, context) { - const conditions = context.conditions || []; - +// Unified exports resolution. Returns a resolved path or null. +// ESM callers pass context.conditions and extensions=null. +// CJS callers pass CJS_CONDITIONS and extensions=CJS_EXTENSIONS. +function resolveExportsToPath(vfs, pkgDir, packageSubpath, exports, conditions, extensions) { if (typeof exports === 'string') { if (packageSubpath === '.') { - const resolved = normalizeVFSPath(resolve(pkgDir, exports)); - if (vfs.internalModuleStat(resolved) === 0) { - return makeResolveResult(vfs, resolved); - } + return resolveExportTarget(vfs, pkgDir, exports, null, extensions); } return null; } - if (typeof exports !== 'object' || exports === null) { - return null; - } + if (typeof exports !== 'object' || exports === null) return null; const keys = Object.getOwnPropertyNames(exports); if (keys.length === 0) return null; @@ -398,7 +425,7 @@ function resolvePackageExports(vfs, pkgDir, packageSubpath, exports, context) { const isConditional = keys[0] !== '' && keys[0][0] !== '.'; if (isConditional) { if (packageSubpath !== '.') return null; - return resolveConditions(vfs, pkgDir, exports, conditions); + return resolveConditionsToPath(vfs, pkgDir, exports, conditions, null, extensions); } let target = exports[packageSubpath]; @@ -416,53 +443,40 @@ function resolvePackageExports(vfs, pkgDir, packageSubpath, exports, context) { if (target === undefined) return null; if (typeof target === 'string') { - const expanded = patternMatch !== null ? target.replace(/\*/g, patternMatch) : target; - const resolved = normalizeVFSPath(resolve(pkgDir, expanded)); - if (vfs.internalModuleStat(resolved) === 0) { - return makeResolveResult(vfs, resolved); - } - return null; + return resolveExportTarget(vfs, pkgDir, target, patternMatch, extensions); } - if (typeof target === 'object' && target !== null) { - if (Array.isArray(target)) return null; - return resolveConditionsWithPattern(vfs, pkgDir, target, conditions, patternMatch); + if (typeof target === 'object' && target !== null && !Array.isArray(target)) { + return resolveConditionsToPath(vfs, pkgDir, target, conditions, patternMatch, extensions); } return null; } function resolveDirectoryEntry(vfs, dirPath, context) { - const pjsonPath = normalizeVFSPath(resolve(dirPath, 'package.json')); + const pjsonPath = normalizeVFSPath(joinVFSParts(dirPath, 'package.json')); if (vfs.internalModuleStat(pjsonPath) === 0) { try { const content = vfs.readFileSync(pjsonPath, 'utf8'); const parsed = JSON.parse(content); if (parsed.exports != null) { - const resolved = resolvePackageExports( - vfs, dirPath, '.', parsed.exports, context); - if (resolved) return resolved; + const resolved = resolveExportsToPath( + vfs, dirPath, '.', parsed.exports, context.conditions || [], null); + if (resolved) return makeResolveResult(vfs, resolved); } if (parsed.main) { - const mainPath = normalizeVFSPath(resolve(dirPath, parsed.main)); - if (vfs.internalModuleStat(mainPath) === 0) { - return makeResolveResult(vfs, mainPath); - } - const withExt = tryExtensions(vfs, mainPath); - if (withExt) return makeResolveResult(vfs, withExt); - // main points to a directory — try index files inside it - if (vfs.internalModuleStat(mainPath) === 1) { - const mainIndexResult = tryIndexFiles(vfs, mainPath); - if (mainIndexResult) return mainIndexResult; - } + const mainResult = resolveMainField( + vfs, dirPath, parsed.main, ESM_EXTENSIONS, ESM_INDEX_FILES); + if (mainResult) return makeResolveResult(vfs, mainResult); } } catch { // Invalid package.json } } - return tryIndexFiles(vfs, dirPath); + const indexResult = tryIndexFiles(vfs, dirPath, ESM_INDEX_FILES); + return indexResult ? makeResolveResult(vfs, indexResult) : null; } function parsePackageName(specifier) { @@ -507,7 +521,7 @@ function resolveVFSPath(checkPath, context, nextResolve, specifier) { // resolution order: file.js > file.json > dir/package.json#main > dir/index.js // This handles cases like require('./schema') where both schema.js // and schema/ directory exist — the file should win. - const withExt = tryExtensions(vfs, normalized); + const withExt = tryExtensions(vfs, normalized, ESM_EXTENSIONS); if (withExt) return makeResolveResult(vfs, withExt); if (stat === 1) { @@ -522,49 +536,42 @@ function resolveVFSPath(checkPath, context, nextResolve, specifier) { function resolvePackageInVFS(vfs, startDir, packageName, packageSubpath, context) { let currentDir = startDir; let lastDir; + const conditions = context.conditions || []; + while (currentDir !== lastDir) { const pkgDir = normalizeVFSPath( joinVFSParts(currentDir, 'node_modules', packageName)); if (vfs.shouldHandle(pkgDir) && vfs.internalModuleStat(pkgDir) === 1) { + // Read package.json once and reuse for exports + main resolution const pjsonPath = normalizeVFSPath( joinVFSParts(pkgDir, 'package.json')); if (vfs.internalModuleStat(pjsonPath) === 0) { try { - const content = vfs.readFileSync(pjsonPath, 'utf8'); - const parsed = JSON.parse(content); + const parsed = JSON.parse(vfs.readFileSync(pjsonPath, 'utf8')); if (parsed.exports != null) { - const resolved = resolvePackageExports( - vfs, pkgDir, packageSubpath, parsed.exports, context); - if (resolved) return resolved; + const resolved = resolveExportsToPath( + vfs, pkgDir, packageSubpath, parsed.exports, conditions, null); + if (resolved) return makeResolveResult(vfs, resolved); } if (packageSubpath === '.') { if (parsed.main) { - const mainPath = normalizeVFSPath( - joinVFSParts(pkgDir, parsed.main)); - if (vfs.internalModuleStat(mainPath) === 0) { - return makeResolveResult(vfs, mainPath); - } - const withExt = tryExtensions(vfs, mainPath); - if (withExt) return makeResolveResult(vfs, withExt); - // main points to a directory — try index files inside it - if (vfs.internalModuleStat(mainPath) === 1) { - const mainIndexResult = tryIndexFiles(vfs, mainPath); - if (mainIndexResult) return mainIndexResult; - } + const mainResult = resolveMainField( + vfs, pkgDir, parsed.main, ESM_EXTENSIONS, ESM_INDEX_FILES); + if (mainResult) return makeResolveResult(vfs, mainResult); } - const indexResult = tryIndexFiles(vfs, pkgDir); - if (indexResult) return indexResult; + const indexResult = tryIndexFiles(vfs, pkgDir, ESM_INDEX_FILES); + if (indexResult) return makeResolveResult(vfs, indexResult); } else { const subResolved = normalizeVFSPath( joinVFSParts(pkgDir, packageSubpath)); if (vfs.internalModuleStat(subResolved) === 0) { return makeResolveResult(vfs, subResolved); } - const withExt = tryExtensions(vfs, subResolved); + const withExt = tryExtensions(vfs, subResolved, ESM_EXTENSIONS); if (withExt) return makeResolveResult(vfs, withExt); } } catch { @@ -580,71 +587,6 @@ function resolvePackageInVFS(vfs, startDir, packageName, packageSubpath, context return null; } -function resolveCJSExportsPath(vfs, pkgDir, target, patternMatch) { - if (typeof target !== 'string') return null; - const expanded = patternMatch !== null ? target.replace(/\*/g, patternMatch) : target; - const resolved = normalizeVFSPath(resolve(pkgDir, expanded)); - if (vfs.internalModuleStat(resolved) === 0) return resolved; - const withExt = tryExtensions(vfs, resolved); - if (withExt) return withExt; - return null; -} - -function resolveCJSConditions(vfs, pkgDir, condMap, patternMatch) { - const keys = Object.getOwnPropertyNames(condMap); - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - if (key === 'default' || key === 'require' || key === 'node') { - const value = condMap[key]; - if (typeof value === 'string') { - const result = resolveCJSExportsPath(vfs, pkgDir, value, patternMatch); - if (result) return result; - continue; - } - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - const result = resolveCJSConditions(vfs, pkgDir, value, patternMatch); - if (result) return result; - continue; - } - } - } - return null; -} - -function resolveCJSPackageExports(vfs, pkgDir, packageSubpath, exports) { - if (typeof exports === 'string') { - if (packageSubpath === '.') { - return resolveCJSExportsPath(vfs, pkgDir, exports, null); - } - return null; - } - if (typeof exports !== 'object' || exports === null) return null; - const keys = Object.getOwnPropertyNames(exports); - if (keys.length === 0) return null; - const isConditional = keys[0] !== '' && keys[0][0] !== '.'; - if (isConditional) { - if (packageSubpath !== '.') return null; - return resolveCJSConditions(vfs, pkgDir, exports, null); - } - let target = exports[packageSubpath]; - let patternMatch = null; - if (target === undefined) { - const match = matchWildcardPattern(keys, packageSubpath); - if (match) { - patternMatch = match.patternMatch; - target = exports[match.key]; - } - } - if (target === undefined) return null; - if (typeof target === 'string') { - return resolveCJSExportsPath(vfs, pkgDir, target, patternMatch); - } - if (typeof target === 'object' && target !== null && !Array.isArray(target)) { - return resolveCJSConditions(vfs, pkgDir, target, patternMatch); - } - return null; -} - function resolveCJSPackageInVFS(vfs, startDir, packageName, packageSubpath) { let currentDir = startDir; let lastDir; @@ -667,27 +609,19 @@ function resolveCJSPackageInVFS(vfs, startDir, packageName, packageSubpath) { // Try exports field first (supports wildcards and conditions) if (parsed?.exports != null) { - const exportsResult = resolveCJSPackageExports( - vfs, pkgDir, packageSubpath, parsed.exports); + const exportsResult = resolveExportsToPath( + vfs, pkgDir, packageSubpath, parsed.exports, + CJS_CONDITIONS, CJS_EXTENSIONS); if (exportsResult) return exportsResult; } if (packageSubpath === '.') { if (parsed?.main) { - const mainPath = normalizeVFSPath( - joinVFSParts(pkgDir, parsed.main)); - if (vfs.internalModuleStat(mainPath) === 0) { - return mainPath; - } - const withExt = tryExtensions(vfs, mainPath); - if (withExt) return withExt; - // main points to a directory — try index files inside it - if (vfs.internalModuleStat(mainPath) === 1) { - const mainIdx = tryCJSIndexFiles(vfs, mainPath); - if (mainIdx) return mainIdx; - } + const mainResult = resolveMainField( + vfs, pkgDir, parsed.main, CJS_EXTENSIONS, CJS_INDEX_FILES); + if (mainResult) return mainResult; } - const idxResult = tryCJSIndexFiles(vfs, pkgDir); + const idxResult = tryIndexFiles(vfs, pkgDir, CJS_INDEX_FILES); if (idxResult) return idxResult; } else { const subResolved = normalizeVFSPath( @@ -696,7 +630,7 @@ function resolveCJSPackageInVFS(vfs, startDir, packageName, packageSubpath) { if (subStat === 0) { return subResolved; } - const withExt = tryExtensions(vfs, subResolved); + const withExt = tryExtensions(vfs, subResolved, CJS_EXTENSIONS); if (withExt) return withExt; // Subpath resolves to a directory — try package.json main, then index files if (subStat === 1) { @@ -706,19 +640,14 @@ function resolveCJSPackageInVFS(vfs, startDir, packageName, packageSubpath) { try { const subParsed = JSON.parse(vfs.readFileSync(subPjsonPath, 'utf8')); if (subParsed.main) { - const mainPath = normalizeVFSPath( - joinVFSParts(subResolved, subParsed.main)); - if (vfs.internalModuleStat(mainPath) === 0) return mainPath; - const mainExt = tryExtensions(vfs, mainPath); - if (mainExt) return mainExt; - if (vfs.internalModuleStat(mainPath) === 1) { - const mainIdx = tryCJSIndexFiles(vfs, mainPath); - if (mainIdx) return mainIdx; - } + const mainResult = resolveMainField( + vfs, subResolved, subParsed.main, + CJS_EXTENSIONS, CJS_INDEX_FILES); + if (mainResult) return mainResult; } } catch { /* ignore */ } } - const subIdx = tryCJSIndexFiles(vfs, subResolved); + const subIdx = tryIndexFiles(vfs, subResolved, CJS_INDEX_FILES); if (subIdx) return subIdx; } } @@ -730,8 +659,46 @@ function resolveCJSPackageInVFS(vfs, startDir, packageName, packageSubpath) { return null; } +// === Hash imports (#imports) === + +// Resolve a single import target string for ESM. +// Handles both relative paths and bare specifier re-resolution. +function resolveImportTarget(vfs, baseDir, value, patternMatch, context, nextResolve) { + const expanded = expandPattern(value, patternMatch); + if (!expanded.startsWith('.') && !expanded.startsWith('/')) { + return resolveBareSpecifier(expanded, context, nextResolve); + } + const resolved = normalizeVFSPath(joinVFSParts(baseDir, expanded)); + if (vfs.internalModuleStat(resolved) === 0) { + return makeResolveResult(vfs, resolved); + } + return null; +} + +// Resolve conditions in an imports field for ESM. Properly recursive +// to handle nested condition objects (e.g. { "node": { "require": "..." } }). +function resolveImportConditions(vfs, baseDir, condMap, conditions, patternMatch, context, nextResolve) { + const keys = Object.getOwnPropertyNames(condMap); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key === 'default' || conditions.indexOf(key) !== -1) { + const value = condMap[key]; + if (typeof value === 'string') { + const result = resolveImportTarget(vfs, baseDir, value, patternMatch, context, nextResolve); + if (result) return result; + continue; + } + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const result = resolveImportConditions(vfs, baseDir, value, conditions, patternMatch, context, nextResolve); + if (result) return result; + continue; + } + } + } + return null; +} + function resolveHashImport(specifier, context, nextResolve) { - // Handle package #imports (subpath imports) for files inside the VFS if (!context.parentURL) { return nextResolve(specifier, context); } @@ -744,14 +711,7 @@ function resolveHashImport(specifier, context, nextResolve) { } const parentNorm = normalizeVFSPath(parentPath); - let parentVfs = null; - for (let i = 0; i < activeVFSList.length; i++) { - if (activeVFSList[i].shouldHandle(parentNorm)) { - parentVfs = activeVFSList[i]; - break; - } - } - + const parentVfs = findVFSForPath(parentNorm); if (!parentVfs) { return nextResolve(specifier, context); } @@ -766,8 +726,7 @@ function resolveHashImport(specifier, context, nextResolve) { if (parentVfs.shouldHandle(pjsonPath) && parentVfs.internalModuleStat(pjsonPath) === 0) { try { - const content = parentVfs.readFileSync(pjsonPath, 'utf8'); - const parsed = JSON.parse(content); + const parsed = JSON.parse(parentVfs.readFileSync(pjsonPath, 'utf8')); if (parsed.imports) { let target = parsed.imports[specifier]; let patternMatch = null; @@ -784,36 +743,16 @@ function resolveHashImport(specifier, context, nextResolve) { if (target !== undefined) { if (typeof target === 'string') { - const expanded = patternMatch !== null ? target.replace(/\*/g, patternMatch) : target; - // If the target is a bare specifier (not relative), re-resolve it - if (!expanded.startsWith('.') && !expanded.startsWith('/')) { - return resolveBareSpecifier(expanded, context, nextResolve); - } - const resolved = normalizeVFSPath(resolve(currentDir, expanded)); - if (parentVfs.internalModuleStat(resolved) === 0) { - return makeResolveResult(parentVfs, resolved); - } + const result = resolveImportTarget( + parentVfs, currentDir, target, patternMatch, + context, nextResolve); + if (result) return result; } if (typeof target === 'object' && target !== null && !Array.isArray(target)) { - // Resolve conditions - const condKeys = Object.getOwnPropertyNames(target); - for (let i = 0; i < condKeys.length; i++) { - const ckey = condKeys[i]; - if (ckey === 'default' || conditions.indexOf(ckey) !== -1) { - const value = target[ckey]; - if (typeof value === 'string') { - const expanded = patternMatch !== null ? value.replace(/\*/g, patternMatch) : value; - // If the target is a bare specifier, re-resolve it - if (!expanded.startsWith('.') && !expanded.startsWith('/')) { - return resolveBareSpecifier(expanded, context, nextResolve); - } - const resolved = normalizeVFSPath(resolve(currentDir, expanded)); - if (parentVfs.internalModuleStat(resolved) === 0) { - return makeResolveResult(parentVfs, resolved); - } - } - } - } + const result = resolveImportConditions( + parentVfs, currentDir, target, conditions, patternMatch, + context, nextResolve); + if (result) return result; } } break; // Found a package.json with imports — stop walking up @@ -827,6 +766,91 @@ function resolveHashImport(specifier, context, nextResolve) { return nextResolve(specifier, context); } +// Resolve a single import target string for CJS. +// startDir is the parent file's directory (for node_modules walking). +// baseDir is the package.json directory (for relative path resolution). +function resolveCJSImportTarget(vfs, startDir, baseDir, value, patternMatch) { + const expanded = expandPattern(value, patternMatch); + if (!expanded.startsWith('.') && !expanded.startsWith('/')) { + const { packageName, packageSubpath } = parsePackageName(expanded); + return resolveCJSPackageInVFS(vfs, startDir, packageName, packageSubpath); + } + const resolved = normalizeVFSPath(joinVFSParts(baseDir, expanded)); + if (vfs.internalModuleStat(resolved) === 0) return resolved; + return tryExtensions(vfs, resolved, CJS_EXTENSIONS); +} + +// Resolve conditions in an imports field for CJS. Properly recursive. +function resolveCJSImportConditions(vfs, startDir, baseDir, condMap, patternMatch) { + const keys = Object.getOwnPropertyNames(condMap); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key === 'default' || CJS_CONDITIONS.indexOf(key) !== -1) { + const value = condMap[key]; + if (typeof value === 'string') { + const result = resolveCJSImportTarget(vfs, startDir, baseDir, value, patternMatch); + if (result) return result; + continue; + } + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const result = resolveCJSImportConditions(vfs, startDir, baseDir, value, patternMatch); + if (result) return result; + continue; + } + } + } + return null; +} + +function resolveCJSHashImport(specifier, parentDir) { + const parentNorm = normalizeVFSPath(parentDir); + const parentVfs = findVFSForPath(parentNorm); + if (!parentVfs) return null; + + let currentDir = parentNorm; + let lastDir; + while (currentDir !== lastDir) { + const pjsonPath = normalizeVFSPath( + joinVFSParts(currentDir, 'package.json')); + if (parentVfs.shouldHandle(pjsonPath) && + parentVfs.internalModuleStat(pjsonPath) === 0) { + try { + const parsed = JSON.parse(parentVfs.readFileSync(pjsonPath, 'utf8')); + if (parsed.imports) { + let target = parsed.imports[specifier]; + let patternMatch = null; + + if (target === undefined) { + const impKeys = Object.getOwnPropertyNames(parsed.imports); + const match = matchWildcardPattern(impKeys, specifier); + if (match) { + patternMatch = match.patternMatch; + target = parsed.imports[match.key]; + } + } + + if (target !== undefined) { + if (typeof target === 'string') { + return resolveCJSImportTarget( + parentVfs, parentNorm, currentDir, target, patternMatch); + } + if (typeof target === 'object' && target !== null && !Array.isArray(target)) { + return resolveCJSImportConditions( + parentVfs, parentNorm, currentDir, target, patternMatch); + } + } + break; + } + } catch { /* ignore */ } + } + lastDir = currentDir; + currentDir = dirnameVFS(currentDir); + } + return null; +} + +// === Bare specifier resolution === + function resolveBareSpecifier(specifier, context, nextResolve) { if (isNodeBuiltin(specifier)) { return nextResolve(specifier, context); @@ -848,13 +872,7 @@ function resolveBareSpecifier(specifier, context, nextResolve) { } const parentNorm = normalizeVFSPath(parentPath); - let parentVfs = null; - for (let i = 0; i < activeVFSList.length; i++) { - if (activeVFSList[i].shouldHandle(parentNorm)) { - parentVfs = activeVFSList[i]; - break; - } - } + const parentVfs = findVFSForPath(parentNorm); const { packageName, packageSubpath } = parsePackageName(specifier); @@ -882,16 +900,8 @@ function resolveBareSpecifier(specifier, context, nextResolve) { // === Hooks === -function isNodeBuiltin(name) { - if (typeof NodeModule.isBuiltin === 'function') { - return NodeModule.isBuiltin(name); - } - const bare = name.startsWith('node:') ? name.slice(5) : name; - return builtinSet.has(bare); -} - function vfsResolveHook(specifier, context, nextResolve) { - if (specifier.startsWith('node:') || isNodeBuiltin(specifier)) { + if (isNodeBuiltin(specifier)) { return nextResolve(specifier, context); } @@ -995,7 +1005,7 @@ function installModuleHooks() { // Try extensions BEFORE directory resolution to match Node.js // resolution order: file.js > file.json > dir/package.json#main > dir/index.js - const withExt = tryExtensions(vfs, normalized); + const withExt = tryExtensions(vfs, normalized, CJS_EXTENSIONS); if (withExt) return withExt; if (stat === 1) { @@ -1003,31 +1013,32 @@ function installModuleHooks() { const pjsonPath = normalizeVFSPath(resolve(normalized, 'package.json')); if (vfs.internalModuleStat(pjsonPath) === 0) { try { - const content = vfs.readFileSync(pjsonPath, 'utf8'); - const parsed = JSON.parse(content); + const parsed = JSON.parse(vfs.readFileSync(pjsonPath, 'utf8')); if (parsed.main) { - const mainPath = normalizeVFSPath(resolve(normalized, parsed.main)); - if (vfs.internalModuleStat(mainPath) === 0) return mainPath; - const mainWithExt = tryExtensions(vfs, mainPath); - if (mainWithExt) return mainWithExt; - // main points to a directory — try index files inside it - if (vfs.internalModuleStat(mainPath) === 1) { - const mainIdx = tryCJSIndexFiles(vfs, mainPath); - if (mainIdx) return mainIdx; - } + const mainResult = resolveMainField( + vfs, normalized, parsed.main, + CJS_EXTENSIONS, CJS_INDEX_FILES); + if (mainResult) return mainResult; } } catch { // ignore } } - const idxResult = tryCJSIndexFiles(vfs, normalized); + const idxResult = tryIndexFiles(vfs, normalized, CJS_INDEX_FILES); if (idxResult) return idxResult; } } } + // Handle #imports for CJS + if (request[0] === '#') { + const parentDir = parent?.filename ? dirname(parent.filename) : process.cwd(); + const found = resolveCJSHashImport(request, parentDir); + if (found) return found; + } + // Bare specifier - walk node_modules - if (!isAbsolute(request) && request[0] !== '.' && !isNodeBuiltin(request)) { + if (!isAbsolute(request) && request[0] !== '.' && request[0] !== '#' && !isNodeBuiltin(request)) { const parentDir = parent?.filename ? dirname(parent.filename) : process.cwd(); const parentNorm = normalizeVFSPath(parentDir); const { packageName, packageSubpath } = parsePackageName(request); diff --git a/test/module_resolution.test.js b/test/module_resolution.test.js index 5c0a813..9b43d5d 100644 --- a/test/module_resolution.test.js +++ b/test/module_resolution.test.js @@ -381,6 +381,25 @@ describe('Module resolution — package #imports', () => { }); }); + it('#import with nested condition objects', () => { + withVFS({ + '/node_modules/vfs-hash-nested-cond/package.json': JSON.stringify({ + name: 'vfs-hash-nested-cond', + imports: { + '#util': { node: { require: './cjs-util.js', import: './esm-util.js' } }, + }, + main: './index.js', + }), + '/node_modules/vfs-hash-nested-cond/cjs-util.js': + 'module.exports = "nested-cjs-util";', + '/node_modules/vfs-hash-nested-cond/index.js': + 'module.exports = require("#util");', + }, () => { + const mod = require('vfs-hash-nested-cond'); + assert.strictEqual(mod, 'nested-cjs-util'); + }); + }); + it('#import from nested file walks up to find package.json', () => { withVFS({ '/node_modules/vfs-hash-nested/package.json': JSON.stringify({