@alchemy.run/node-utils 2.0.0-beta.76 → 2.0.0-beta.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/THIRD_PARTY_LICENSES.md +11 -11
  2. package/lib/dependency-watcher.d.ts +28 -0
  3. package/lib/dependency-watcher.d.ts.map +1 -0
  4. package/lib/dependency-watcher.js +114 -0
  5. package/lib/dependency-watcher.js.map +1 -0
  6. package/lib/register-oxc.d.ts +61 -0
  7. package/lib/register-oxc.d.ts.map +1 -0
  8. package/lib/register-oxc.js +220 -0
  9. package/lib/register-oxc.js.map +1 -0
  10. package/lib/resolve-specifier.d.ts +46 -0
  11. package/lib/resolve-specifier.d.ts.map +1 -0
  12. package/lib/resolve-specifier.js +123 -0
  13. package/lib/resolve-specifier.js.map +1 -0
  14. package/lib/transform-cache.d.ts +63 -0
  15. package/lib/transform-cache.d.ts.map +1 -0
  16. package/lib/transform-cache.js +193 -0
  17. package/lib/transform-cache.js.map +1 -0
  18. package/lib/transform-source.d.ts +19 -0
  19. package/lib/transform-source.d.ts.map +1 -0
  20. package/lib/transform-source.js +213 -0
  21. package/lib/transform-source.js.map +1 -0
  22. package/lib/watch-import-bun.d.ts +30 -0
  23. package/lib/watch-import-bun.d.ts.map +1 -0
  24. package/lib/watch-import-bun.js +73 -0
  25. package/lib/watch-import-bun.js.map +1 -0
  26. package/lib/watch-import.d.ts +27 -0
  27. package/lib/watch-import.d.ts.map +1 -0
  28. package/lib/watch-import.js +79 -0
  29. package/lib/watch-import.js.map +1 -0
  30. package/package.json +21 -2
  31. package/src/dependency-watcher.ts +130 -0
  32. package/src/register-oxc.ts +337 -0
  33. package/src/resolve-specifier.ts +157 -0
  34. package/src/transform-cache.ts +226 -0
  35. package/src/transform-source.ts +254 -0
  36. package/src/watch-import-bun.ts +99 -0
  37. package/src/watch-import.ts +106 -0
@@ -0,0 +1,337 @@
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
+ };
@@ -0,0 +1,157 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { ResolverFactory, type ResolveOptions } from "rolldown/experimental";
4
+
5
+ /**
6
+ * TypeScript-aware specifier resolution on top of Oxc's resolver, mirroring
7
+ * what tsx layers over Node's resolver:
8
+ *
9
+ * - `tsconfig.json` `paths` / `baseUrl` aliases, discovered per importing file
10
+ * - emitted-extension substitution (`./x.js` → `x.ts`/`x.tsx`, `.mjs` → `.mts`,
11
+ * `.cjs` → `.cts`, `.jsx` → `.tsx`), TypeScript source winning over an
12
+ * emitted sibling
13
+ * - extensionless specifiers and directory indexes, TypeScript first
14
+ *
15
+ * Node's own resolver stays the authority: every candidate this class
16
+ * produces is handed back to Node (`nextResolve`) so it decides the format,
17
+ * validates package exports, and reports the canonical error.
18
+ */
19
+ export interface SpecifierResolverOptions {
20
+ /** Discover `tsconfig.json` upward from each importing file. */
21
+ readonly tsconfig: boolean;
22
+ }
23
+
24
+ export const nodeModulesSegment = `${path.sep}node_modules${path.sep}`;
25
+
26
+ /** Local project code: a file path outside every `node_modules` directory. */
27
+ export const isProjectPath = (filePath: string) =>
28
+ !filePath.includes(nodeModulesSegment);
29
+
30
+ const typeScriptExtensions = /\.(?:[cm]?ts|[tj]sx)$/;
31
+
32
+ /** Whether TypeScript's extension substitution applies to this importer. */
33
+ export const isTypeScriptPath = (filePath: string) =>
34
+ typeScriptExtensions.test(filePath);
35
+
36
+ export const isFileLikeSpecifier = (specifier: string) =>
37
+ specifier.startsWith("./") ||
38
+ specifier.startsWith("../") ||
39
+ specifier === "." ||
40
+ specifier === ".." ||
41
+ specifier.startsWith("file:") ||
42
+ path.isAbsolute(specifier);
43
+
44
+ /**
45
+ * Splits `?query#fragment` metadata off a specifier. Bare specifiers never
46
+ * carry fragments in Node, so only `?` is honoured there.
47
+ */
48
+ export const splitSpecifierMetadata = (specifier: string) => {
49
+ const index = isFileLikeSpecifier(specifier)
50
+ ? specifier.search(/[?#]/)
51
+ : specifier.indexOf("?");
52
+ return index === -1
53
+ ? { specifier, metadata: "" }
54
+ : {
55
+ specifier: specifier.slice(0, index),
56
+ metadata: specifier.slice(index),
57
+ };
58
+ };
59
+
60
+ export const filePathOfUrl = (url: string | undefined) => {
61
+ if (url === undefined || !url.startsWith("file:")) return undefined;
62
+ try {
63
+ return fileURLToPath(new URL(url));
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ };
68
+
69
+ export class SpecifierResolver {
70
+ readonly #options: ResolveOptions;
71
+ readonly #base: ResolverFactory;
72
+ readonly #byConditions = new Map<string, ResolverFactory>();
73
+
74
+ constructor(options: SpecifierResolverOptions) {
75
+ this.#options = {
76
+ tsconfig: options.tsconfig ? "auto" : undefined,
77
+ // TypeScript source first, then Node's implicit extensions.
78
+ extensions: [
79
+ ".ts",
80
+ ".tsx",
81
+ ".mts",
82
+ ".cts",
83
+ ".jsx",
84
+ ".js",
85
+ ".mjs",
86
+ ".cjs",
87
+ ".json",
88
+ ".node",
89
+ ],
90
+ // TypeScript's emitted-extension substitution: `./x.js` may point at
91
+ // `x.ts` (source) or `x.js` (emitted); source wins when both exist.
92
+ extensionAlias: {
93
+ ".js": [".ts", ".tsx", ".js"],
94
+ ".jsx": [".tsx", ".jsx"],
95
+ ".mjs": [".mts", ".mjs"],
96
+ ".cjs": [".cts", ".cjs"],
97
+ },
98
+ mainFiles: ["index"],
99
+ builtinModules: true,
100
+ moduleType: true,
101
+ };
102
+ this.#base = new ResolverFactory(this.#options);
103
+ }
104
+
105
+ /**
106
+ * Bare specifiers are probed without following symlinks: a workspace
107
+ * package linked into `node_modules` must still read as a package (and be
108
+ * left to Node), not as the project file its real path points at.
109
+ */
110
+ #resolver(conditions: ReadonlyArray<string>, symlinks: boolean) {
111
+ const key = `${symlinks} ${conditions.join(" ")}`;
112
+ let resolver = this.#byConditions.get(key);
113
+ if (resolver === undefined) {
114
+ // `cloneWithOptions` replaces the option set (sharing only the cache),
115
+ // so each conditions variant restates the base options.
116
+ resolver = this.#base.cloneWithOptions({
117
+ ...this.#options,
118
+ symlinks,
119
+ conditionNames: [...conditions],
120
+ });
121
+ this.#byConditions.set(key, resolver);
122
+ }
123
+ return resolver;
124
+ }
125
+
126
+ /**
127
+ * Resolves `specifier` as imported from `parentPath` to an absolute file
128
+ * path, or `undefined` when Oxc cannot resolve it (Node then reports the
129
+ * error), when it is a builtin, or when a bare specifier lands inside
130
+ * `node_modules` — packages are Node's business, only `paths` aliases
131
+ * that map onto project files are ours.
132
+ */
133
+ resolve(
134
+ parentPath: string,
135
+ specifier: string,
136
+ conditions: ReadonlyArray<string>,
137
+ ): string | undefined {
138
+ const request = specifier.startsWith("file:")
139
+ ? filePathOfUrl(specifier)
140
+ : specifier;
141
+ if (request === undefined) return undefined;
142
+ let result;
143
+ try {
144
+ result = this.#resolver(
145
+ conditions,
146
+ isFileLikeSpecifier(request),
147
+ ).resolveFileSync(parentPath, request);
148
+ } catch {
149
+ return undefined;
150
+ }
151
+ if (result.path === undefined) return undefined;
152
+ if (!isFileLikeSpecifier(request) && !isProjectPath(result.path)) {
153
+ return undefined;
154
+ }
155
+ return result.path;
156
+ }
157
+ }