Skip to main content

webui_docs/
serve.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Development server for `webui-press`.
5//!
6//! Composes [`webui_dev_server`] primitives:
7//!
8//!  - [`LiveReload`] for SSE-based browser auto-reload.
9//!  - [`spawn_watcher`](webui_dev_server::spawn_watcher) for debounced
10//!    filesystem notifications.
11//!  - [`spawn_rebuild_worker`](webui_dev_server::spawn_rebuild_worker)
12//!    for the rebuild loop (tick coalescing, success/error reporting,
13//!    livereload broadcast).
14//!  - [`serve_static_file`](webui_dev_server::serve_static_file) for
15//!    `basePath`-aware static file routing with HTML livereload
16//!    injection.
17//!
18//! Press-specific responsibilities that stay in this file:
19//!  - re-reading and parsing `config.json` on every rebuild so live edits
20//!    take effect,
21//!  - calling [`build_docs`](crate::build::build_docs) and reusing the
22//!    syntect highlighter across rebuilds via [`BuildCache`],
23//!  - serving the flat `out_dir` (the dev-server crate handles the
24//!    actual HTTP machinery — this module just wires it up).
25
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex, OnceLock};
28use std::time::Duration;
29
30use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer};
31use anyhow::{anyhow, Context, Result};
32use console::style;
33use webui_dev_server::path::normalize_base_path;
34use webui_dev_server::shutdown::Control;
35use webui_dev_server::{
36    default_ignore_paths, serve_static_file, spawn_rebuild_worker, spawn_watcher, sse_handler,
37    LiveReload, NotFoundStrategy, StaticServeConfig, TickSender, WatchConfig, WatcherHandle,
38};
39
40use crate::build::{build_docs_with_cache, BuildCache};
41use crate::state::StateDependencies;
42use crate::types::{DocsConfig, ShowMode};
43
44/// Filesystem-event debounce window. Editors often save in multiple bursts;
45/// a single rebuild per burst feels right.
46const DEBOUNCE_DURATION: Duration = Duration::from_millis(50);
47
48/// SSE endpoint path. Absolute (root-relative) so it's not affected by
49/// `<base href>` set in built pages.
50const RELOAD_PATH: &str = "/__webui_press/livereload";
51
52/// Inputs needed to start the dev server.
53pub struct ServeConfig {
54    pub config: DocsConfig,
55    pub config_dir: PathBuf,
56    pub template_dir: PathBuf,
57    pub config_path: PathBuf,
58    pub host: String,
59    pub port: u16,
60    /// Explicit CLI display-mode override, reapplied after each config reload.
61    pub show_override: Option<ShowMode>,
62}
63
64/// Run the dev server until interrupted.
65pub async fn run_serve(opts: ServeConfig, control: Option<Control>) -> Result<()> {
66    let ServeConfig {
67        config,
68        config_dir,
69        template_dir,
70        config_path,
71        host,
72        port,
73        show_override,
74    } = opts;
75    let base_path = normalize_base_path(&config.base_path);
76    // Match `build_docs` semantics: `out_dir`, `content_dir`, and
77    // `public_dir` are interpreted relative to the process working
78    // directory, not relative to config_dir.
79    let out_dir = PathBuf::from(&config.out_dir);
80
81    println!(
82        "{} {}",
83        style("⚡").cyan().bold(),
84        style("WebUI Press dev server").bold()
85    );
86    println!();
87
88    // Initial build (synchronous so the server starts with a populated
89    // dist). The cache is carried into the rebuild worker so subsequent
90    // ticks reuse the syntect highlighter (the only state worth
91    // amortizing across rebuilds — every other build step runs from
92    // scratch).
93    let initial_cache: BuildCache = {
94        let cfg = clone_config_via_reparse(&config_path, show_override)?;
95        let cd = config_dir.clone();
96        let td = template_dir.clone();
97        tokio::task::spawn_blocking(move || -> Result<BuildCache> {
98            let mut cache = BuildCache::new();
99            build_docs_with_cache(&cfg, &cd, &td, &mut cache)
100                .map_err(|e| anyhow!("initial build failed: {e}"))?;
101            // Subsequent rebuilds suppress the per-step banner output
102            // (the rolling rebuild line replaces it) and skip cleaning
103            // the output dir — overwriting in place is faster and
104            // avoids macOS-ENOTEMPTY races. Stale files from deleted
105            // source pages survive until the server is restarted.
106            cache.set_dev_rebuild();
107            Ok(cache)
108        })
109        .await
110        .map_err(|e| anyhow!("build task panicked: {e}"))??
111    };
112    // Borrow the original config only for setup (basePath, paths) — every
113    // rebuild re-reads from disk so live edits to config.json take effect.
114    let livereload = LiveReload::new(RELOAD_PATH);
115
116    // Cache lives across rebuilds. Wrapped in Arc<Mutex<_>> so the
117    // worker thread can mutate it; the only contender is the worker
118    // itself, so the mutex is uncontended in practice.
119    let cache = Arc::new(Mutex::new(initial_cache));
120    let watcher = Arc::new(Mutex::new(None::<WatcherHandle>));
121    let sender = Arc::new(OnceLock::<TickSender>::new());
122
123    let worker = {
124        let config_path = config_path.clone();
125        let config_dir = config_dir.clone();
126        let template_dir = template_dir.clone();
127        let cache = cache.clone();
128        let watcher = watcher.clone();
129        let sender = sender.clone();
130        spawn_rebuild_worker(livereload.clone(), move || {
131            let cfg = clone_config_via_reparse(&config_path, show_override)
132                .map_err(|e| format!("config reload failed: {e}"))?;
133            let mut guard = cache
134                .lock()
135                .unwrap_or_else(|poisoned| poisoned.into_inner());
136            let result = build_docs_with_cache(&cfg, &config_dir, &template_dir, &mut guard);
137            let tx = sender
138                .get()
139                .ok_or_else(|| "Press watcher sender is unavailable".to_string())?;
140            let next = source_watcher(
141                &cfg,
142                &config_dir,
143                &config_path,
144                &guard.dependencies,
145                tx.clone(),
146            )
147            .map_err(|error| format!("Cannot refresh Press input watches: {error}"))?;
148            *watcher
149                .lock()
150                .map_err(|error| format!("Press watcher lock failed: {error}"))? = Some(next);
151            result.map_err(|e| format!("{e}"))?;
152            // webui-press has no per-rebuild advisories to surface.
153            Ok(Vec::new())
154        })
155    };
156
157    let tx = worker.sender();
158    sender
159        .set(tx.clone())
160        .map_err(|_| anyhow!("Press watcher sender was already initialized"))?;
161    {
162        let guard = cache
163            .lock()
164            .map_err(|error| anyhow!("Press build cache lock failed: {error}"))?;
165        let initial_watcher =
166            source_watcher(&config, &config_dir, &config_path, &guard.dependencies, tx)?;
167        *watcher
168            .lock()
169            .map_err(|error| anyhow!("Press watcher lock failed: {error}"))? =
170            Some(initial_watcher);
171    }
172
173    let static_cfg = StaticServeConfig {
174        root: out_dir.clone(),
175        base_path: base_path.clone(),
176        livereload: livereload.clone(),
177        not_found: NotFoundStrategy::File(PathBuf::from("404.html")),
178    };
179    let static_data = web::Data::new(static_cfg);
180    let lr_data = web::Data::new(livereload.clone());
181
182    let bind = format!("{host}:{port}");
183    let url = format!("http://{host}:{port}{base_path}");
184    println!(
185        "  {} {}",
186        style("➜").green().bold(),
187        style(&url).cyan().underlined()
188    );
189    println!();
190
191    let mut server = HttpServer::new(move || {
192        App::new()
193            .app_data(static_data.clone())
194            .app_data(lr_data.clone())
195            .route(RELOAD_PATH, web::get().to(sse_handler))
196            .default_service(web::get().to(static_handler))
197    });
198    if control.is_some() {
199        server = server.disable_signals();
200    }
201    let server = server
202        .bind(&bind)
203        .with_context(|| format!("Cannot bind {bind}"))?
204        .run();
205    let server_result: Result<()> = match control {
206        Some(control) => control.serve(server).await.map_err(Into::into),
207        None => server.await.map_err(Into::into),
208    };
209    let server_result = server_result.context("Dev server failed");
210
211    worker.shutdown()?;
212    *watcher
213        .lock()
214        .map_err(|error| anyhow!("Press watcher lock failed: {error}"))? = None;
215    server_result?;
216    Ok(())
217}
218
219fn source_watcher(
220    config: &DocsConfig,
221    config_dir: &Path,
222    config_path: &Path,
223    dependencies: &StateDependencies,
224    tx: TickSender,
225) -> Result<WatcherHandle> {
226    let mut explicit_files = projection_manifest_paths(
227        config_dir,
228        config
229            .bundler
230            .as_ref()
231            .map_or(&[][..], |bundler| bundler.projection_manifests.as_slice()),
232    );
233    explicit_files.extend_from_slice(&dependencies.paths);
234    let mut ignore = default_ignore_paths();
235    ignore.push(PathBuf::from(&config.out_dir));
236    ignore.push(PathBuf::from(".webui-press/cache"));
237    let events = tx.clone();
238    let watcher = spawn_watcher(
239        WatchConfig {
240            paths: watch_paths(
241                config_dir,
242                config_path,
243                Path::new(&config.content_dir),
244                Path::new(&config.public_dir),
245            ),
246            explicit_files,
247            ignore,
248            debounce: DEBOUNCE_DURATION,
249            retry_unchanged_when: None,
250        },
251        move |paths| {
252            let _ = events.try_send(paths);
253        },
254    )?;
255    // The old watcher may exclude a newly discovered include. Compare with the
256    // bytes used for rendering only after the replacement watch is active.
257    let changed = dependencies.changed_paths();
258    if !changed.is_empty() {
259        match tx.try_send(changed) {
260            Ok(()) | Err(std::sync::mpsc::TrySendError::Full(_)) => {}
261            Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
262                return Err(anyhow!("Press rebuild worker disconnected"));
263            }
264        }
265    }
266    Ok(watcher)
267}
268
269/// Default actix handler — delegates to the shared static-file
270/// responder. A trivial wrapper because actix needs a function pointer
271/// and the shared crate ships an `&HttpRequest`-taking helper.
272async fn static_handler(req: HttpRequest, cfg: web::Data<StaticServeConfig>) -> HttpResponse {
273    serve_static_file(&req, cfg.get_ref()).await
274}
275
276// ─────────────────────────────────────────────────────────────────────────────
277// Watch-path selection
278// ─────────────────────────────────────────────────────────────────────────────
279
280/// Compute the deduped set of filesystem paths the dev server should
281/// watch. The watcher tracks every path that contributes to the rendered
282/// output:
283///
284///  - `config_dir` — components, theme, and other assets co-located with
285///    `config.json`,
286///  - the parent of `config_path` — only added if it isn't already
287///    covered (it usually equals `config_dir`),
288///  - `content_dir` — markdown source files, typically a sibling of
289///    `config_dir` (e.g. `docs/` while config lives at
290///    `docs/.webui-press/config.json`),
291///  - `public_dir` — verbatim assets copied into the build, often
292///    nested inside `config_dir`.
293///
294/// We deliberately do NOT watch the framework's `template_dir`: that
295/// directory ships with the `webui-press` crate and only changes when
296/// the binary itself is rebuilt, which already restarts the dev server.
297///
298/// A path is dropped if it is already covered (via `starts_with`) by
299/// something we are already watching. When a new path is a parent of an
300/// already-watched one, it replaces the descendant — this keeps `notify`
301/// from receiving overlapping subscriptions.
302fn watch_paths(
303    config_dir: &Path,
304    config_path: &Path,
305    content_dir: &Path,
306    public_dir: &Path,
307) -> Vec<PathBuf> {
308    let mut paths: Vec<PathBuf> = Vec::with_capacity(4);
309    let mut push_dedup = |candidate: PathBuf| {
310        if candidate.as_os_str().is_empty() {
311            return;
312        }
313        // Canonicalize so that subset/superset relations compare on
314        // identical, symlink-resolved roots — e.g. `./` vs an absolute
315        // `/Users/.../docs` would otherwise look disjoint and watch
316        // both. Falls back to the raw candidate when the path doesn't
317        // exist yet (e.g. publicDir a user hasn't created).
318        let candidate = candidate.canonicalize().unwrap_or(candidate);
319        if paths.iter().any(|p| candidate.starts_with(p)) {
320            return;
321        }
322        paths.retain(|p| !p.starts_with(&candidate));
323        paths.push(candidate);
324    };
325    push_dedup(config_dir.to_path_buf());
326    if let Some(parent) = config_path.parent() {
327        push_dedup(parent.to_path_buf());
328    }
329    push_dedup(content_dir.to_path_buf());
330    push_dedup(public_dir.to_path_buf());
331    paths
332}
333
334fn projection_manifest_paths(config_dir: &Path, manifests: &[String]) -> Vec<PathBuf> {
335    manifests
336        .iter()
337        .map(|manifest| {
338            let path = config_dir.join(manifest);
339            path.canonicalize().unwrap_or(path)
340        })
341        .collect()
342}
343
344/// Re-read and parse `config.json`. Used both to seed the initial build
345/// and inside the rebuild worker so live edits to the config take effect.
346fn clone_config_via_reparse(
347    config_path: &Path,
348    show_override: Option<ShowMode>,
349) -> Result<DocsConfig> {
350    let s = std::fs::read_to_string(config_path)
351        .with_context(|| format!("Cannot read {}", config_path.display()))?;
352    let mut config: DocsConfig = serde_json::from_str(&s)
353        .with_context(|| format!("Invalid JSON in {}", config_path.display()))?;
354    if let Some(show) = show_override {
355        config.show = show;
356    }
357    Ok(config)
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn watcher_requeues_state_changed_after_the_render_read() -> Result<()> {
366        let root = tempfile::tempdir()?;
367        let file = root.path().join("target/state.json");
368        std::fs::create_dir(file.parent().context("state parent")?)?;
369        std::fs::write(&file, r#"{"count":1}"#)?;
370        let mut loader = crate::state::StateLoader::new();
371        loader.load_markdown_state(
372            Some("target/state.json"),
373            None,
374            &root.path().join("page.md"),
375        )?;
376        let dependencies = loader.into_dependencies();
377        std::fs::write(&file, r#"{"count":2}"#)?;
378        let config: DocsConfig = serde_json::from_str(
379            r#"{"site":{"title":"Test"},"basePath":"/","contentDir":"missing","nav":[],"sidebar":[]}"#,
380        )?;
381        let (tx, rx) = std::sync::mpsc::sync_channel(8);
382        let _watcher = source_watcher(
383            &config,
384            root.path(),
385            &root.path().join("config.json"),
386            &dependencies,
387            tx,
388        )?;
389        assert_eq!(
390            rx.recv_timeout(Duration::from_millis(200))?,
391            dependencies.paths
392        );
393        Ok(())
394    }
395
396    #[test]
397    fn watch_paths_dedupes_nested_paths() {
398        let cfg = Path::new("/proj/docs/.webui-press");
399        let config_file = Path::new("/proj/docs/.webui-press/config.json");
400        let content = Path::new("/proj/docs/.webui-press");
401        let public = Path::new("/proj/docs/.webui-press/public");
402        let paths = watch_paths(cfg, config_file, content, public);
403        // config_path parent, content_dir, public_dir are all inside
404        // config_dir → only config_dir watched.
405        assert_eq!(paths, vec![PathBuf::from("/proj/docs/.webui-press")]);
406    }
407
408    #[test]
409    fn watch_paths_includes_sibling_content_dir() {
410        // Real-world layout: config lives in docs/.webui-press/config.json,
411        // markdown lives in docs/ (a parent / sibling of .webui-press).
412        let cfg = Path::new("docs/.webui-press");
413        let config_file = Path::new("docs/.webui-press/config.json");
414        let content = Path::new("docs");
415        let public = Path::new("docs/.webui-press/public");
416        let paths = watch_paths(cfg, config_file, content, public);
417        // content_dir (docs) is a parent of everything else → it
418        // promotes to the only watched root.
419        assert_eq!(paths, vec![PathBuf::from("docs")]);
420    }
421
422    #[test]
423    fn watch_paths_includes_external_content_dir() {
424        // content_dir lives outside config_dir.
425        let cfg = Path::new("/proj/.webui-press");
426        let config_file = Path::new("/proj/.webui-press/config.json");
427        let content = Path::new("/proj/markdown");
428        let public = Path::new("/proj/.webui-press/public");
429        let paths = watch_paths(cfg, config_file, content, public);
430        assert_eq!(
431            paths,
432            vec![
433                PathBuf::from("/proj/.webui-press"),
434                PathBuf::from("/proj/markdown"),
435            ]
436        );
437    }
438
439    #[test]
440    fn projection_manifest_paths_resolve_from_config_directory() {
441        let paths = projection_manifest_paths(
442            Path::new("/proj/docs/.webui-press"),
443            &["../../client/dist/webui-projection.json".to_string()],
444        );
445
446        assert_eq!(
447            paths,
448            [PathBuf::from(
449                "/proj/docs/.webui-press/../../client/dist/webui-projection.json"
450            )]
451        );
452    }
453}