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::HashSet;
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15use anyhow::{Context, Result};
16use notify::RecommendedWatcher;
17use notify::RecursiveMode;
18use notify_debouncer_mini::{new_debouncer, DebounceEventResult, Debouncer};
19
20/// Owns the watcher background thread. Drop to stop watching.
21///
22/// `notify-debouncer-mini` spawns its background thread inside the
23/// debouncer struct; dropping the struct kills the thread. Consumers
24/// should hold this handle for the lifetime of their server.
25pub struct WatcherHandle {
26    _debouncer: Debouncer<RecommendedWatcher>,
27}
28
29/// Configuration for [`spawn_watcher`].
30pub struct WatchConfig {
31    /// Roots to watch recursively. Non-existent entries are silently
32    /// skipped.
33    pub paths: Vec<PathBuf>,
34    /// Subtrees to ignore. An event is suppressed when its path lives
35    /// underneath any entry here. Typical values: the build's `out_dir`,
36    /// `node_modules`, `.git`, `target`.
37    ///
38    /// Each entry is canonicalized at registration time so symlink and
39    /// path-form differences (`./dist` vs `dist`) compare correctly.
40    pub ignore: Vec<PathBuf>,
41    /// Debounce window — events arriving within this window are
42    /// coalesced into a single callback invocation.
43    pub debounce: Duration,
44}
45
46/// Start a debounced recursive watcher.
47///
48/// The closure `on_event` is invoked once per debounce window with the
49/// owned, deduplicated list of paths that changed outside any
50/// `cfg.ignore` root. If every event in a window targets an ignored
51/// subtree, the callback is not invoked.
52///
53/// Non-existent paths in `cfg.paths` are silently skipped; this matches
54/// the dev-server use case where some watched directories (e.g. an
55/// optional `public/`) may not exist yet.
56///
57/// # Errors
58///
59/// Returns an error if the watcher cannot be created or if a path that
60/// exists cannot be watched (typically a permissions issue).
61pub fn spawn_watcher<F>(cfg: WatchConfig, on_event: F) -> Result<WatcherHandle>
62where
63    F: Fn(Vec<PathBuf>) + Send + 'static,
64{
65    // Canonicalize ignore paths once, up front. Non-existent ignore
66    // entries are kept as-is so they still match if the path appears
67    // mid-session (e.g. a fresh `dist/` created by the first build).
68    let ignore: Vec<PathBuf> = cfg
69        .ignore
70        .iter()
71        .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
72        .collect();
73
74    let mut debouncer = new_debouncer(cfg.debounce, move |res: DebounceEventResult| match res {
75        Ok(events) => {
76            // Filter out ignored paths and dedupe (notify can emit
77            // duplicate events for the same path within a window).
78            let mut paths: Vec<PathBuf> = Vec::with_capacity(events.len());
79            let mut seen: HashSet<PathBuf> = HashSet::with_capacity(events.len());
80            for e in events {
81                if is_ignored(&e.path, &ignore) {
82                    continue;
83                }
84                if seen.insert(e.path.clone()) {
85                    paths.push(e.path);
86                }
87            }
88            if !paths.is_empty() {
89                on_event(paths);
90            }
91        }
92        Err(e) => {
93            eprintln!("watcher error: {e:?}");
94        }
95    })
96    .context("Cannot start file watcher")?;
97
98    for p in &cfg.paths {
99        if p.exists() {
100            debouncer
101                .watcher()
102                .watch(p, RecursiveMode::Recursive)
103                .with_context(|| format!("Cannot watch {}", p.display()))?;
104        }
105    }
106
107    Ok(WatcherHandle {
108        _debouncer: debouncer,
109    })
110}
111
112/// Returns true when `event_path` lives under any ignored root.
113///
114/// Matching uses two strategies:
115/// 1. **Absolute roots** (e.g. canonicalized `dist/`): the event path
116///    must literally start with the root. Handles "ignore this exact
117///    output directory" cases.
118/// 2. **Single-component relative roots** (e.g. `node_modules`,
119///    `.git`, `target`): match if any component of the event path
120///    equals that name. This is what makes `default_ignore_paths()`
121///    work universally — `target` matches `/repo/target/...`,
122///    `/repo/sub/target/...`, etc., regardless of where the watcher
123///    cwd was when the path was registered.
124fn is_ignored(event_path: &Path, ignore: &[PathBuf]) -> bool {
125    if ignore.is_empty() {
126        return false;
127    }
128    let canon = std::fs::canonicalize(event_path).ok();
129    let candidate: &Path = canon.as_deref().unwrap_or(event_path);
130
131    for root in ignore {
132        if root.is_absolute() {
133            if candidate.starts_with(root) {
134                return true;
135            }
136            continue;
137        }
138        // Relative root: treat as a name to match against any path
139        // component. Skips the absolute-prefix mismatch trap that
140        // would otherwise let `target` / `node_modules` through.
141        let mut components = root.components();
142        let first = components.next();
143        let only_one_component = components.next().is_none();
144        if let (Some(first), true) = (first, only_one_component) {
145            let name = first.as_os_str();
146            if candidate.components().any(|c| c.as_os_str() == name) {
147                return true;
148            }
149        } else if candidate.starts_with(root) {
150            // Multi-component relative root — fall back to prefix.
151            return true;
152        }
153    }
154    false
155}
156
157/// Default ignore subtrees common to dev servers. Includes the universal
158/// junk: `node_modules`, `.git`, `target`. Callers should append their
159/// own build-output directory and any tool-specific cache directories
160/// before passing this to [`spawn_watcher`].
161#[must_use]
162pub fn default_ignore_paths() -> Vec<PathBuf> {
163    vec![
164        PathBuf::from("node_modules"),
165        PathBuf::from(".git"),
166        PathBuf::from("target"),
167    ]
168}
169
170#[cfg(test)]
171#[allow(clippy::disallowed_methods)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn is_ignored_matches_descendant() {
177        let dir = tempfile::tempdir().unwrap();
178        let dist = dir.path().join("dist");
179        std::fs::create_dir(&dist).unwrap();
180        let nested = dist.join("a/b.html");
181        std::fs::create_dir_all(nested.parent().unwrap()).unwrap();
182        std::fs::write(&nested, "x").unwrap();
183
184        let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
185        assert!(is_ignored(&nested, &ignore));
186    }
187
188    #[test]
189    fn is_ignored_misses_sibling() {
190        let dir = tempfile::tempdir().unwrap();
191        let dist = dir.path().join("dist");
192        let other = dir.path().join("src/a.md");
193        std::fs::create_dir(&dist).unwrap();
194        std::fs::create_dir_all(other.parent().unwrap()).unwrap();
195        std::fs::write(&other, "x").unwrap();
196
197        let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
198        assert!(!is_ignored(&other, &ignore));
199    }
200
201    #[test]
202    fn is_ignored_is_false_when_no_ignores() {
203        let p = Path::new("/anything/at/all");
204        assert!(!is_ignored(p, &[]));
205    }
206
207    #[test]
208    fn is_ignored_matches_relative_component_anywhere() {
209        // `target` (single relative component) must match no matter
210        // how deep — this is how default_ignore_paths() actually
211        // works in the wild against absolute paths from notify.
212        let ignore = vec![PathBuf::from("target")];
213        assert!(is_ignored(Path::new("/repo/target/debug/build"), &ignore));
214        assert!(is_ignored(
215            Path::new("/repo/sub/crate/target/foo.rs"),
216            &ignore
217        ));
218        assert!(!is_ignored(Path::new("/repo/src/target_name.rs"), &ignore));
219    }
220
221    #[test]
222    fn is_ignored_handles_node_modules_and_git() {
223        let ignore = default_ignore_paths();
224        assert!(is_ignored(
225            Path::new("/repo/node_modules/foo/index.js"),
226            &ignore
227        ));
228        assert!(is_ignored(Path::new("/repo/.git/HEAD"), &ignore));
229        assert!(!is_ignored(Path::new("/repo/src/index.ts"), &ignore));
230    }
231}