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}