webui_dev_server/watch.rs
1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Debounced filesystem watcher.
5//!
6//! Wraps `notify-debouncer-mini` so callers don't need to deal with the
7//! debouncer event type. Returns a [`WatcherHandle`] that owns the
8//! background thread; **the handle must be kept alive** for the watcher
9//! to run.
10
11use std::collections::hash_map::DefaultHasher;
12use std::collections::{HashMap, HashSet};
13use std::hash::Hasher;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::Duration;
17
18use anyhow::{Context, Result};
19use notify::RecommendedWatcher;
20use notify::RecursiveMode;
21use notify_debouncer_mini::{new_debouncer, DebounceEventResult, Debouncer};
22
23/// Owns the watcher background thread. Drop to stop watching.
24///
25/// `notify-debouncer-mini` spawns its background thread inside the
26/// debouncer struct; dropping the struct kills the thread. Consumers
27/// should hold this handle for the lifetime of their server.
28pub struct WatcherHandle {
29 _debouncer: Debouncer<RecommendedWatcher>,
30}
31
32/// Configuration for [`spawn_watcher`].
33pub struct WatchConfig {
34 /// Roots to watch recursively. Non-existent entries are silently
35 /// skipped.
36 pub paths: Vec<PathBuf>,
37 /// Subtrees to ignore. An event is suppressed when its path lives
38 /// underneath any entry here. Typical values: the build's `out_dir`,
39 /// `node_modules`, `.git`, `target`.
40 ///
41 /// Each entry is canonicalized at registration time so symlink and
42 /// path-form differences (`./dist` vs `dist`) compare correctly.
43 pub ignore: Vec<PathBuf>,
44 /// Debounce window — events arriving within this window are
45 /// coalesced into a single callback invocation.
46 pub debounce: Duration,
47 /// Optional predicate that allows byte-identical file events through.
48 ///
49 /// `webui serve --watch` uses this while a rebuild error is active so a
50 /// no-op save can retry transient failures. When the predicate returns
51 /// `false` (the common clean state), identical saves are still dropped.
52 pub retry_unchanged_when: Option<Arc<dyn Fn() -> bool + Send + Sync>>,
53}
54
55/// Start a debounced recursive watcher.
56///
57/// The closure `on_event` is invoked once per debounce window with the
58/// owned, deduplicated list of paths that changed outside any
59/// `cfg.ignore` root. If every event in a window targets an ignored
60/// subtree, the callback is not invoked.
61///
62/// Paths whose file content is byte-identical to the previous event are dropped
63/// in the clean state, so a no-op save (e.g. repeated Ctrl+S that rewrites the
64/// same bytes) triggers **no** rebuild. If `retry_unchanged_when` returns true,
65/// unchanged events are forwarded so callers can retry an active error.
66/// Deletions and files larger than an internal cap always count as changed.
67///
68/// Non-existent paths in `cfg.paths` are silently skipped; this matches
69/// the dev-server use case where some watched directories (e.g. an
70/// optional `public/`) may not exist yet.
71///
72/// # Errors
73///
74/// Returns an error if the watcher cannot be created or if a path that
75/// exists cannot be watched (typically a permissions issue).
76pub fn spawn_watcher<F>(cfg: WatchConfig, on_event: F) -> Result<WatcherHandle>
77where
78 F: Fn(Vec<PathBuf>) + Send + 'static,
79{
80 // Canonicalize ignore paths once, up front. Non-existent ignore
81 // entries are kept as-is so they still match if the path appears
82 // mid-session (e.g. a fresh `dist/` created by the first build).
83 let ignore: Vec<PathBuf> = cfg
84 .ignore
85 .iter()
86 .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
87 .collect();
88
89 let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
90 let retry_unchanged_when = cfg.retry_unchanged_when.clone();
91 let mut debouncer = new_debouncer(cfg.debounce, move |res: DebounceEventResult| match res {
92 Ok(events) => {
93 // Filter out ignored paths and dedupe (notify can emit
94 // duplicate events for the same path within a window).
95 let mut paths: Vec<PathBuf> = Vec::with_capacity(events.len());
96 let mut seen: HashSet<PathBuf> = HashSet::with_capacity(events.len());
97 for e in events {
98 if is_ignored(&e.path, &ignore) {
99 continue;
100 }
101 if seen.insert(e.path.clone()) {
102 paths.push(e.path);
103 }
104 }
105 // Drop paths whose content is byte-identical to the last event.
106 // Editors fire a write on every Ctrl+S even when nothing changed;
107 // rebuilding then is pure wasted work, so a no-op save triggers no
108 // rebuild at all in the clean state. When the caller reports that a
109 // rebuild error is active, unchanged events are allowed through so a
110 // no-op save can retry transient failures. Deletions and oversized
111 // files always count as changed (see `content_changed`).
112 let retry_unchanged = retry_unchanged_when
113 .as_ref()
114 .is_some_and(|predicate| predicate());
115 paths.retain(|path| should_forward_path(&mut content_hashes, path, retry_unchanged));
116 if !paths.is_empty() {
117 on_event(paths);
118 }
119 }
120 Err(e) => {
121 eprintln!("watcher error: {e:?}");
122 }
123 })
124 .context("Cannot start file watcher")?;
125
126 for p in &cfg.paths {
127 if p.exists() {
128 debouncer
129 .watcher()
130 .watch(p, RecursiveMode::Recursive)
131 .with_context(|| format!("Cannot watch {}", p.display()))?;
132 }
133 }
134
135 Ok(WatcherHandle {
136 _debouncer: debouncer,
137 })
138}
139
140/// Returns true when `event_path` lives under any ignored root.
141///
142/// Matching uses two strategies:
143/// 1. **Absolute roots** (e.g. canonicalized `dist/`): the event path
144/// must literally start with the root. Handles "ignore this exact
145/// output directory" cases.
146/// 2. **Single-component relative roots** (e.g. `node_modules`,
147/// `.git`, `target`): match if any component of the event path
148/// equals that name. This is what makes `default_ignore_paths()`
149/// work universally — `target` matches `/repo/target/...`,
150/// `/repo/sub/target/...`, etc., regardless of where the watcher
151/// cwd was when the path was registered.
152fn is_ignored(event_path: &Path, ignore: &[PathBuf]) -> bool {
153 if ignore.is_empty() {
154 return false;
155 }
156 let canon = std::fs::canonicalize(event_path).ok();
157 let candidate: &Path = canon.as_deref().unwrap_or(event_path);
158
159 for root in ignore {
160 if root.is_absolute() {
161 if candidate.starts_with(root) {
162 return true;
163 }
164 continue;
165 }
166 // Relative root: treat as a name to match against any path
167 // component. Skips the absolute-prefix mismatch trap that
168 // would otherwise let `target` / `node_modules` through.
169 let mut components = root.components();
170 let first = components.next();
171 let only_one_component = components.next().is_none();
172 if let (Some(first), true) = (first, only_one_component) {
173 let name = first.as_os_str();
174 if candidate.components().any(|c| c.as_os_str() == name) {
175 return true;
176 }
177 } else if candidate.starts_with(root) {
178 // Multi-component relative root — fall back to prefix.
179 return true;
180 }
181 }
182 false
183}
184
185/// Default ignore subtrees common to dev servers. Includes the universal
186/// junk: `node_modules`, `.git`, `target`. Callers should append their
187/// own build-output directory and any tool-specific cache directories
188/// before passing this to [`spawn_watcher`].
189#[must_use]
190pub fn default_ignore_paths() -> Vec<PathBuf> {
191 vec![
192 PathBuf::from("node_modules"),
193 PathBuf::from(".git"),
194 PathBuf::from("target"),
195 ]
196}
197
198/// Largest file the watcher will hash to detect a no-op change. Above this,
199/// an event is always treated as a change — hashing a huge file on every event
200/// would cost more than an occasional rebuild. Dev source files are tiny, so
201/// this only guards pathological inputs.
202const MAX_HASH_BYTES: u64 = 8 * 1024 * 1024;
203
204/// Whether `path`'s content changed since the previous event, updating `cache`.
205///
206/// A path that cannot be read as a regular file within the size cap (deleted,
207/// a directory, a permissions error, or oversized) is treated as **changed** so
208/// deletions still trigger a rebuild and large files are never silently skipped.
209fn content_changed(cache: &mut HashMap<PathBuf, u64>, path: &Path) -> bool {
210 match hash_file(path) {
211 Some(hash) => match cache.insert(path.to_path_buf(), hash) {
212 Some(previous) => previous != hash,
213 None => true,
214 },
215 None => {
216 cache.remove(path);
217 true
218 }
219 }
220}
221
222fn should_forward_path(
223 cache: &mut HashMap<PathBuf, u64>,
224 path: &Path,
225 retry_unchanged: bool,
226) -> bool {
227 content_changed(cache, path) || retry_unchanged
228}
229
230/// Hash the full contents of `path`, or `None` if it is not a readable regular
231/// file within [`MAX_HASH_BYTES`]. Uses the standard hasher — collision
232/// resistance is irrelevant here; we only need "did these bytes change".
233fn hash_file(path: &Path) -> Option<u64> {
234 let metadata = std::fs::metadata(path).ok()?;
235 if !metadata.is_file() || metadata.len() > MAX_HASH_BYTES {
236 return None;
237 }
238 let bytes = std::fs::read(path).ok()?;
239 let mut hasher = DefaultHasher::new();
240 hasher.write(&bytes);
241 Some(hasher.finish())
242}
243
244#[cfg(test)]
245#[allow(clippy::disallowed_methods)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn is_ignored_matches_descendant() {
251 let dir = tempfile::tempdir().unwrap();
252 let dist = dir.path().join("dist");
253 std::fs::create_dir(&dist).unwrap();
254 let nested = dist.join("a/b.html");
255 std::fs::create_dir_all(nested.parent().unwrap()).unwrap();
256 std::fs::write(&nested, "x").unwrap();
257
258 let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
259 assert!(is_ignored(&nested, &ignore));
260 }
261
262 #[test]
263 fn is_ignored_misses_sibling() {
264 let dir = tempfile::tempdir().unwrap();
265 let dist = dir.path().join("dist");
266 let other = dir.path().join("src/a.md");
267 std::fs::create_dir(&dist).unwrap();
268 std::fs::create_dir_all(other.parent().unwrap()).unwrap();
269 std::fs::write(&other, "x").unwrap();
270
271 let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
272 assert!(!is_ignored(&other, &ignore));
273 }
274
275 #[test]
276 fn is_ignored_is_false_when_no_ignores() {
277 let p = Path::new("/anything/at/all");
278 assert!(!is_ignored(p, &[]));
279 }
280
281 #[test]
282 fn is_ignored_matches_relative_component_anywhere() {
283 // `target` (single relative component) must match no matter
284 // how deep — this is how default_ignore_paths() actually
285 // works in the wild against absolute paths from notify.
286 let ignore = vec![PathBuf::from("target")];
287 assert!(is_ignored(Path::new("/repo/target/debug/build"), &ignore));
288 assert!(is_ignored(
289 Path::new("/repo/sub/crate/target/foo.rs"),
290 &ignore
291 ));
292 assert!(!is_ignored(Path::new("/repo/src/target_name.rs"), &ignore));
293 }
294
295 #[test]
296 fn is_ignored_handles_node_modules_and_git() {
297 let ignore = default_ignore_paths();
298 assert!(is_ignored(
299 Path::new("/repo/node_modules/foo/index.js"),
300 &ignore
301 ));
302 assert!(is_ignored(Path::new("/repo/.git/HEAD"), &ignore));
303 assert!(!is_ignored(Path::new("/repo/src/index.ts"), &ignore));
304 }
305
306 #[test]
307 fn content_changed_skips_identical_saves() {
308 let dir = tempfile::tempdir().unwrap();
309 let file = dir.path().join("a.css");
310 std::fs::write(&file, "a { color: red; }").unwrap();
311 let mut cache = HashMap::new();
312
313 // First sighting → changed (nothing cached yet).
314 assert!(content_changed(&mut cache, &file));
315 // Re-saving identical bytes (repeated Ctrl+S) → no change → no rebuild.
316 assert!(!content_changed(&mut cache, &file));
317 assert!(!content_changed(&mut cache, &file));
318
319 // A real edit → changed.
320 std::fs::write(&file, "a { color: blue; }").unwrap();
321 assert!(content_changed(&mut cache, &file));
322 // Identical again → unchanged.
323 assert!(!content_changed(&mut cache, &file));
324
325 // Deletion → changed, so a rebuild can clear stale output.
326 std::fs::remove_file(&file).unwrap();
327 assert!(content_changed(&mut cache, &file));
328 // The cache forgot it, so a later recreation is a fresh change.
329 std::fs::write(&file, "a { color: blue; }").unwrap();
330 assert!(content_changed(&mut cache, &file));
331 }
332
333 #[test]
334 fn unchanged_save_can_retry_when_error_active() {
335 let dir = tempfile::tempdir().unwrap();
336 let file = dir.path().join("a.css");
337 std::fs::write(&file, "a { color: red; }").unwrap();
338 let mut cache = HashMap::new();
339
340 assert!(should_forward_path(&mut cache, &file, false));
341 assert!(!should_forward_path(&mut cache, &file, false));
342 assert!(should_forward_path(&mut cache, &file, true));
343 }
344}