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};
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::{
35    default_ignore_paths, serve_static_file, spawn_rebuild_worker, spawn_watcher, sse_handler,
36    LiveReload, NotFoundStrategy, StaticServeConfig, WatchConfig, WatcherHandle,
37};
38
39use crate::build::{build_docs_with_cache, BuildCache};
40use crate::types::DocsConfig;
41
42/// Filesystem-event debounce window. Editors often save in multiple bursts;
43/// a single rebuild per burst feels right.
44const DEBOUNCE_DURATION: Duration = Duration::from_millis(50);
45
46/// SSE endpoint path. Absolute (root-relative) so it's not affected by
47/// `<base href>` set in built pages.
48const RELOAD_PATH: &str = "/__webui_press/livereload";
49
50/// Inputs needed to start the dev server.
51pub struct ServeConfig {
52    pub config: DocsConfig,
53    pub config_dir: PathBuf,
54    pub template_dir: PathBuf,
55    pub config_path: PathBuf,
56    pub host: String,
57    pub port: u16,
58}
59
60/// Run the dev server until interrupted.
61pub async fn run_serve(opts: ServeConfig) -> Result<()> {
62    let ServeConfig {
63        config,
64        config_dir,
65        template_dir,
66        config_path,
67        host,
68        port,
69    } = opts;
70    let base_path = normalize_base_path(&config.base_path);
71    // Match `build_docs` semantics: `out_dir`, `content_dir`, and
72    // `public_dir` are interpreted relative to the process working
73    // directory, not relative to config_dir.
74    let out_dir = PathBuf::from(&config.out_dir);
75    let content_dir_str = config.content_dir.clone();
76    let public_dir_str = config.public_dir.clone();
77
78    println!(
79        "{} {}",
80        style("⚡").cyan().bold(),
81        style("WebUI Press dev server").bold()
82    );
83    println!();
84
85    // Initial build (synchronous so the server starts with a populated
86    // dist). The cache is carried into the rebuild worker so subsequent
87    // ticks reuse the syntect highlighter (the only state worth
88    // amortizing across rebuilds — every other build step runs from
89    // scratch).
90    let initial_cache: BuildCache = {
91        let cfg = clone_config_via_reparse(&config_path)?;
92        let cd = config_dir.clone();
93        let td = template_dir.clone();
94        tokio::task::spawn_blocking(move || -> Result<BuildCache> {
95            let mut cache = BuildCache::new();
96            build_docs_with_cache(&cfg, &cd, &td, &mut cache)
97                .map_err(|e| anyhow!("initial build failed: {e}"))?;
98            // Subsequent rebuilds suppress the per-step banner output
99            // (the rolling rebuild line replaces it) and skip cleaning
100            // the output dir — overwriting in place is faster and
101            // avoids macOS-ENOTEMPTY races. Stale files from deleted
102            // source pages survive until the server is restarted.
103            cache.set_dev_rebuild();
104            Ok(cache)
105        })
106        .await
107        .map_err(|e| anyhow!("build task panicked: {e}"))??
108    };
109    // Borrow the original config only for setup (basePath, paths) — every
110    // rebuild re-reads from disk so live edits to config.json take effect.
111    let _ = config;
112
113    let livereload = LiveReload::new(RELOAD_PATH);
114
115    // Cache lives across rebuilds. Wrapped in Arc<Mutex<_>> so the
116    // worker thread can mutate it; the only contender is the worker
117    // itself, so the mutex is uncontended in practice.
118    let cache = Arc::new(Mutex::new(initial_cache));
119
120    let tick_tx = {
121        let config_path = config_path.clone();
122        let config_dir = config_dir.clone();
123        let template_dir = template_dir.clone();
124        let cache = cache.clone();
125        spawn_rebuild_worker(livereload.clone(), move || {
126            let cfg = clone_config_via_reparse(&config_path)
127                .map_err(|e| format!("config reload failed: {e}"))?;
128            let mut guard = cache
129                .lock()
130                .unwrap_or_else(|poisoned| poisoned.into_inner());
131            build_docs_with_cache(&cfg, &config_dir, &template_dir, &mut guard)
132                .map_err(|e| format!("{e}"))?;
133            Ok(())
134        })
135    };
136
137    let _watcher: WatcherHandle = {
138        let tx = tick_tx.clone();
139        let watched = watch_paths(
140            &config_dir,
141            &config_path,
142            Path::new(&content_dir_str),
143            Path::new(&public_dir_str),
144        );
145        let mut ignore = default_ignore_paths();
146        ignore.push(out_dir.clone());
147        ignore.push(PathBuf::from(".webui-press/cache"));
148        spawn_watcher(
149            WatchConfig {
150                paths: watched,
151                ignore,
152                debounce: DEBOUNCE_DURATION,
153            },
154            move |_paths: Vec<PathBuf>| {
155                // Coalesce on full: the worker drains all queued ticks
156                // before starting a build, so a dropped tick is
157                // equivalent to a coalesced one.
158                let _ = tx.try_send(());
159            },
160        )?
161    };
162
163    let static_cfg = StaticServeConfig {
164        root: out_dir.clone(),
165        base_path: base_path.clone(),
166        livereload: livereload.clone(),
167        not_found: NotFoundStrategy::File(PathBuf::from("404.html")),
168    };
169    let static_data = web::Data::new(static_cfg);
170    let lr_data = web::Data::new(livereload.clone());
171
172    let bind = format!("{host}:{port}");
173    let url = format!("http://{host}:{port}{base_path}");
174    println!(
175        "  {} {}",
176        style("➜").green().bold(),
177        style(&url).cyan().underlined()
178    );
179    println!();
180
181    HttpServer::new(move || {
182        App::new()
183            .app_data(static_data.clone())
184            .app_data(lr_data.clone())
185            .route(RELOAD_PATH, web::get().to(sse_handler))
186            .default_service(web::get().to(static_handler))
187    })
188    .bind(&bind)
189    .with_context(|| format!("Cannot bind {bind}"))?
190    .run()
191    .await
192    .context("Dev server failed")?;
193
194    // Hold the watcher until the server returns so it isn't dropped early.
195    drop(_watcher);
196    Ok(())
197}
198
199/// Default actix handler — delegates to the shared static-file
200/// responder. A trivial wrapper because actix needs a function pointer
201/// and the shared crate ships an `&HttpRequest`-taking helper.
202async fn static_handler(req: HttpRequest, cfg: web::Data<StaticServeConfig>) -> HttpResponse {
203    serve_static_file(&req, cfg.get_ref()).await
204}
205
206// ─────────────────────────────────────────────────────────────────────────────
207// Watch-path selection
208// ─────────────────────────────────────────────────────────────────────────────
209
210/// Compute the deduped set of filesystem paths the dev server should
211/// watch. The watcher tracks every path that contributes to the rendered
212/// output:
213///
214///  - `config_dir` — components, theme, and other assets co-located with
215///    `config.json`,
216///  - the parent of `config_path` — only added if it isn't already
217///    covered (it usually equals `config_dir`),
218///  - `content_dir` — markdown source files, typically a sibling of
219///    `config_dir` (e.g. `docs/` while config lives at
220///    `docs/.webui-press/config.json`),
221///  - `public_dir` — verbatim assets copied into the build, often
222///    nested inside `config_dir`.
223///
224/// We deliberately do NOT watch the framework's `template_dir`: that
225/// directory ships with the `webui-press` crate and only changes when
226/// the binary itself is rebuilt, which already restarts the dev server.
227///
228/// A path is dropped if it is already covered (via `starts_with`) by
229/// something we are already watching. When a new path is a parent of an
230/// already-watched one, it replaces the descendant — this keeps `notify`
231/// from receiving overlapping subscriptions.
232fn watch_paths(
233    config_dir: &Path,
234    config_path: &Path,
235    content_dir: &Path,
236    public_dir: &Path,
237) -> Vec<PathBuf> {
238    let mut paths: Vec<PathBuf> = Vec::with_capacity(4);
239    let mut push_dedup = |candidate: PathBuf| {
240        if candidate.as_os_str().is_empty() {
241            return;
242        }
243        // Canonicalize so that subset/superset relations compare on
244        // identical, symlink-resolved roots — e.g. `./` vs an absolute
245        // `/Users/.../docs` would otherwise look disjoint and watch
246        // both. Falls back to the raw candidate when the path doesn't
247        // exist yet (e.g. publicDir a user hasn't created).
248        let candidate = candidate.canonicalize().unwrap_or(candidate);
249        if paths.iter().any(|p| candidate.starts_with(p)) {
250            return;
251        }
252        paths.retain(|p| !p.starts_with(&candidate));
253        paths.push(candidate);
254    };
255    push_dedup(config_dir.to_path_buf());
256    if let Some(parent) = config_path.parent() {
257        push_dedup(parent.to_path_buf());
258    }
259    push_dedup(content_dir.to_path_buf());
260    push_dedup(public_dir.to_path_buf());
261    paths
262}
263
264/// Re-read and parse `config.json`. Used both to seed the initial build
265/// and inside the rebuild worker so live edits to the config take effect.
266fn clone_config_via_reparse(config_path: &Path) -> Result<DocsConfig> {
267    let s = std::fs::read_to_string(config_path)
268        .with_context(|| format!("Cannot read {}", config_path.display()))?;
269    serde_json::from_str(&s).with_context(|| format!("Invalid JSON in {}", config_path.display()))
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn watch_paths_dedupes_nested_paths() {
278        let cfg = Path::new("/proj/docs/.webui-press");
279        let config_file = Path::new("/proj/docs/.webui-press/config.json");
280        let content = Path::new("/proj/docs/.webui-press");
281        let public = Path::new("/proj/docs/.webui-press/public");
282        let paths = watch_paths(cfg, config_file, content, public);
283        // config_path parent, content_dir, public_dir are all inside
284        // config_dir → only config_dir watched.
285        assert_eq!(paths, vec![PathBuf::from("/proj/docs/.webui-press")]);
286    }
287
288    #[test]
289    fn watch_paths_includes_sibling_content_dir() {
290        // Real-world layout: config lives in docs/.webui-press/config.json,
291        // markdown lives in docs/ (a parent / sibling of .webui-press).
292        let cfg = Path::new("docs/.webui-press");
293        let config_file = Path::new("docs/.webui-press/config.json");
294        let content = Path::new("docs");
295        let public = Path::new("docs/.webui-press/public");
296        let paths = watch_paths(cfg, config_file, content, public);
297        // content_dir (docs) is a parent of everything else → it
298        // promotes to the only watched root.
299        assert_eq!(paths, vec![PathBuf::from("docs")]);
300    }
301
302    #[test]
303    fn watch_paths_includes_external_content_dir() {
304        // content_dir lives outside config_dir.
305        let cfg = Path::new("/proj/.webui-press");
306        let config_file = Path::new("/proj/.webui-press/config.json");
307        let content = Path::new("/proj/markdown");
308        let public = Path::new("/proj/.webui-press/public");
309        let paths = watch_paths(cfg, config_file, content, public);
310        assert_eq!(
311            paths,
312            vec![
313                PathBuf::from("/proj/.webui-press"),
314                PathBuf::from("/proj/markdown"),
315            ]
316        );
317    }
318}