@alchemy.run/node-utils 2.0.0-beta.77 → 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.
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import * as NodeModule from "node:module";
2
2
  import {
3
3
  registerHooks,
4
4
  type LoadFnOutput,
@@ -7,10 +7,6 @@ import {
7
7
  type ResolveHookContext,
8
8
  } from "node:module";
9
9
  import { pathToFileURL } from "node:url";
10
- import type {
11
- ImportLoader,
12
- ImportLoaderRegistrationOptions,
13
- } from "./import-loader.ts";
14
10
  import {
15
11
  filePathOfUrl,
16
12
  isFileLikeSpecifier,
@@ -20,26 +16,69 @@ import {
20
16
  } from "./resolve-specifier.ts";
21
17
  import { SourceTransformer } from "./transform-source.ts";
22
18
 
23
- export type {
24
- ImportLoader as RegisteredOxcImporter,
25
- ImportLoaderRegistrationOptions as RegisterOxcOptions,
26
- SourceTransform,
27
- SourceTransformResult,
28
- TransformContext,
29
- } from "./import-loader.ts";
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
+ }
30
76
 
31
- const protocol = "alchemy-import:";
32
77
  const namespaceParameter = "alchemy-import-namespace";
33
78
  const globalRegistrationKey = Symbol.for(
34
79
  "@alchemy.run/node-utils/register-oxc",
35
80
  );
36
81
 
37
- interface ImportRequest {
38
- readonly namespace?: string | undefined;
39
- readonly parentURL: string;
40
- readonly specifier: string;
41
- }
42
-
43
82
  type NextResolve = (
44
83
  specifier: string,
45
84
  context?: Partial<ResolveHookContext>,
@@ -63,45 +102,34 @@ const withNamespace = (url: string, namespace: string) => {
63
102
  return parsed.href;
64
103
  };
65
104
 
66
- const parseRequest = (specifier: string): ImportRequest | undefined => {
67
- if (!specifier.startsWith(protocol)) return undefined;
68
- return JSON.parse(decodeURIComponent(specifier.slice(protocol.length)));
69
- };
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
+ })();
70
127
 
71
128
  /** Specifiers Node owns outright: builtins, data URLs, remote schemes. */
72
129
  const isForeignSpecifier = (specifier: string) =>
73
130
  /^(?:node:|data:|[a-z][a-z\d+.-]*:\/\/)/i.test(specifier) &&
74
131
  !specifier.startsWith("file:");
75
132
 
76
- const notFoundCodes = new Set([
77
- "ERR_MODULE_NOT_FOUND",
78
- "MODULE_NOT_FOUND",
79
- "ERR_UNSUPPORTED_DIR_IMPORT",
80
- "ERR_PACKAGE_PATH_NOT_EXPORTED",
81
- ]);
82
-
83
- const isNotFound = (error: unknown): error is Error & { url?: string } =>
84
- error instanceof Error &&
85
- notFoundCodes.has((error as { code?: string }).code ?? "");
86
-
87
- /**
88
- * The file Node could not find, from the error it raised. Node names the
89
- * resolved target (`url` on ESM errors, the message on CommonJS ones) —
90
- * for a package `exports` entry pointing at emitted JavaScript that was
91
- * never built, that is the `.js` path whose `.ts` source we can substitute.
92
- */
93
- const missingPathOf = (error: Error & { url?: string }) => {
94
- if (error.url !== undefined) return filePathOfUrl(error.url);
95
- const match = error.message.match(/^Cannot find module '([^']+)'/);
96
- if (match === null) return undefined;
97
- const [, target] = match;
98
- if (target === undefined) return undefined;
99
- if (target.startsWith("file:")) return filePathOfUrl(target);
100
- return target.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(target)
101
- ? target
102
- : undefined;
103
- };
104
-
105
133
  /** A `require()` reaching the hooks: Node's CommonJS resolver wants paths, not URLs. */
106
134
  const isRequireContext = (context: ResolveHookContext) =>
107
135
  context.conditions?.includes("require") === true &&
@@ -156,27 +184,7 @@ const resolveSpecifier = (
156
184
  }
157
185
  }
158
186
 
159
- try {
160
- return nextResolve(specifier, context);
161
- } catch (error) {
162
- if (!isNotFound(error)) throw error;
163
- // A package `exports`/`main` target naming emitted JavaScript that only
164
- // exists as TypeScript source (workspace packages in a checkout).
165
- const missing = missingPathOf(error);
166
- if (missing !== undefined && isFileLikeSpecifier(missing)) {
167
- const candidate = resolver.resolveMissing(missing, conditions);
168
- if (candidate !== undefined && candidate !== missing) {
169
- const resolved = resolveWithCandidate(
170
- candidate,
171
- metadata,
172
- context,
173
- nextResolve,
174
- );
175
- if (resolved !== undefined) return resolved;
176
- }
177
- }
178
- throw error;
179
- }
187
+ return nextResolve(specifier, context);
180
188
  };
181
189
 
182
190
  /**
@@ -200,16 +208,14 @@ const withJsonAttribute = (url: string, context: LoadHookContext) => {
200
208
  * does. A namespaced registration also provides a scoped import whose
201
209
  * namespace propagates through the complete ESM graph.
202
210
  */
203
- export const registerOxc = (
204
- options: ImportLoaderRegistrationOptions = {},
205
- ): ImportLoader => {
211
+ export const registerOxc = (options: RegisterOxcOptions = {}): OxcLoader => {
206
212
  // One global (un-namespaced) registration per process. Alchemy starts every
207
213
  // Node process with `--import` of a file that calls this, and in-process
208
214
  // callers (the dev exec child, tests) may call it again; a second copy of
209
215
  // the hooks would only re-run the resolve chain. The marker lives on
210
216
  // globalThis because a checkout can load this module twice (src/ and lib/).
211
217
  const globalRegistration = globalThis as typeof globalThis & {
212
- [globalRegistrationKey]?: ImportLoader;
218
+ [globalRegistrationKey]?: OxcLoader;
213
219
  };
214
220
  if (options.namespace === undefined) {
215
221
  const existing = globalRegistration[globalRegistrationKey];
@@ -221,19 +227,20 @@ export const registerOxc = (
221
227
  });
222
228
  const shouldInvalidate = options.shouldInvalidate ?? (() => true);
223
229
 
224
- // Transformed sources carry inline source maps; Node only applies them to
225
- // stack traces once source-map support is on.
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.
226
233
  const sourceMapsWereEnabled = process.sourceMapsEnabled;
227
234
  process.setSourceMapsEnabled(true);
228
235
 
229
236
  const hooks = registerHooks({
230
237
  resolve(specifier, context, nextResolve) {
231
- const request = parseRequest(specifier);
232
- const inheritedNamespace = namespaceOf(context.parentURL);
238
+ // A graph's entry carries the namespace itself (see `import` below);
239
+ // everything it imports inherits it from the importing module's URL.
233
240
  const namespace =
234
241
  options.namespace === undefined
235
242
  ? undefined
236
- : (request?.namespace ?? inheritedNamespace);
243
+ : (namespaceOf(specifier) ?? namespaceOf(context.parentURL));
237
244
 
238
245
  if (options.namespace !== undefined && namespace !== options.namespace) {
239
246
  return nextResolve(specifier, context);
@@ -248,14 +255,12 @@ export const registerOxc = (
248
255
  ...new Set([...options.conditions, ...context.conditions]),
249
256
  ],
250
257
  };
251
- const resolved = request
252
- ? resolveSpecifier(
253
- resolver,
254
- request.specifier,
255
- { ...resolutionContext, parentURL: request.parentURL },
256
- nextResolve,
257
- )
258
- : resolveSpecifier(resolver, specifier, resolutionContext, nextResolve);
258
+ const resolved = resolveSpecifier(
259
+ resolver,
260
+ specifier,
261
+ resolutionContext,
262
+ nextResolve,
263
+ );
259
264
  if (
260
265
  namespace !== undefined &&
261
266
  resolved.url.startsWith("file:") &&
@@ -271,6 +276,7 @@ export const registerOxc = (
271
276
  return resolved;
272
277
  },
273
278
  load(url, context, nextLoad): LoadFnOutput {
279
+ scheduleCompileCacheFlush();
274
280
  const namespace = namespaceOf(url);
275
281
  if (options.namespace !== undefined && namespace !== options.namespace) {
276
282
  return nextLoad(url, context);
@@ -284,11 +290,7 @@ export const registerOxc = (
284
290
  if (options.filter !== undefined && !options.filter(filePath)) {
285
291
  return nextLoad(cleanUrl, withJsonAttribute(cleanUrl, context));
286
292
  }
287
- const transformed = transformer.transform(
288
- filePath,
289
- cleanUrl,
290
- context.format,
291
- );
293
+ const transformed = transformer.transform(filePath, context.format);
292
294
  if (transformed === undefined) {
293
295
  return nextLoad(cleanUrl, withJsonAttribute(cleanUrl, context));
294
296
  }
@@ -296,17 +298,28 @@ export const registerOxc = (
296
298
  },
297
299
  });
298
300
 
299
- const loader: ImportLoader = {
301
+ const loader: OxcLoader = {
300
302
  import<T>(specifier: string, parentURL: string) {
301
- const request: ImportRequest = {
302
- namespace: options.namespace,
303
- parentURL: parentURL.startsWith("file:")
304
- ? parentURL
305
- : pathToFileURL(parentURL).href,
306
- specifier,
307
- };
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;
308
319
  return import(
309
- `${protocol}${encodeURIComponent(JSON.stringify(request))}`
320
+ options.namespace === undefined
321
+ ? url
322
+ : withNamespace(url, options.namespace)
310
323
  ) as Promise<T>;
311
324
  },
312
325
  unregister() {
@@ -322,21 +335,3 @@ export const registerOxc = (
322
335
  }
323
336
  return loader;
324
337
  };
325
-
326
- /**
327
- * One-shot TypeScript import that leaves the rest of the runtime untouched:
328
- * a private namespace is registered for the call, so nothing is shared with
329
- * other imports of the same files. Mirrors tsx's `tsImport`.
330
- */
331
- export const tsImport = async <T = unknown>(
332
- specifier: string,
333
- parentURL: string,
334
- options: Omit<ImportLoaderRegistrationOptions, "namespace"> = {},
335
- ): Promise<T> => {
336
- const loader = registerOxc({ ...options, namespace: randomUUID() });
337
- try {
338
- return await loader.import<T>(specifier, parentURL);
339
- } finally {
340
- await loader.unregister();
341
- }
342
- };
@@ -154,26 +154,4 @@ export class SpecifierResolver {
154
154
  }
155
155
  return result.path;
156
156
  }
157
-
158
- /**
159
- * Given a file path Node failed to find (typically an `exports`/`main`
160
- * target that names emitted JavaScript that was never built), finds the
161
- * TypeScript source it was emitted from via extension substitution.
162
- */
163
- resolveMissing(
164
- missingPath: string,
165
- conditions: ReadonlyArray<string>,
166
- ): string | undefined {
167
- const directory = path.dirname(missingPath);
168
- const base = path.basename(missingPath);
169
- try {
170
- const result = this.#resolver(conditions, true).sync(
171
- directory,
172
- `./${base}`,
173
- );
174
- return result.path;
175
- } catch {
176
- return undefined;
177
- }
178
- }
179
157
  }
@@ -0,0 +1,226 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ promises as fs,
6
+ readFileSync,
7
+ renameSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import rolldown from "rolldown/package.json" with { type: "json" };
14
+ import 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
+ */
21
+ const CACHE_VERSION = ["1", self.version, rolldown.version].join("-");
22
+
23
+ /** Entries untouched for this long are swept on the first disk access. */
24
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
25
+
26
+ export 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
+ */
32
+ const 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
+ */
42
+ export 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}. */
54
+ export 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. */
62
+ export 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
+ */
92
+ export 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
+ }