1import { randomUUID } from "node:crypto";
2import { fileURLToPath } from "node:url";
3import {
4 DependencyWatcher,
5 type DependencyChangeListener,
6 type DependencyWatcherOptions,
7} from "./dependency-watcher.ts";
8import type { OxcLoader, OxcLoaderOptions } from "./register-oxc.ts";
9
10export interface ImportGeneration<T> {
11 readonly value: T;
12 readonly namespace: string;
13 readonly dependencies: ReadonlySet<string>;
14}
15
16export 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 */
27export 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
103export const watchImport = <T = unknown>(
104 specifier: string,
105 options: ImportWatcherOptions,
106) => new ImportWatcher<T>(specifier, options);