branch:
resolver.ts
9303 bytesRaw
// 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<string, unknown>;
try {
pkg = JSON.parse(packageJson) as Record<string, unknown>;
} 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., `<div>` 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 (<Component />) 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
}