| 1 | import * as NodeModule from "node:module"; |
| 2 | import { |
| 3 | registerHooks, |
| 4 | type LoadFnOutput, |
| 5 | type LoadHookContext, |
| 6 | type ResolveFnOutput, |
| 7 | type ResolveHookContext, |
| 8 | } from "node:module"; |
| 9 | import { pathToFileURL } from "node:url"; |
| 10 | import { |
| 11 | filePathOfUrl, |
| 12 | isFileLikeSpecifier, |
| 13 | isProjectPath, |
| 14 | SpecifierResolver, |
| 15 | splitSpecifierMetadata, |
| 16 | } from "./resolve-specifier.ts"; |
| 17 | import { SourceTransformer } from "./transform-source.ts"; |
| 18 | |
| 19 | export 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 | |
| 54 | export 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 | |
| 68 | export 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 | |
| 77 | const namespaceParameter = "alchemy-import-namespace"; |
| 78 | const globalRegistrationKey = Symbol.for( |
| 79 | "@alchemy.run/node-utils/register-oxc", |
| 80 | ); |
| 81 | |
| 82 | type NextResolve = ( |
| 83 | specifier: string, |
| 84 | context?: Partial<ResolveHookContext>, |
| 85 | ) => ResolveFnOutput; |
| 86 | |
| 87 | const namespaceOf = (url: string | undefined) => { |
| 88 | if (url === undefined || !url.startsWith("file:")) return undefined; |
| 89 | return new URL(url).searchParams.get(namespaceParameter) ?? undefined; |
| 90 | }; |
| 91 | |
| 92 | const 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 | |
| 99 | const 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 | */ |
| 115 | const 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. */ |
| 129 | const 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. */ |
| 134 | const isRequireContext = (context: ResolveHookContext) => |
| 135 | context.conditions?.includes("require") === true && |
| 136 | !context.conditions.includes("import"); |
| 137 | |
| 138 | const 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 | */ |
| 160 | const 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 | */ |
| 195 | const 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 | */ |
| 211 | export 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 | }; |