1import { realpathSync } from "node:fs";
2import path from "node:path";
3import {
4 DependencyWatcher,
5 type DependencyChangeListener,
6 type DependencyWatcherOptions,
7} from "./dependency-watcher.ts";
8
9export 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
17const 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
28const 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 */
42export 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
98export const trackBunImports = (options: BunImportTrackerOptions) =>
99 new BunImportTracker(options);