| 1 | import path from "node:path"; |
| 2 | import { fileURLToPath } from "node:url"; |
| 3 | import { 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 | */ |
| 19 | export interface SpecifierResolverOptions { |
| 20 | /** Discover `tsconfig.json` upward from each importing file. */ |
| 21 | readonly tsconfig: boolean; |
| 22 | } |
| 23 | |
| 24 | export const nodeModulesSegment = `${path.sep}node_modules${path.sep}`; |
| 25 | |
| 26 | /** Local project code: a file path outside every `node_modules` directory. */ |
| 27 | export const isProjectPath = (filePath: string) => |
| 28 | !filePath.includes(nodeModulesSegment); |
| 29 | |
| 30 | const typeScriptExtensions = /\.(?:[cm]?ts|[tj]sx)$/; |
| 31 | |
| 32 | /** Whether TypeScript's extension substitution applies to this importer. */ |
| 33 | export const isTypeScriptPath = (filePath: string) => |
| 34 | typeScriptExtensions.test(filePath); |
| 35 | |
| 36 | export 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 | */ |
| 48 | export 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 | |
| 60 | export 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 | |
| 69 | export 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 | } |