| 1 | import { existsSync, readFileSync, statSync } from "node:fs"; |
| 2 | import path from "node:path"; |
| 3 | import { pathToFileURL } from "node:url"; |
| 4 | import { resolveTsconfig } from "rolldown/experimental"; |
| 5 | import { |
| 6 | parseSync, |
| 7 | transformSync, |
| 8 | TsconfigCache, |
| 9 | type TransformOptions, |
| 10 | } from "rolldown/utils"; |
| 11 | import type { OxcLoaderOptions } from "./register-oxc.ts"; |
| 12 | import { resolveCacheDirectory, TransformCache } from "./transform-cache.ts"; |
| 13 | |
| 14 | /** Extensions Oxc transpiles; everything else is JavaScript Node can run. */ |
| 15 | export const transformExtensions = new Set([ |
| 16 | ".ts", |
| 17 | ".tsx", |
| 18 | ".mts", |
| 19 | ".cts", |
| 20 | ".jsx", |
| 21 | ]); |
| 22 | |
| 23 | export 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 | */ |
| 30 | const 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. */ |
| 46 | const 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 | |
| 68 | const 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 | */ |
| 88 | const 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 | */ |
| 99 | const 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 | */ |
| 108 | const withoutSourcesContent = ({ |
| 109 | sourcesContent: _, |
| 110 | ...map |
| 111 | }: NonNullable<ReturnType<typeof transformSync>["map"]>) => map; |
| 112 | |
| 113 | export interface TransformedSource { |
| 114 | readonly format: ModuleFormat; |
| 115 | readonly source: string; |
| 116 | } |
| 117 | |
| 118 | export 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 | } |