| 1 | import { createHash } from "node:crypto"; |
| 2 | import { readFileSync } from "node:fs"; |
| 3 | import path from "node:path"; |
| 4 | import { watch, type ChokidarOptions, type FSWatcher } from "chokidar"; |
| 5 | |
| 6 | export interface DependencyChange { |
| 7 | readonly paths: ReadonlySet<string>; |
| 8 | } |
| 9 | |
| 10 | export type DependencyChangeListener = (change: DependencyChange) => void; |
| 11 | |
| 12 | export 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 | */ |
| 25 | export 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 | } |