Skip to main content

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}