@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,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
+ }
@@ -0,0 +1,254 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { resolveTsconfig } from "rolldown/experimental";
5
+ import {
6
+ parseSync,
7
+ transformSync,
8
+ TsconfigCache,
9
+ type TransformOptions,
10
+ } from "rolldown/utils";
11
+ import type { OxcLoaderOptions } from "./register-oxc.ts";
12
+ import { resolveCacheDirectory, TransformCache } from "./transform-cache.ts";
13
+
14
+ /** Extensions Oxc transpiles; everything else is JavaScript Node can run. */
15
+ export const transformExtensions = new Set([
16
+ ".ts",
17
+ ".tsx",
18
+ ".mts",
19
+ ".cts",
20
+ ".jsx",
21
+ ]);
22
+
23
+ export type ModuleFormat = "module" | "commonjs";
24
+
25
+ /**
26
+ * Module format from Node's `load` hook context. Node derives these from the
27
+ * extension and the nearest `package.json#type`; `*-typescript` variants are
28
+ * its TypeScript-aware spellings and mean the same thing.
29
+ */
30
+ const nodeFormat = (
31
+ format: string | null | undefined,
32
+ ): ModuleFormat | undefined => {
33
+ switch (format) {
34
+ case "module":
35
+ case "module-typescript":
36
+ return "module";
37
+ case "commonjs":
38
+ case "commonjs-typescript":
39
+ return "commonjs";
40
+ default:
41
+ return undefined;
42
+ }
43
+ };
44
+
45
+ /** Fallback for older Nodes that pass no format: extension, then package type. */
46
+ const inferFormat = (filePath: string): ModuleFormat => {
47
+ const extension = path.extname(filePath);
48
+ if (extension === ".mts" || extension === ".mjs") return "module";
49
+ if (extension === ".cts" || extension === ".cjs") return "commonjs";
50
+ let directory = path.dirname(filePath);
51
+ while (true) {
52
+ const packageJson = path.join(directory, "package.json");
53
+ if (existsSync(packageJson)) {
54
+ try {
55
+ return JSON.parse(readFileSync(packageJson, "utf8")).type === "module"
56
+ ? "module"
57
+ : "commonjs";
58
+ } catch {
59
+ return "commonjs";
60
+ }
61
+ }
62
+ const parent = path.dirname(directory);
63
+ if (parent === directory) return "commonjs";
64
+ directory = parent;
65
+ }
66
+ };
67
+
68
+ const language = (filePath: string): TransformOptions["lang"] => {
69
+ switch (path.extname(filePath)) {
70
+ case ".tsx":
71
+ return "tsx";
72
+ case ".ts":
73
+ case ".mts":
74
+ case ".cts":
75
+ return "ts";
76
+ case ".jsx":
77
+ return "jsx";
78
+ default:
79
+ return "js";
80
+ }
81
+ };
82
+
83
+ /**
84
+ * Inline map, for when there is no cache file to point at. Only the
85
+ * fallback: the base64 becomes part of the script source V8 retains, and
86
+ * for a non-ASCII source it is stored two bytes per character on top.
87
+ */
88
+ const inlineSourceMapComment = (map: string | object) => {
89
+ const json = typeof map === "string" ? map : JSON.stringify(map);
90
+ return `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(json).toString("base64")}`;
91
+ };
92
+
93
+ /**
94
+ * Map by reference. Node's source-map support only understands `data:`
95
+ * URLs and scheme-less paths (it resolves the latter against the module
96
+ * URL and reads the file), so this is the file URL's path component —
97
+ * `/var/…/x.map` on POSIX, `/C:/…/x.map` on Windows — never a `file:` URL.
98
+ */
99
+ const fileSourceMapComment = (mapFile: string) =>
100
+ `\n//# sourceMappingURL=${pathToFileURL(mapFile).pathname}`;
101
+
102
+ /**
103
+ * The transform's map without `sourcesContent`. Every source is a file on
104
+ * this machine, named by the map's `sources`, so embedding its text only
105
+ * makes the map larger than the code it describes and every process that
106
+ * loads the module pay for it.
107
+ */
108
+ const withoutSourcesContent = ({
109
+ sourcesContent: _,
110
+ ...map
111
+ }: NonNullable<ReturnType<typeof transformSync>["map"]>) => map;
112
+
113
+ export interface TransformedSource {
114
+ readonly format: ModuleFormat;
115
+ readonly source: string;
116
+ }
117
+
118
+ export class SourceTransformer {
119
+ readonly #options: OxcLoaderOptions;
120
+ readonly #tsconfigCache = new TsconfigCache();
121
+ readonly #cache: TransformCache | undefined;
122
+
123
+ constructor(options: OxcLoaderOptions) {
124
+ this.#options = options;
125
+ const directory = resolveCacheDirectory(options.cache);
126
+ this.#cache =
127
+ directory === undefined ? undefined : new TransformCache(directory);
128
+ }
129
+
130
+ /**
131
+ * Cache key for one transform, or `undefined` when the result must not be
132
+ * cached. Every input Oxc's output depends on is part of it: the file's
133
+ * path (source maps name it), its size and mtime standing in for its
134
+ * contents — a stat instead of a read plus a hash per module on the warm
135
+ * path — the effective transform options, Node's module format, and the
136
+ * tsconfig that would be discovered for the file, resolved through the
137
+ * same cache `transformSync` uses with the `extends` chain already
138
+ * merged, so editing any tsconfig in the chain is a new key.
139
+ */
140
+ #cacheKey(
141
+ filePath: string,
142
+ options: TransformOptions,
143
+ format: ModuleFormat,
144
+ ): string | undefined {
145
+ if (this.#cache === undefined) return undefined;
146
+ let stat: { size: bigint; mtimeNs: bigint };
147
+ try {
148
+ stat = statSync(filePath, { bigint: true });
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ let tsconfig: unknown = null;
153
+ try {
154
+ if (options.tsconfig === true) {
155
+ tsconfig = resolveTsconfig(filePath, this.#tsconfigCache)?.tsconfig;
156
+ } else if (typeof options.tsconfig === "string") {
157
+ tsconfig = readFileSync(options.tsconfig, "utf8");
158
+ }
159
+ } catch {
160
+ // A broken tsconfig is the transform's error to report; don't cache.
161
+ return undefined;
162
+ }
163
+ return this.#cache.key([
164
+ filePath,
165
+ `${stat.size}:${stat.mtimeNs}`,
166
+ JSON.stringify(options),
167
+ JSON.stringify(tsconfig ?? null),
168
+ format,
169
+ ]);
170
+ }
171
+
172
+ /**
173
+ * Transpiles `filePath` for Node, or returns `undefined` when the file is
174
+ * JavaScript that needs no work. `format` is what Node's `load` hook was
175
+ * told; it decides `sourceType` and the format handed back.
176
+ */
177
+ transform(
178
+ filePath: string,
179
+ format: string | null | undefined,
180
+ ): TransformedSource | undefined {
181
+ const extension = path.extname(filePath);
182
+ if (!transformExtensions.has(extension)) return undefined;
183
+
184
+ let moduleFormat = nodeFormat(format) ?? inferFormat(filePath);
185
+ const lang = language(filePath);
186
+ const options: TransformOptions = {
187
+ tsconfig: this.#options.tsconfig ?? true,
188
+ sourcemap: true,
189
+ lang,
190
+ };
191
+ // The key is taken before the source is read so a hit costs one stat
192
+ // and one cache read, never the source itself.
193
+ const key = this.#cacheKey(filePath, options, moduleFormat);
194
+ const cached = key === undefined ? undefined : this.#cache?.get(key);
195
+ if (cached !== undefined) {
196
+ return {
197
+ format: cached.format,
198
+ source:
199
+ cached.mapFile === undefined
200
+ ? cached.code
201
+ : cached.code + fileSourceMapComment(cached.mapFile),
202
+ };
203
+ }
204
+ const source = readFileSync(filePath, "utf8");
205
+ // A `.ts` file in a CommonJS package that uses `import`/`export` runs
206
+ // as ESM — the same call Node's own module-syntax detection makes for
207
+ // `.js`. Explicit `.cts` stays CommonJS regardless.
208
+ if (
209
+ moduleFormat === "commonjs" &&
210
+ extension !== ".cts" &&
211
+ parseSync(filePath, source, { lang, sourceType: "unambiguous" }).module
212
+ .hasModuleSyntax
213
+ ) {
214
+ moduleFormat = "module";
215
+ }
216
+ const transformed = transformSync(
217
+ filePath,
218
+ source,
219
+ { ...options, sourceType: moduleFormat },
220
+ this.#tsconfigCache,
221
+ );
222
+ if (transformed.errors.length > 0) {
223
+ const [error] = transformed.errors;
224
+ throw error instanceof Error
225
+ ? error
226
+ : new SyntaxError(
227
+ `${filePath}: ${(error as { message?: string }).message ?? String(error)}`,
228
+ );
229
+ }
230
+ const map =
231
+ transformed.map === undefined
232
+ ? undefined
233
+ : JSON.stringify(withoutSourcesContent(transformed.map));
234
+ // The map ends up in the module exactly one way: on disk next to the
235
+ // cache entry and referenced by path, or (cache off) inlined.
236
+ const mapFile =
237
+ key === undefined || map === undefined
238
+ ? undefined
239
+ : this.#cache?.set(key, {
240
+ format: moduleFormat,
241
+ code: transformed.code,
242
+ map,
243
+ });
244
+ return {
245
+ format: moduleFormat,
246
+ source:
247
+ mapFile !== undefined
248
+ ? transformed.code + fileSourceMapComment(mapFile)
249
+ : map !== undefined
250
+ ? transformed.code + inlineSourceMapComment(map)
251
+ : transformed.code,
252
+ };
253
+ }
254
+ }
@@ -0,0 +1,99 @@
1
+ import { realpathSync } from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ DependencyWatcher,
5
+ type DependencyChangeListener,
6
+ type DependencyWatcherOptions,
7
+ } from "./dependency-watcher.ts";
8
+
9
+ export interface BunImportTrackerOptions extends DependencyWatcherOptions {
10
+ /**
11
+ * Directory whose modules belong to the tracked graph. Files outside it and
12
+ * anything under a `node_modules` directory load untouched.
13
+ */
14
+ readonly root: string;
15
+ }
16
+
17
+ const loaders: Record<string, "js" | "jsx" | "ts" | "tsx"> = {
18
+ ".js": "js",
19
+ ".mjs": "js",
20
+ ".cjs": "js",
21
+ ".jsx": "jsx",
22
+ ".ts": "ts",
23
+ ".mts": "ts",
24
+ ".cts": "ts",
25
+ ".tsx": "tsx",
26
+ };
27
+
28
+ const escapeRegExp = (value: string) =>
29
+ value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
30
+
31
+ /**
32
+ * Records every project-local module Bun loads after registration and watches
33
+ * those files for changes.
34
+ *
35
+ * Bun has no loader hooks that can evict or re-namespace an evaluated module,
36
+ * so unlike Node's {@link ImportWatcher} this cannot import a fresh
37
+ * generation in-process. A runtime `Bun.plugin` `onLoad` hook is used purely
38
+ * as a dependency probe: it hands the source back unchanged with the loader
39
+ * Bun would have picked itself. Callers react to a change by exiting so a
40
+ * supervisor can start a fresh process.
41
+ */
42
+ export class BunImportTracker {
43
+ readonly #watcher: DependencyWatcher;
44
+ readonly #dependencies = new Set<string>();
45
+
46
+ constructor(options: BunImportTrackerOptions) {
47
+ if (process.versions.bun === undefined) {
48
+ throw new Error(
49
+ "BunImportTracker requires Bun; Node callers should use watchImport.",
50
+ );
51
+ }
52
+ this.#watcher = new DependencyWatcher(options);
53
+ // Bun reports real paths (`/private/tmp/...` for `/tmp/...` on macOS);
54
+ // match them against the root's real path too.
55
+ const root = realpathSync.native(path.resolve(options.root)) + path.sep;
56
+ const nodeModules = `node_modules${path.sep}`;
57
+ // `root` already ends in a separator, so the segment right after it has no
58
+ // leading one: a `.*${sep}node_modules${sep}` lookahead alone would miss
59
+ // `<root>node_modules/...` and intercept every dependency. Bun cannot
60
+ // return CommonJS source from `onLoad` (oven-sh/bun#19279), so a CJS
61
+ // dependency that reaches this probe loses its exports.
62
+ const filter = new RegExp(
63
+ `^${escapeRegExp(root)}(?!(?:.*${escapeRegExp(path.sep)})?${escapeRegExp(nodeModules)}).*\\.[cm]?[jt]sx?$`,
64
+ );
65
+ Bun.plugin({
66
+ name: "@alchemy.run/node-utils/watch-import-bun",
67
+ setup: (build) => {
68
+ build.onLoad({ filter }, async (args) => {
69
+ this.#dependencies.add(args.path);
70
+ this.#watcher.set(new Set(this.#dependencies));
71
+ return {
72
+ contents: await Bun.file(args.path).text(),
73
+ loader: loaders[path.extname(args.path)] ?? "js",
74
+ };
75
+ });
76
+ },
77
+ });
78
+ }
79
+
80
+ get dependencies(): ReadonlySet<string> {
81
+ return this.#watcher.dependencies;
82
+ }
83
+
84
+ subscribe(listener: DependencyChangeListener): () => void {
85
+ return this.#watcher.subscribe(listener);
86
+ }
87
+
88
+ /** Stops watching. The load hook stays registered but only echoes sources. */
89
+ close(): Promise<void> {
90
+ return this.#watcher.close();
91
+ }
92
+
93
+ async [Symbol.asyncDispose](): Promise<void> {
94
+ await this.close();
95
+ }
96
+ }
97
+
98
+ export const trackBunImports = (options: BunImportTrackerOptions) =>
99
+ new BunImportTracker(options);
@@ -0,0 +1,106 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { fileURLToPath } from "node:url";
3
+ import {
4
+ DependencyWatcher,
5
+ type DependencyChangeListener,
6
+ type DependencyWatcherOptions,
7
+ } from "./dependency-watcher.ts";
8
+ import type { OxcLoader, OxcLoaderOptions } from "./register-oxc.ts";
9
+
10
+ export interface ImportGeneration<T> {
11
+ readonly value: T;
12
+ readonly namespace: string;
13
+ readonly dependencies: ReadonlySet<string>;
14
+ }
15
+
16
+ export interface ImportWatcherOptions
17
+ extends OxcLoaderOptions, DependencyWatcherOptions {
18
+ readonly parentURL: string;
19
+ }
20
+
21
+ /**
22
+ * Imports fresh Node module generations and watches the exact files loaded by
23
+ * the current generation. Bun callers should use `BunImportTracker` from
24
+ * `./watch-import-bun.ts`: Bun cannot evict evaluated modules, so a change
25
+ * there restarts the process instead of importing a new generation.
26
+ */
27
+ export class ImportWatcher<T = unknown> {
28
+ readonly #specifier: string;
29
+ readonly #options: ImportWatcherOptions;
30
+ readonly #watcher: DependencyWatcher;
31
+ #registration: OxcLoader | undefined;
32
+ #dependencies = new Set<string>();
33
+ #closed = false;
34
+
35
+ constructor(specifier: string, options: ImportWatcherOptions) {
36
+ this.#specifier = specifier;
37
+ this.#options = options;
38
+ this.#watcher = new DependencyWatcher(options);
39
+ }
40
+
41
+ get dependencies(): ReadonlySet<string> {
42
+ return this.#dependencies;
43
+ }
44
+
45
+ subscribe(listener: DependencyChangeListener): () => void {
46
+ return this.#watcher.subscribe(listener);
47
+ }
48
+
49
+ async import(): Promise<ImportGeneration<T>> {
50
+ if (this.#closed) throw new Error("ImportWatcher is closed");
51
+ const namespace = randomUUID();
52
+ const dependencies = new Set<string>();
53
+ const {
54
+ debounceMs: _,
55
+ parentURL,
56
+ watch: _watch,
57
+ ...registerOptions
58
+ } = this.#options;
59
+ // Loaded here, not at module scope: the exec child imports this file on
60
+ // both runtimes, and the loader's Node hooks do not exist under Bun.
61
+ const { registerOxc } = await import("./register-oxc.ts");
62
+ const registration = registerOxc({
63
+ ...registerOptions,
64
+ namespace,
65
+ onImport: (url) => {
66
+ if (!url.startsWith("file:")) return;
67
+ dependencies.add(fileURLToPath(url));
68
+ // A lazy import evaluated after this generation became current
69
+ // extends the watched set immediately.
70
+ if (this.#dependencies === dependencies)
71
+ this.#watcher.set(dependencies);
72
+ },
73
+ });
74
+ try {
75
+ const value = await registration.import<T>(this.#specifier, parentURL);
76
+ await this.#registration?.unregister();
77
+ this.#registration = registration;
78
+ this.#dependencies = dependencies;
79
+ this.#watcher.set(dependencies);
80
+ return { value, namespace, dependencies };
81
+ } catch (error) {
82
+ await registration.unregister();
83
+ // Keep watching everything the failed import touched so the next save
84
+ // of any of those files retries.
85
+ this.#dependencies = new Set([...this.#dependencies, ...dependencies]);
86
+ this.#watcher.set(this.#dependencies);
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ async close(): Promise<void> {
92
+ if (this.#closed) return;
93
+ this.#closed = true;
94
+ await this.#registration?.unregister();
95
+ await this.#watcher.close();
96
+ }
97
+
98
+ async [Symbol.asyncDispose](): Promise<void> {
99
+ await this.close();
100
+ }
101
+ }
102
+
103
+ export const watchImport = <T = unknown>(
104
+ specifier: string,
105
+ options: ImportWatcherOptions,
106
+ ) => new ImportWatcher<T>(specifier, options);