diff --git a/lib/module_hooks.js b/lib/module_hooks.js index e4cb823..2992140 100644 --- a/lib/module_hooks.js +++ b/lib/module_hooks.js @@ -7,6 +7,25 @@ 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); + +// 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 @@ -241,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'); @@ -262,7 +281,7 @@ function getVFSPackageType(vfs, filePath) { } } lastDir = currentDir; - currentDir = dirname(currentDir); + currentDir = dirnameVFS(currentDir); } return 'none'; } @@ -275,8 +294,54 @@ function getVFSFormat(vfs, filePath) { return VFS_FORMAT_MAP[ext] ?? 'commonjs'; } -function tryExtensions(vfs, basePath) { - const extensions = ['.js', '.json', '.node', '.mjs', '.cjs']; +// === Resolution helpers === + +// 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 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), + format: getVFSFormat(vfs, filePath), + shortCircuit: true, + }; +} + +function tryExtensions(vfs, basePath, extensions) { for (let i = 0; i < extensions.length; i++) { const candidate = basePath + extensions[i]; if (vfs.internalModuleStat(candidate) === 0) { @@ -286,36 +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) { - const url = vfsPathToURL(candidate); - const format = getVFSFormat(vfs, candidate); - return { url, format, shortCircuit: true }; + return candidate; } } return null; } -function resolveConditions(vfs, pkgDir, condMap, conditions) { +// 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; +} + +// 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; +} + +// 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 resolved = normalizeVFSPath(resolve(pkgDir, value)); - if (vfs.internalModuleStat(resolved) === 0) { - const url = vfsPathToURL(resolved); - const format = getVFSFormat(vfs, resolved); - return { url, format, shortCircuit: true }; - } + const result = resolveExportTarget(vfs, pkgDir, value, patternMatch, extensions); + if (result) return result; continue; } if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - const result = resolveConditions(vfs, pkgDir, value, conditions); + const result = resolveConditionsToPath(vfs, pkgDir, value, conditions, patternMatch, extensions); if (result) return result; continue; } @@ -324,24 +406,18 @@ function resolveConditions(vfs, pkgDir, condMap, conditions) { 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) { - const url = vfsPathToURL(resolved); - const format = getVFSFormat(vfs, resolved); - return { url, format, shortCircuit: true }; - } + 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; @@ -349,62 +425,58 @@ 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); } - 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)); - if (vfs.internalModuleStat(resolved) === 0) { - const url = vfsPathToURL(resolved); - const format = getVFSFormat(vfs, resolved); - return { url, format, shortCircuit: true }; - } - return null; + return resolveExportTarget(vfs, pkgDir, target, patternMatch, extensions); } - if (typeof target === 'object' && target !== null) { - if (Array.isArray(target)) return null; - return resolveConditions(vfs, pkgDir, target, conditions); + 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) { - const url = vfsPathToURL(mainPath); - const format = getVFSFormat(vfs, mainPath); - return { url, format, shortCircuit: true }; - } - const withExt = tryExtensions(vfs, mainPath); - if (withExt) { - const url = vfsPathToURL(withExt); - const format = getVFSFormat(vfs, withExt); - return { url, format, shortCircuit: true }; - } + 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) { @@ -417,8 +489,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,25 +512,22 @@ 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 }; + 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, ESM_EXTENSIONS); + if (withExt) return makeResolveResult(vfs, withExt); + if (stat === 1) { const resolved = resolveDirectoryEntry(vfs, normalized, context); if (resolved) return resolved; } - - if (stat !== 1) { - const withExt = tryExtensions(vfs, normalized); - if (withExt) { - const url = vfsPathToURL(withExt); - const format = getVFSFormat(vfs, withExt); - return { url, format, shortCircuit: true }; - } - } } return nextResolve(specifier, context); @@ -465,6 +536,7 @@ 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( @@ -472,51 +544,35 @@ function resolvePackageInVFS(vfs, startDir, packageName, packageSubpath, context 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) { - const url = vfsPathToURL(mainPath); - const format = getVFSFormat(vfs, mainPath); - return { url, format, shortCircuit: true }; - } - const withExt = tryExtensions(vfs, mainPath); - if (withExt) { - const url = vfsPathToURL(withExt); - const format = getVFSFormat(vfs, withExt); - return { url, format, shortCircuit: true }; - } + 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) { - const url = vfsPathToURL(subResolved); - const format = getVFSFormat(vfs, subResolved); - return { url, format, shortCircuit: true }; - } - const withExt = tryExtensions(vfs, subResolved); - if (withExt) { - const url = vfsPathToURL(withExt); - const format = getVFSFormat(vfs, withExt); - return { url, format, shortCircuit: true }; + return makeResolveResult(vfs, subResolved); } + const withExt = tryExtensions(vfs, subResolved, ESM_EXTENSIONS); + if (withExt) return makeResolveResult(vfs, withExt); } } catch { // Invalid package.json, continue walking @@ -540,40 +596,60 @@ 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 = resolveExportsToPath( + vfs, pkgDir, packageSubpath, parsed.exports, + CJS_CONDITIONS, CJS_EXTENSIONS); + 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; - } - } 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; - } + if (parsed?.main) { + const mainResult = resolveMainField( + vfs, pkgDir, parsed.main, CJS_EXTENSIONS, CJS_INDEX_FILES); + if (mainResult) return mainResult; } + const idxResult = tryIndexFiles(vfs, pkgDir, CJS_INDEX_FILES); + 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); + 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) { + 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 mainResult = resolveMainField( + vfs, subResolved, subParsed.main, + CJS_EXTENSIONS, CJS_INDEX_FILES); + if (mainResult) return mainResult; + } + } catch { /* ignore */ } + } + const subIdx = tryIndexFiles(vfs, subResolved, CJS_INDEX_FILES); + if (subIdx) return subIdx; + } } } lastDir = currentDir; @@ -583,11 +659,46 @@ function resolveCJSPackageInVFS(vfs, startDir, packageName, packageSubpath) { return null; } -function resolveBareSpecifier(specifier, context, nextResolve) { - if (specifier[0] === '#') { - return nextResolve(specifier, context); +// === 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) { if (!context.parentURL) { return nextResolve(specifier, context); } @@ -600,19 +711,174 @@ 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); + 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 parsed = JSON.parse(parentVfs.readFileSync(pjsonPath, 'utf8')); + 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 result = resolveImportTarget( + parentVfs, currentDir, target, patternMatch, + context, nextResolve); + if (result) return result; + } + if (typeof target === 'object' && target !== null && !Array.isArray(target)) { + const result = resolveImportConditions( + parentVfs, currentDir, target, conditions, patternMatch, + context, nextResolve); + if (result) return result; + } + } + break; // Found a package.json with imports — stop walking up + } + } catch { /* ignore invalid JSON */ } + } + lastDir = currentDir; + currentDir = dirnameVFS(currentDir); + } + + 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); + } + + if (specifier[0] === '#') { + return resolveHashImport(specifier, context, nextResolve); + } + + if (!context.parentURL) { + return nextResolve(specifier, context); + } + + let parentPath; + try { + parentPath = urlToPath(context.parentURL); + } catch { + return nextResolve(specifier, context); + } + + const parentNorm = normalizeVFSPath(parentPath); + const parentVfs = findVFSForPath(parentNorm); const { packageName, packageSubpath } = parsePackageName(specifier); if (parentVfs) { const result = resolvePackageInVFS( - parentVfs, dirname(parentNorm), + parentVfs, dirnameVFS(parentNorm), packageName, packageSubpath, context); if (result) return result; } else { @@ -635,7 +901,7 @@ function resolveBareSpecifier(specifier, context, nextResolve) { // === Hooks === function vfsResolveHook(specifier, context, nextResolve) { - if (specifier.startsWith('node:')) { + if (isNodeBuiltin(specifier)) { return nextResolve(specifier, context); } @@ -698,20 +964,23 @@ function vfsLoadHook(url, context, nextLoad) { } function installModuleHooks() { - const Module = require('node:module'); - - // Use Module.registerHooks if available (Node.js 23.5+) - if (typeof Module.registerHooks === 'function') { - Module.registerHooks({ + // 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. + // 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, }); - return; } - // Fallback: patch Module._resolveFilename for CJS - const origResolveFilename = Module._resolveFilename; - Module._resolveFilename = function(request, parent, isMain, options) { + // Always patch Module._resolveFilename for CJS require.resolve() support + const origResolveFilename = NodeModule._resolveFilename; + NodeModule._resolveFilename = function(request, parent, isMain, options) { if (request.startsWith('node:')) { return origResolveFilename.call(this, request, parent, isMain, options); } @@ -734,37 +1003,42 @@ 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, CJS_EXTENSIONS); + if (withExt) return withExt; + if (stat === 1) { // Try package.json main / index files 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 withExt = tryExtensions(vfs, mainPath); - if (withExt) return withExt; + const mainResult = resolveMainField( + vfs, normalized, parsed.main, + CJS_EXTENSIONS, CJS_INDEX_FILES); + if (mainResult) return mainResult; } } 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 = tryIndexFiles(vfs, normalized, CJS_INDEX_FILES); + if (idxResult) return idxResult; } - - const withExt = tryExtensions(vfs, normalized); - if (withExt) return withExt; } } + // 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] !== '.') { + 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); @@ -799,8 +1073,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]; @@ -813,8 +1087,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..9b43d5d --- /dev/null +++ b/test/module_resolution.test.js @@ -0,0 +1,421 @@ +'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 }); + }); + }); +}); + +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 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({ + 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'); + }); + }); +});