1import * as NodeModule from "node:module";
2import {
3 registerHooks,
4 type LoadFnOutput,
5 type LoadHookContext,
6 type ResolveFnOutput,
7 type ResolveHookContext,
8} from "node:module";
9import { pathToFileURL } from "node:url";
10import {
11 filePathOfUrl,
12 isFileLikeSpecifier,
13 isProjectPath,
14 SpecifierResolver,
15 splitSpecifierMetadata,
16} from "./resolve-specifier.ts";
17import { SourceTransformer } from "./transform-source.ts";
18
19export interface OxcLoaderOptions {
20 /**
21 * Additional package export conditions used during module resolution.
22 * They are made available alongside Node's ambient conditions to both the
23 * TypeScript-aware resolver and Node's package exports resolver.
24 */
25 readonly conditions?: ReadonlyArray<string> | undefined;
26 /**
27 * Honour `tsconfig.json` discovered upward from each file: compiler
28 * options for the transform, `paths`/`baseUrl` aliases for resolution.
29 * @default true
30 */
31 readonly tsconfig?: boolean | undefined;
32 /** Controls which file URLs belong to the fresh import graph. */
33 readonly shouldInvalidate?:
34 | ((url: string, parentURL: string | undefined) => boolean)
35 | undefined;
36 /**
37 * Limits transformation to matching absolute file paths; everything else
38 * loads through Node untouched. Lets a published install transpile only
39 * the user's own TypeScript while alchemy and its dependencies run their
40 * built JavaScript.
41 */
42 readonly filter?: ((path: string) => boolean) | undefined;
43 /**
44 * On-disk cache of Oxc output shared by every process on the machine, so
45 * the CLI, its dev exec child and the local-provider sidecars transpile
46 * each source file once between them rather than once each. `false`
47 * disables it, a string names the directory.
48 * @default `$ALCHEMY_TRANSFORM_CACHE` (`0` disables), else a per-user
49 * directory under the OS temp directory
50 */
51 readonly cache?: boolean | string | undefined;
52}
53
54export interface RegisterOxcOptions extends OxcLoaderOptions {
55 /**
56 * Isolates one import graph in the runtime's module cache: every file
57 * URL the graph resolves carries this namespace as a query parameter, so
58 * the same files import again as fresh modules under a new namespace.
59 * This is how `alchemy dev` reloads the user's stack (see
60 * `watch-import.ts`); an un-namespaced registration is the process-wide
61 * TypeScript loader.
62 */
63 readonly namespace?: string | undefined;
64 /** Called once the runtime loads a file in this registration's graph. */
65 readonly onImport?: ((url: string) => void) | undefined;
66}
67
68export interface OxcLoader {
69 /**
70 * Imports a file under this registration's namespace. `specifier` is a
71 * file URL, an absolute path, or a path relative to `parentURL`.
72 */
73 import<T = unknown>(specifier: string, parentURL: string): Promise<T>;
74 unregister(): void;
75}
76
77const namespaceParameter = "alchemy-import-namespace";
78const globalRegistrationKey = Symbol.for(
79 "@alchemy.run/node-utils/register-oxc",
80);
81
82type NextResolve = (
83 specifier: string,
84 context?: Partial<ResolveHookContext>,
85) => ResolveFnOutput;
86
87const namespaceOf = (url: string | undefined) => {
88 if (url === undefined || !url.startsWith("file:")) return undefined;
89 return new URL(url).searchParams.get(namespaceParameter) ?? undefined;
90};
91
92const withoutNamespace = (url: string) => {
93 if (!url.startsWith("file:")) return url;
94 const parsed = new URL(url);
95 parsed.searchParams.delete(namespaceParameter);
96 return parsed.href;
97};
98
99const withNamespace = (url: string, namespace: string) => {
100 const parsed = new URL(url);
101 parsed.searchParams.set(namespaceParameter, namespace);
102 return parsed.href;
103};
104
105/**
106 * Node's module compile cache (`module.enableCompileCache`) keeps V8 code
107 * cache for compiled modules — transformed TypeScript included, since it is
108 * keyed by the compiled source — but Node only persists it once after the
109 * entry module evaluated and again on a clean exit. Alchemy processes load
110 * most of their graph lazily after that point (commands, the user's stack,
111 * provider layers) and usually stop on a signal, so without an explicit
112 * flush that code never reaches the cache. Flush once module loading has
113 * gone quiet; a no-op when the cache is off or this Node predates it.
114 */
115const scheduleCompileCacheFlush = (() => {
116 let timer: NodeJS.Timeout | undefined;
117 return () => {
118 if (NodeModule.getCompileCacheDir?.() === undefined) return;
119 if (timer !== undefined) clearTimeout(timer);
120 timer = setTimeout(() => {
121 timer = undefined;
122 NodeModule.flushCompileCache?.();
123 }, 1000);
124 timer.unref();
125 };
126})();
127
128/** Specifiers Node owns outright: builtins, data URLs, remote schemes. */
129const isForeignSpecifier = (specifier: string) =>
130 /^(?:node:|data:|[a-z][a-z\d+.-]*:\/\/)/i.test(specifier) &&
131 !specifier.startsWith("file:");
132
133/** A `require()` reaching the hooks: Node's CommonJS resolver wants paths, not URLs. */
134const isRequireContext = (context: ResolveHookContext) =>
135 context.conditions?.includes("require") === true &&
136 !context.conditions.includes("import");
137
138const resolveWithCandidate = (
139 candidate: string,
140 metadata: string,
141 context: ResolveHookContext,
142 nextResolve: NextResolve,
143): ResolveFnOutput | undefined => {
144 const specifier = isRequireContext(context)
145 ? candidate + metadata
146 : pathToFileURL(candidate).href + metadata;
147 try {
148 return nextResolve(specifier, context);
149 } catch {
150 return undefined;
151 }
152};
153
154/**
155 * tsx-compatible resolution. Node's resolver decides in the end; Oxc's
156 * resolver supplies the TypeScript-aware candidate (tsconfig `paths`,
157 * `.js` → `.ts` substitution, extensionless and directory imports) that
158 * Node would not find on its own.
159 */
160const resolveSpecifier = (
161 resolver: SpecifierResolver,
162 specifier: string,
163 context: ResolveHookContext,
164 nextResolve: NextResolve,
165): ResolveFnOutput => {
166 if (isForeignSpecifier(specifier)) return nextResolve(specifier, context);
167
168 const parentPath = filePathOfUrl(context.parentURL);
169 const { specifier: clean, metadata } = splitSpecifierMetadata(specifier);
170 const conditions = context.conditions ?? [];
171
172 // TypeScript's rules apply to project code. Dependencies keep Node's plain
173 // resolution so published packages behave exactly as they would without us.
174 if (parentPath !== undefined && isProjectPath(parentPath)) {
175 const candidate = resolver.resolve(parentPath, clean, conditions);
176 if (candidate !== undefined) {
177 const resolved = resolveWithCandidate(
178 candidate,
179 metadata,
180 context,
181 nextResolve,
182 );
183 if (resolved !== undefined) return resolved;
184 }
185 }
186
187 return nextResolve(specifier, context);
188};
189
190/**
191 * `import data from "./x.json"` without an import attribute is how
192 * TypeScript projects import JSON (`resolveJsonModule`); Node insists on
193 * `with { type: "json" }` for ESM. Supply it, as tsx does.
194 */
195const withJsonAttribute = (url: string, context: LoadHookContext) => {
196 if (!/\.json(?:[?#]|$)/.test(url) || context.importAttributes?.type) {
197 return context;
198 }
199 return {
200 ...context,
201 importAttributes: { ...context.importAttributes, type: "json" },
202 };
203};
204
205/**
206 * Registers synchronous Node module hooks that transpile TypeScript with
207 * Rolldown's Oxc transformer and resolve it the way TypeScript (and tsx)
208 * does. A namespaced registration also provides a scoped import whose
209 * namespace propagates through the complete ESM graph.
210 */
211export const registerOxc = (options: RegisterOxcOptions = {}): OxcLoader => {
212 // One global (un-namespaced) registration per process. Alchemy starts every
213 // Node process with `--import` of a file that calls this, and in-process
214 // callers (the dev exec child, tests) may call it again; a second copy of
215 // the hooks would only re-run the resolve chain. The marker lives on
216 // globalThis because a checkout can load this module twice (src/ and lib/).
217 const globalRegistration = globalThis as typeof globalThis & {
218 [globalRegistrationKey]?: OxcLoader;
219 };
220 if (options.namespace === undefined) {
221 const existing = globalRegistration[globalRegistrationKey];
222 if (existing !== undefined) return existing;
223 }
224 const transformer = new SourceTransformer(options);
225 const resolver = new SpecifierResolver({
226 tsconfig: options.tsconfig ?? true,
227 });
228 const shouldInvalidate = options.shouldInvalidate ?? (() => true);
229
230 // Transformed sources reference their source maps (see transform-source);
231 // Node only reads and applies them to stack traces once source-map support
232 // is on.
233 const sourceMapsWereEnabled = process.sourceMapsEnabled;
234 process.setSourceMapsEnabled(true);
235
236 const hooks = registerHooks({
237 resolve(specifier, context, nextResolve) {
238 // A graph's entry carries the namespace itself (see `import` below);
239 // everything it imports inherits it from the importing module's URL.
240 const namespace =
241 options.namespace === undefined
242 ? undefined
243 : (namespaceOf(specifier) ?? namespaceOf(context.parentURL));
244
245 if (options.namespace !== undefined && namespace !== options.namespace) {
246 return nextResolve(specifier, context);
247 }
248
249 const resolutionContext =
250 options.conditions === undefined || options.conditions.length === 0
251 ? context
252 : {
253 ...context,
254 conditions: [
255 ...new Set([...options.conditions, ...context.conditions]),
256 ],
257 };
258 const resolved = resolveSpecifier(
259 resolver,
260 specifier,
261 resolutionContext,
262 nextResolve,
263 );
264 if (
265 namespace !== undefined &&
266 resolved.url.startsWith("file:") &&
267 shouldInvalidate(
268 withoutNamespace(resolved.url),
269 context.parentURL === undefined
270 ? undefined
271 : withoutNamespace(context.parentURL),
272 )
273 ) {
274 return { ...resolved, url: withNamespace(resolved.url, namespace) };
275 }
276 return resolved;
277 },
278 load(url, context, nextLoad): LoadFnOutput {
279 scheduleCompileCacheFlush();
280 const namespace = namespaceOf(url);
281 if (options.namespace !== undefined && namespace !== options.namespace) {
282 return nextLoad(url, context);
283 }
284
285 const cleanUrl = withoutNamespace(url);
286 const filePath = filePathOfUrl(cleanUrl);
287 if (filePath === undefined) return nextLoad(cleanUrl, context);
288 options.onImport?.(cleanUrl);
289
290 if (options.filter !== undefined && !options.filter(filePath)) {
291 return nextLoad(cleanUrl, withJsonAttribute(cleanUrl, context));
292 }
293 const transformed = transformer.transform(filePath, context.format);
294 if (transformed === undefined) {
295 return nextLoad(cleanUrl, withJsonAttribute(cleanUrl, context));
296 }
297 return { ...transformed, shortCircuit: true };
298 },
299 });
300
301 const loader: OxcLoader = {
302 import<T>(specifier: string, parentURL: string) {
303 if (!isFileLikeSpecifier(specifier)) {
304 throw new Error(
305 `Cannot import '${specifier}': expected a file URL or path.`,
306 );
307 }
308 const base = parentURL.startsWith("file:")
309 ? parentURL
310 : pathToFileURL(parentURL).href;
311 const url = specifier.startsWith("file:")
312 ? specifier
313 : new URL(
314 specifier.startsWith(".")
315 ? specifier
316 : pathToFileURL(specifier).href,
317 base,
318 ).href;
319 return import(
320 options.namespace === undefined
321 ? url
322 : withNamespace(url, options.namespace)
323 ) as Promise<T>;
324 },
325 unregister() {
326 hooks.deregister();
327 if (globalRegistration[globalRegistrationKey] === loader) {
328 delete globalRegistration[globalRegistrationKey];
329 }
330 if (sourceMapsWereEnabled === false) process.setSourceMapsEnabled(false);
331 },
332 };
333 if (options.namespace === undefined) {
334 globalRegistration[globalRegistrationKey] = loader;
335 }
336 return loader;
337};