// Use the asm.js version to avoid WASM (works in workerd) import { parse } from "es-module-lexer/js"; import * as resolveExports from "resolve.exports"; import type { Files } from "./types"; export interface ResolveOptions { /** * All files in the virtual file system */ files: Files; /** * Directory of the importing file (relative to root) */ importer?: string; /** * Conditions for exports resolution (e.g., 'import', 'require', 'browser') */ conditions?: string[]; /** * Extensions to try when resolving */ extensions?: string[]; } export interface ResolveResult { /** * Resolved path (relative to root) */ path: string; /** * Whether this is an external module (npm package not in files) */ external: boolean; } const DEFAULT_EXTENSIONS = [ ".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".json" ]; /** * Resolve a module specifier to a file path in the virtual file system. * * Handles: * - Relative imports (./foo, ../bar) * - Package imports (lodash, @scope/pkg) * - Package.json exports field * - Extension resolution (.ts, .tsx, .js, etc.) * - Index file resolution (foo/index.ts) * * @param specifier - The import specifier (e.g., './utils', 'lodash') * @param options - Resolution options * @returns Resolved path or external marker */ export function resolveModule( specifier: string, options: ResolveOptions ): ResolveResult { const { files, importer = "", conditions = ["import", "browser"], extensions = DEFAULT_EXTENSIONS } = options; // Handle relative imports if (specifier.startsWith(".") || specifier.startsWith("/")) { const resolved = resolveRelative(specifier, importer, files, extensions); if (resolved) { return { path: resolved, external: false }; } // Relative import not found throw new Error( `Cannot resolve relative import '${specifier}' from '${importer}'` ); } // Handle bare specifiers (npm packages) return resolvePackage(specifier, files, conditions, extensions); } /** * Resolve a relative import */ function resolveRelative( specifier: string, importer: string, files: Files, extensions: string[] ): string | undefined { // Get the directory of the importer const importerDir = getDirectory(importer); // Resolve the path const resolved = joinPaths(importerDir, specifier); return resolveWithExtensions(resolved, files, extensions); } /** * Resolve a package specifier */ function resolvePackage( specifier: string, files: Files, conditions: string[], extensions: string[] ): ResolveResult { // Parse the specifier const { packageName, subpath } = parsePackageSpecifier(specifier); // Look for the package in node_modules const packageJsonPath = `node_modules/${packageName}/package.json`; const packageJson = files[packageJsonPath]; if (!packageJson) { // Package not found in files, mark as external return { path: specifier, external: true }; } // Parse package.json let pkg: Record; try { pkg = JSON.parse(packageJson) as Record; } catch { throw new Error(`Invalid package.json for ${packageName}`); } // Use resolve.exports to handle the exports field const entrySubpath = subpath ? `./${subpath}` : "."; try { const resolved = resolveExports.resolve(pkg, entrySubpath, { conditions }); if (resolved && resolved.length > 0) { // resolve.exports returns relative paths like './dist/index.js' const resolvedPath = resolved[0]; if (resolvedPath) { const fullPath = `node_modules/${packageName}/${normalizeRelativePath(resolvedPath)}`; if (fullPath in files) { return { path: fullPath, external: false }; } } } } catch { // resolve.exports failed, try legacy resolution } // Fall back to legacy resolution (main, module fields) const legacyEntry = resolveExports.legacy(pkg, { fields: ["module", "main"] }); if (legacyEntry && typeof legacyEntry === "string") { const fullPath = `node_modules/${packageName}/${normalizeRelativePath(legacyEntry)}`; if (fullPath in files) { return { path: fullPath, external: false }; } } // Try index files directly const indexPath = resolveWithExtensions( `node_modules/${packageName}${subpath ? `/${subpath}` : ""}`, files, extensions ); if (indexPath) { return { path: indexPath, external: false }; } // Package found but entry point not resolved, mark as external return { path: specifier, external: true }; } /** * Try to resolve a path with various extensions and index files */ function resolveWithExtensions( path: string, files: Files, extensions: string[] ): string | undefined { // Normalize the path const normalized = normalizePath(path); // Try exact match first if (normalized in files) { return normalized; } // Try adding extensions for (const ext of extensions) { const withExt = normalized + ext; if (withExt in files) { return withExt; } } // Try index files for (const ext of extensions) { const indexPath = `${normalized}/index${ext}`; if (indexPath in files) { return indexPath; } } return undefined; } /** * Parse a package specifier into package name and subpath */ function parsePackageSpecifier(specifier: string): { packageName: string; subpath: string | undefined; } { // Handle scoped packages (@scope/pkg) if (specifier.startsWith("@")) { const parts = specifier.split("/"); if (parts.length >= 2) { const packageName = `${parts[0]}/${parts[1]}`; const subpath = parts.slice(2).join("/") || undefined; return { packageName, subpath }; } } // Handle regular packages const slashIndex = specifier.indexOf("/"); if (slashIndex === -1) { return { packageName: specifier, subpath: undefined }; } return { packageName: specifier.slice(0, slashIndex), subpath: specifier.slice(slashIndex + 1) }; } /** * Get the directory of a file path */ function getDirectory(filePath: string): string { const lastSlash = filePath.lastIndexOf("/"); if (lastSlash === -1) { return ""; } return filePath.slice(0, lastSlash); } /** * Join two paths */ function joinPaths(base: string, relative: string): string { if (relative.startsWith("/")) { return relative.slice(1); } const baseParts = base ? base.split("/") : []; const relativeParts = relative.split("/"); for (const part of relativeParts) { if (part === "..") { baseParts.pop(); } else if (part !== ".") { baseParts.push(part); } } return baseParts.join("/"); } /** * Normalize a path (remove ./ prefix, handle multiple slashes) */ function normalizePath(path: string): string { return path.replace(/^\.\//, "").replace(/\/+/g, "/").replace(/\/$/, ""); } /** * Normalize a relative path from package.json */ function normalizeRelativePath(path: string): string { if (path.startsWith("./")) { return path.slice(2); } if (path.startsWith("/")) { return path.slice(1); } return path; } /** * Parse imports from a JavaScript/TypeScript source file. * * Uses es-module-lexer for accurate parsing of ES module syntax. * Falls back to regex for JSX files since es-module-lexer doesn't * handle JSX syntax (e.g., `
` is not valid JavaScript). */ export function parseImports(code: string): string[] { try { const [imports] = parse(code); const specifiers: string[] = []; for (const imp of imports) { // imp.n is the resolved module specifier (handles escape sequences) // imp.n is undefined for dynamic imports with non-string arguments if (imp.n !== undefined) { specifiers.push(imp.n); } } return [...new Set(specifiers)]; // Deduplicate } catch { // es-module-lexer fails on JSX syntax () and malformed code // Fall back to regex-based parsing return parseImportsRegex(code); } } /** * Regex-based fallback for parsing imports. * Used when es-module-lexer fails (e.g., on JSX/TSX files). */ function parseImportsRegex(code: string): string[] { const imports: string[] = []; // Match ES module imports // import foo from 'bar' // import { foo } from 'bar' // import * as foo from 'bar' // import 'bar' const importRegex = /import\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g; for (const match of code.matchAll(importRegex)) { const specifier = match[1]; if (specifier) { imports.push(specifier); } } // Match dynamic imports // import('bar') // await import('bar') const dynamicImportRegex = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g; for (const match of code.matchAll(dynamicImportRegex)) { const specifier = match[1]; if (specifier) { imports.push(specifier); } } // Match export from // export { foo } from 'bar' // export * from 'bar' const exportFromRegex = /export\s+(?:[\w*{}\s,]+\s+)?from\s+['"]([^'"]+)['"]/g; for (const match of code.matchAll(exportFromRegex)) { const specifier = match[1]; if (specifier) { imports.push(specifier); } } return [...new Set(imports)]; // Deduplicate }