1import path from "node:path";
2import { fileURLToPath } from "node:url";
3import { ResolverFactory, type ResolveOptions } from "rolldown/experimental";
4
5/**
6 * TypeScript-aware specifier resolution on top of Oxc's resolver, mirroring
7 * what tsx layers over Node's resolver:
8 *
9 * - `tsconfig.json` `paths` / `baseUrl` aliases, discovered per importing file
10 * - emitted-extension substitution (`./x.js` → `x.ts`/`x.tsx`, `.mjs` → `.mts`,
11 * `.cjs` → `.cts`, `.jsx` → `.tsx`), TypeScript source winning over an
12 * emitted sibling
13 * - extensionless specifiers and directory indexes, TypeScript first
14 *
15 * Node's own resolver stays the authority: every candidate this class
16 * produces is handed back to Node (`nextResolve`) so it decides the format,
17 * validates package exports, and reports the canonical error.
18 */
19export interface SpecifierResolverOptions {
20 /** Discover `tsconfig.json` upward from each importing file. */
21 readonly tsconfig: boolean;
22}
23
24export const nodeModulesSegment = `${path.sep}node_modules${path.sep}`;
25
26/** Local project code: a file path outside every `node_modules` directory. */
27export const isProjectPath = (filePath: string) =>
28 !filePath.includes(nodeModulesSegment);
29
30const typeScriptExtensions = /\.(?:[cm]?ts|[tj]sx)$/;
31
32/** Whether TypeScript's extension substitution applies to this importer. */
33export const isTypeScriptPath = (filePath: string) =>
34 typeScriptExtensions.test(filePath);
35
36export const isFileLikeSpecifier = (specifier: string) =>
37 specifier.startsWith("./") ||
38 specifier.startsWith("../") ||
39 specifier === "." ||
40 specifier === ".." ||
41 specifier.startsWith("file:") ||
42 path.isAbsolute(specifier);
43
44/**
45 * Splits `?query#fragment` metadata off a specifier. Bare specifiers never
46 * carry fragments in Node, so only `?` is honoured there.
47 */
48export const splitSpecifierMetadata = (specifier: string) => {
49 const index = isFileLikeSpecifier(specifier)
50 ? specifier.search(/[?#]/)
51 : specifier.indexOf("?");
52 return index === -1
53 ? { specifier, metadata: "" }
54 : {
55 specifier: specifier.slice(0, index),
56 metadata: specifier.slice(index),
57 };
58};
59
60export const filePathOfUrl = (url: string | undefined) => {
61 if (url === undefined || !url.startsWith("file:")) return undefined;
62 try {
63 return fileURLToPath(new URL(url));
64 } catch {
65 return undefined;
66 }
67};
68
69export class SpecifierResolver {
70 readonly #options: ResolveOptions;
71 readonly #base: ResolverFactory;
72 readonly #byConditions = new Map<string, ResolverFactory>();
73
74 constructor(options: SpecifierResolverOptions) {
75 this.#options = {
76 tsconfig: options.tsconfig ? "auto" : undefined,
77 // TypeScript source first, then Node's implicit extensions.
78 extensions: [
79 ".ts",
80 ".tsx",
81 ".mts",
82 ".cts",
83 ".jsx",
84 ".js",
85 ".mjs",
86 ".cjs",
87 ".json",
88 ".node",
89 ],
90 // TypeScript's emitted-extension substitution: `./x.js` may point at
91 // `x.ts` (source) or `x.js` (emitted); source wins when both exist.
92 extensionAlias: {
93 ".js": [".ts", ".tsx", ".js"],
94 ".jsx": [".tsx", ".jsx"],
95 ".mjs": [".mts", ".mjs"],
96 ".cjs": [".cts", ".cjs"],
97 },
98 mainFiles: ["index"],
99 builtinModules: true,
100 moduleType: true,
101 };
102 this.#base = new ResolverFactory(this.#options);
103 }
104
105 /**
106 * Bare specifiers are probed without following symlinks: a workspace
107 * package linked into `node_modules` must still read as a package (and be
108 * left to Node), not as the project file its real path points at.
109 */
110 #resolver(conditions: ReadonlyArray<string>, symlinks: boolean) {
111 const key = `${symlinks} ${conditions.join(" ")}`;
112 let resolver = this.#byConditions.get(key);
113 if (resolver === undefined) {
114 // `cloneWithOptions` replaces the option set (sharing only the cache),
115 // so each conditions variant restates the base options.
116 resolver = this.#base.cloneWithOptions({
117 ...this.#options,
118 symlinks,
119 conditionNames: [...conditions],
120 });
121 this.#byConditions.set(key, resolver);
122 }
123 return resolver;
124 }
125
126 /**
127 * Resolves `specifier` as imported from `parentPath` to an absolute file
128 * path, or `undefined` when Oxc cannot resolve it (Node then reports the
129 * error), when it is a builtin, or when a bare specifier lands inside
130 * `node_modules` — packages are Node's business, only `paths` aliases
131 * that map onto project files are ours.
132 */
133 resolve(
134 parentPath: string,
135 specifier: string,
136 conditions: ReadonlyArray<string>,
137 ): string | undefined {
138 const request = specifier.startsWith("file:")
139 ? filePathOfUrl(specifier)
140 : specifier;
141 if (request === undefined) return undefined;
142 let result;
143 try {
144 result = this.#resolver(
145 conditions,
146 isFileLikeSpecifier(request),
147 ).resolveFileSync(parentPath, request);
148 } catch {
149 return undefined;
150 }
151 if (result.path === undefined) return undefined;
152 if (!isFileLikeSpecifier(request) && !isProjectPath(result.path)) {
153 return undefined;
154 }
155 return result.path;
156 }
157}