1import { existsSync, readFileSync, statSync } from "node:fs";
2import path from "node:path";
3import { pathToFileURL } from "node:url";
4import { resolveTsconfig } from "rolldown/experimental";
5import {
6 parseSync,
7 transformSync,
8 TsconfigCache,
9 type TransformOptions,
10} from "rolldown/utils";
11import type { OxcLoaderOptions } from "./register-oxc.ts";
12import { resolveCacheDirectory, TransformCache } from "./transform-cache.ts";
13
14/** Extensions Oxc transpiles; everything else is JavaScript Node can run. */
15export const transformExtensions = new Set([
16 ".ts",
17 ".tsx",
18 ".mts",
19 ".cts",
20 ".jsx",
21]);
22
23export type ModuleFormat = "module" | "commonjs";
24
25/**
26 * Module format from Node's `load` hook context. Node derives these from the
27 * extension and the nearest `package.json#type`; `*-typescript` variants are
28 * its TypeScript-aware spellings and mean the same thing.
29 */
30const nodeFormat = (
31 format: string | null | undefined,
32): ModuleFormat | undefined => {
33 switch (format) {
34 case "module":
35 case "module-typescript":
36 return "module";
37 case "commonjs":
38 case "commonjs-typescript":
39 return "commonjs";
40 default:
41 return undefined;
42 }
43};
44
45/** Fallback for older Nodes that pass no format: extension, then package type. */
46const inferFormat = (filePath: string): ModuleFormat => {
47 const extension = path.extname(filePath);
48 if (extension === ".mts" || extension === ".mjs") return "module";
49 if (extension === ".cts" || extension === ".cjs") return "commonjs";
50 let directory = path.dirname(filePath);
51 while (true) {
52 const packageJson = path.join(directory, "package.json");
53 if (existsSync(packageJson)) {
54 try {
55 return JSON.parse(readFileSync(packageJson, "utf8")).type === "module"
56 ? "module"
57 : "commonjs";
58 } catch {
59 return "commonjs";
60 }
61 }
62 const parent = path.dirname(directory);
63 if (parent === directory) return "commonjs";
64 directory = parent;
65 }
66};
67
68const language = (filePath: string): TransformOptions["lang"] => {
69 switch (path.extname(filePath)) {
70 case ".tsx":
71 return "tsx";
72 case ".ts":
73 case ".mts":
74 case ".cts":
75 return "ts";
76 case ".jsx":
77 return "jsx";
78 default:
79 return "js";
80 }
81};
82
83/**
84 * Inline map, for when there is no cache file to point at. Only the
85 * fallback: the base64 becomes part of the script source V8 retains, and
86 * for a non-ASCII source it is stored two bytes per character on top.
87 */
88const inlineSourceMapComment = (map: string | object) => {
89 const json = typeof map === "string" ? map : JSON.stringify(map);
90 return `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(json).toString("base64")}`;
91};
92
93/**
94 * Map by reference. Node's source-map support only understands `data:`
95 * URLs and scheme-less paths (it resolves the latter against the module
96 * URL and reads the file), so this is the file URL's path component —
97 * `/var/…/x.map` on POSIX, `/C:/…/x.map` on Windows — never a `file:` URL.
98 */
99const fileSourceMapComment = (mapFile: string) =>
100 `\n//# sourceMappingURL=${pathToFileURL(mapFile).pathname}`;
101
102/**
103 * The transform's map without `sourcesContent`. Every source is a file on
104 * this machine, named by the map's `sources`, so embedding its text only
105 * makes the map larger than the code it describes and every process that
106 * loads the module pay for it.
107 */
108const withoutSourcesContent = ({
109 sourcesContent: _,
110 ...map
111}: NonNullable<ReturnType<typeof transformSync>["map"]>) => map;
112
113export interface TransformedSource {
114 readonly format: ModuleFormat;
115 readonly source: string;
116}
117
118export class SourceTransformer {
119 readonly #options: OxcLoaderOptions;
120 readonly #tsconfigCache = new TsconfigCache();
121 readonly #cache: TransformCache | undefined;
122
123 constructor(options: OxcLoaderOptions) {
124 this.#options = options;
125 const directory = resolveCacheDirectory(options.cache);
126 this.#cache =
127 directory === undefined ? undefined : new TransformCache(directory);
128 }
129
130 /**
131 * Cache key for one transform, or `undefined` when the result must not be
132 * cached. Every input Oxc's output depends on is part of it: the file's
133 * path (source maps name it), its size and mtime standing in for its
134 * contents — a stat instead of a read plus a hash per module on the warm
135 * path — the effective transform options, Node's module format, and the
136 * tsconfig that would be discovered for the file, resolved through the
137 * same cache `transformSync` uses with the `extends` chain already
138 * merged, so editing any tsconfig in the chain is a new key.
139 */
140 #cacheKey(
141 filePath: string,
142 options: TransformOptions,
143 format: ModuleFormat,
144 ): string | undefined {
145 if (this.#cache === undefined) return undefined;
146 let stat: { size: bigint; mtimeNs: bigint };
147 try {
148 stat = statSync(filePath, { bigint: true });
149 } catch {
150 return undefined;
151 }
152 let tsconfig: unknown = null;
153 try {
154 if (options.tsconfig === true) {
155 tsconfig = resolveTsconfig(filePath, this.#tsconfigCache)?.tsconfig;
156 } else if (typeof options.tsconfig === "string") {
157 tsconfig = readFileSync(options.tsconfig, "utf8");
158 }
159 } catch {
160 // A broken tsconfig is the transform's error to report; don't cache.
161 return undefined;
162 }
163 return this.#cache.key([
164 filePath,
165 `${stat.size}:${stat.mtimeNs}`,
166 JSON.stringify(options),
167 JSON.stringify(tsconfig ?? null),
168 format,
169 ]);
170 }
171
172 /**
173 * Transpiles `filePath` for Node, or returns `undefined` when the file is
174 * JavaScript that needs no work. `format` is what Node's `load` hook was
175 * told; it decides `sourceType` and the format handed back.
176 */
177 transform(
178 filePath: string,
179 format: string | null | undefined,
180 ): TransformedSource | undefined {
181 const extension = path.extname(filePath);
182 if (!transformExtensions.has(extension)) return undefined;
183
184 let moduleFormat = nodeFormat(format) ?? inferFormat(filePath);
185 const lang = language(filePath);
186 const options: TransformOptions = {
187 tsconfig: this.#options.tsconfig ?? true,
188 sourcemap: true,
189 lang,
190 };
191 // The key is taken before the source is read so a hit costs one stat
192 // and one cache read, never the source itself.
193 const key = this.#cacheKey(filePath, options, moduleFormat);
194 const cached = key === undefined ? undefined : this.#cache?.get(key);
195 if (cached !== undefined) {
196 return {
197 format: cached.format,
198 source:
199 cached.mapFile === undefined
200 ? cached.code
201 : cached.code + fileSourceMapComment(cached.mapFile),
202 };
203 }
204 const source = readFileSync(filePath, "utf8");
205 // A `.ts` file in a CommonJS package that uses `import`/`export` runs
206 // as ESM — the same call Node's own module-syntax detection makes for
207 // `.js`. Explicit `.cts` stays CommonJS regardless.
208 if (
209 moduleFormat === "commonjs" &&
210 extension !== ".cts" &&
211 parseSync(filePath, source, { lang, sourceType: "unambiguous" }).module
212 .hasModuleSyntax
213 ) {
214 moduleFormat = "module";
215 }
216 const transformed = transformSync(
217 filePath,
218 source,
219 { ...options, sourceType: moduleFormat },
220 this.#tsconfigCache,
221 );
222 if (transformed.errors.length > 0) {
223 const [error] = transformed.errors;
224 throw error instanceof Error
225 ? error
226 : new SyntaxError(
227 `${filePath}: ${(error as { message?: string }).message ?? String(error)}`,
228 );
229 }
230 const map =
231 transformed.map === undefined
232 ? undefined
233 : JSON.stringify(withoutSourcesContent(transformed.map));
234 // The map ends up in the module exactly one way: on disk next to the
235 // cache entry and referenced by path, or (cache off) inlined.
236 const mapFile =
237 key === undefined || map === undefined
238 ? undefined
239 : this.#cache?.set(key, {
240 format: moduleFormat,
241 code: transformed.code,
242 map,
243 });
244 return {
245 format: moduleFormat,
246 source:
247 mapFile !== undefined
248 ? transformed.code + fileSourceMapComment(mapFile)
249 : map !== undefined
250 ? transformed.code + inlineSourceMapComment(map)
251 : transformed.code,
252 };
253 }
254}