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