1import { createHash } from "node:crypto";
2import { readFileSync } from "node:fs";
3import path from "node:path";
4import { watch, type ChokidarOptions, type FSWatcher } from "chokidar";
5
6export interface DependencyChange {
7 readonly paths: ReadonlySet<string>;
8}
9
10export type DependencyChangeListener = (change: DependencyChange) => void;
11
12export interface DependencyWatcherOptions {
13 /** Chokidar options passed to the file watcher. */
14 readonly watch?: ChokidarOptions | undefined;
15 readonly debounceMs?: number | undefined;
16}
17
18/**
19 * Watches an explicit set of absolute file paths and delivers debounced
20 * batches only when their contents change. Chokidar follows editor atomic-save
21 * replacements without polling by default. The set is replaced wholesale
22 * with {@link set}, so the watcher always mirrors exactly the files the caller
23 * currently depends on.
24 */
25export class DependencyWatcher {
26 readonly #options: DependencyWatcherOptions;
27 readonly #listeners = new Set<DependencyChangeListener>();
28 readonly #watcher: FSWatcher;
29 #dependencies: ReadonlySet<string> = new Set();
30 #fingerprints = new Map<string, string | undefined>();
31 #pending = new Set<string>();
32 #timer: NodeJS.Timeout | undefined;
33 #closed = false;
34
35 constructor(options: DependencyWatcherOptions = {}) {
36 this.#options = options;
37 this.#watcher = watch([], {
38 ...options.watch,
39 ignoreInitial: true,
40 });
41 this.#watcher.on("all", (_event, changed) => {
42 const absolute = path.resolve(this.#cwd(), changed);
43 if (this.#dependencies.has(absolute)) this.#queue(absolute);
44 });
45 }
46
47 get dependencies(): ReadonlySet<string> {
48 return this.#dependencies;
49 }
50
51 subscribe(listener: DependencyChangeListener): () => void {
52 this.#listeners.add(listener);
53 return () => this.#listeners.delete(listener);
54 }
55
56 /** Replace the watched set with `dependencies` (absolute paths). */
57 set(dependencies: ReadonlySet<string>): void {
58 if (this.#closed) return;
59 const previous = this.#dependencies;
60 this.#dependencies = dependencies;
61 const removed = [...previous].filter(
62 (dependency) => !dependencies.has(dependency),
63 );
64 const added = [...dependencies].filter(
65 (dependency) => !previous.has(dependency),
66 );
67 if (removed.length > 0) {
68 for (const dependency of removed) {
69 this.#fingerprints.delete(dependency);
70 this.#pending.delete(dependency);
71 }
72 void this.#watcher.unwatch(removed);
73 }
74 if (added.length > 0) {
75 for (const dependency of added) {
76 this.#fingerprints.set(dependency, this.#fingerprint(dependency));
77 }
78 this.#watcher.add(added);
79 }
80 }
81
82 async close(): Promise<void> {
83 if (this.#closed) return;
84 this.#closed = true;
85 if (this.#timer !== undefined) clearTimeout(this.#timer);
86 await this.#watcher.close();
87 this.#listeners.clear();
88 }
89
90 async [Symbol.asyncDispose](): Promise<void> {
91 await this.close();
92 }
93
94 #cwd(): string {
95 return this.#options.watch?.cwd ?? process.cwd();
96 }
97
98 #queue(changed: string): void {
99 this.#pending.add(changed);
100 if (this.#timer !== undefined) clearTimeout(this.#timer);
101 this.#timer = setTimeout(() => {
102 this.#timer = undefined;
103 const pending = this.#pending;
104 this.#pending = new Set();
105 const paths = new Set<string>();
106 for (const dependency of pending) {
107 const previous = this.#fingerprints.get(dependency);
108 const current = this.#fingerprint(dependency);
109 if (current === previous) continue;
110 this.#fingerprints.set(dependency, current);
111 paths.add(dependency);
112 }
113 if (paths.size === 0) return;
114 for (const listener of this.#listeners) listener({ paths });
115 }, this.#options.debounceMs ?? 50);
116 }
117
118 #fingerprint(file: string): string | undefined {
119 try {
120 return createHash("sha256")
121 .update(readFileSync(file))
122 .digest("base64url");
123 } catch {
124 // Missing and unreadable are both materially different from the last
125 // successfully read contents, while repeated missing-file events fold
126 // onto the same value.
127 return undefined;
128 }
129 }
130}