1import { createHash } from "node:crypto";
2import {
3 existsSync,
4 mkdirSync,
5 promises as fs,
6 readFileSync,
7 renameSync,
8 unlinkSync,
9 writeFileSync,
10} from "node:fs";
11import os from "node:os";
12import path from "node:path";
13import rolldown from "rolldown/package.json" with { type: "json" };
14import self from "../package.json" with { type: "json" };
15
16/**
17 * Anything that changes Oxc's output for identical input invalidates every
18 * entry: the transformer itself (rolldown), this package's transform
19 * pipeline, and the on-disk entry layout.
20 */
21const CACHE_VERSION = ["1", self.version, rolldown.version].join("-");
22
23/** Entries untouched for this long are swept on the first disk access. */
24const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
25
26export const TRANSFORM_CACHE_ENV = "ALCHEMY_TRANSFORM_CACHE";
27
28/**
29 * Per-user directory, like tsx's `tsx-<uid>`: `tmpdir()` is shared on
30 * multi-user machines, and entries are written with the owner's umask.
31 */
32const defaultDirectory = () => {
33 const user = process.geteuid?.() ?? os.userInfo().username;
34 return path.join(os.tmpdir(), `alchemy-oxc-${user}`);
35};
36
37/**
38 * The directory to use for the given option, or `undefined` when caching is
39 * off. `cache: false` and `ALCHEMY_TRANSFORM_CACHE=0` disable it; a string
40 * (option or env) names the directory; otherwise the per-user default.
41 */
42export const resolveCacheDirectory = (
43 option: boolean | string | undefined,
44): string | undefined => {
45 if (option === false) return undefined;
46 if (typeof option === "string") return option;
47 const env = process.env[TRANSFORM_CACHE_ENV];
48 if (env === "0" || env === "false") return undefined;
49 if (env !== undefined && env !== "") return env;
50 return defaultDirectory();
51};
52
53/** One transform's output as handed to {@link TransformCache.set}. */
54export interface TransformCacheEntry {
55 readonly format: "module" | "commonjs";
56 readonly code: string;
57 /** Source map JSON, or `undefined` when the transform produced none. */
58 readonly map: string | undefined;
59}
60
61/** A cache hit: the code, and where its source map lives on disk. */
62export interface CachedTransform {
63 readonly format: "module" | "commonjs";
64 readonly code: string;
65 /** Absolute path of the entry's `.map` file, present when it has one. */
66 readonly mapFile: string | undefined;
67}
68
69/**
70 * On-disk cache of Oxc transform output shared by every process on the
71 * machine — the `alchemy` CLI, its dev exec child, the local-provider
72 * sidecars and dev-server runners all load the same source files, and
73 * without this each of them transpiles the whole graph again.
74 *
75 * Modelled on tsx's file cache: entries are keyed by a hash of the source
76 * file's path, size and mtime, the transform options and the resolved
77 * tsconfig, so a change to any input is simply a different key; nothing is
78 * ever invalidated in place. Size plus nanosecond mtime stands in for the
79 * contents so a hit never reads the source.
80 *
81 * An entry is two files: `<key>.json` with the code, and `<key>.map` with
82 * the source map. The map stays on disk and is referenced from the module
83 * by path rather than inlined — an inline `data:` map is part of the
84 * script's source text, which V8 keeps for the process lifetime; for a
85 * graph the size of alchemy's that is hundreds of megabytes per process.
86 * Node reads the referenced file synchronously as it compiles the module,
87 * so the map is written (atomically) before `set` returns; the code entry
88 * is a fire-and-forget write, because a missing one is only a slower
89 * cache. Reads are synchronous (the loader hook is). Stale entries are
90 * swept by age once per process.
91 */
92export class TransformCache {
93 readonly #directory: string;
94 #ready = false;
95 #sequence = 0;
96
97 constructor(directory: string) {
98 this.#directory = directory;
99 }
100
101 /** The entry key for these transform inputs. */
102 key(parts: ReadonlyArray<string>): string {
103 const hash = createHash("sha1");
104 hash.update(CACHE_VERSION);
105 for (const part of parts) {
106 // Length-prefixed so adjacent parts cannot run into each other.
107 hash.update(`\0${part.length}\0`);
108 hash.update(part);
109 }
110 return hash.digest("hex");
111 }
112
113 /**
114 * The cached transform, or `undefined` on a miss. An entry whose map file
115 * has gone (swept, or never landed) is a miss too: the module would
116 * otherwise reference a map that is not there.
117 */
118 get(key: string): CachedTransform | undefined {
119 this.#prepare();
120 let raw: string;
121 try {
122 raw = readFileSync(this.#file(key, "json"), "utf8");
123 } catch {
124 return undefined;
125 }
126 let entry: { format?: unknown; code?: unknown; map?: unknown };
127 try {
128 entry = JSON.parse(raw);
129 } catch {
130 return undefined;
131 }
132 if (
133 (entry.format !== "module" && entry.format !== "commonjs") ||
134 typeof entry.code !== "string" ||
135 typeof entry.map !== "boolean"
136 ) {
137 return undefined;
138 }
139 const mapFile = this.#file(key, "map");
140 if (entry.map && !existsSync(mapFile)) return undefined;
141 return {
142 format: entry.format,
143 code: entry.code,
144 mapFile: entry.map ? mapFile : undefined,
145 };
146 }
147
148 /**
149 * Stores one transform. Returns the map file's path once it is on disk,
150 * or `undefined` when there is no map or it could not be written — the
151 * caller then falls back to inlining it.
152 */
153 set(key: string, entry: TransformCacheEntry): string | undefined {
154 this.#prepare();
155 let mapFile: string | undefined;
156 if (entry.map !== undefined) {
157 mapFile = this.#file(key, "map");
158 if (!this.#writeAtomically(mapFile, entry.map)) return undefined;
159 }
160 const file = this.#file(key, "json");
161 const temporary = this.#temporary(file);
162 // Best effort: a cache that cannot be written is only a slower cache.
163 fs.writeFile(
164 temporary,
165 JSON.stringify({
166 format: entry.format,
167 code: entry.code,
168 map: mapFile !== undefined,
169 }),
170 )
171 .then(() => fs.rename(temporary, file))
172 .catch(() => fs.unlink(temporary).catch(() => {}));
173 return mapFile;
174 }
175
176 #file(key: string, extension: "json" | "map") {
177 return path.join(this.#directory, `${key}.${extension}`);
178 }
179
180 #temporary(file: string) {
181 return `${file}.${process.pid}.${this.#sequence++}.tmp`;
182 }
183
184 /** Temp file plus rename: concurrent readers never see a partial file. */
185 #writeAtomically(file: string, content: string): boolean {
186 const temporary = this.#temporary(file);
187 try {
188 writeFileSync(temporary, content);
189 renameSync(temporary, file);
190 return true;
191 } catch {
192 try {
193 unlinkSync(temporary);
194 } catch {}
195 return false;
196 }
197 }
198
199 #prepare() {
200 if (this.#ready) return;
201 this.#ready = true;
202 try {
203 mkdirSync(this.#directory, { recursive: true });
204 } catch {
205 return;
206 }
207 // Off the hot path: the loader hook that got us here is synchronous.
208 setImmediate(() => {
209 this.#sweep().catch(() => {});
210 });
211 }
212
213 async #sweep() {
214 const cutoff = Date.now() - MAX_AGE_MS;
215 const names = await fs.readdir(this.#directory);
216 await Promise.all(
217 names.map(async (name) => {
218 const file = path.join(this.#directory, name);
219 try {
220 const { mtimeMs } = await fs.stat(file);
221 if (mtimeMs < cutoff) await fs.unlink(file);
222 } catch {}
223 }),
224 );
225 }
226}