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, WatchTargets,
38    WatcherHandle,
39};
40
41use crate::build::{build_docs_with_cache, BuildCache};
42use crate::state::StateDependencies;
43use crate::types::{DocsConfig, ShowMode};
44
45/// Filesystem-event debounce window. Editors often save in multiple bursts;
46/// a single rebuild per burst feels right.
47const DEBOUNCE_DURATION: Duration = Duration::from_millis(50);
48
49/// SSE endpoint path. Absolute (root-relative) so it's not affected by
50/// `<base href>` set in built pages.
51const RELOAD_PATH: &str = "/__webui_press/livereload";
52
53/// Inputs needed to start the dev server.
54pub struct ServeConfig {
55    pub config: DocsConfig,
56    pub config_dir: PathBuf,
57    pub template_dir: PathBuf,
58    pub config_path: PathBuf,
59    pub host: String,
60    pub port: u16,
61    /// Explicit CLI display-mode override, reapplied after each config reload.
62    pub show_override: Option<ShowMode>,
63}
64
65/// Run the dev server until interrupted.
66pub async fn run_serve(opts: ServeConfig, control: Option<Control>) -> Result<()> {
67    let ServeConfig {
68        config,
69        config_dir,
70        template_dir,
71        config_path,
72        host,
73        port,
74        show_override,
75    } = opts;
76    let base_path = normalize_base_path(&config.base_path);
77    // Match `build_docs` semantics: `out_dir`, `content_dir`, and
78    // `public_dir` are interpreted relative to the process working
79    // directory, not relative to config_dir.
80    let out_dir = PathBuf::from(&config.out_dir);
81
82    println!(
83        "{} {}",
84        style("⚡").cyan().bold(),
85        style("WebUI Press dev server").bold()
86    );
87    println!();
88
89    // Initial build (synchronous so the server starts with a populated
90    // dist). The cache is carried into the rebuild worker so subsequent
91    // ticks reuse the syntect highlighter (the only state worth
92    // amortizing across rebuilds — every other build step runs from
93    // scratch).
94    let initial_cache: BuildCache = {
95        let cfg = clone_config_via_reparse(&config_path, show_override)?;
96        let cd = config_dir.clone();
97        let td = template_dir.clone();
98        tokio::task::spawn_blocking(move || -> Result<BuildCache> {
99            let mut cache = BuildCache::new();
100            build_docs_with_cache(&cfg, &cd, &td, &mut cache)
101                .map_err(|e| anyhow!("initial build failed: {e}"))?;
102            // Subsequent rebuilds suppress the per-step banner output
103            // (the rolling rebuild line replaces it) and skip cleaning
104            // the output dir — overwriting in place is faster and
105            // avoids macOS-ENOTEMPTY races. Stale files from deleted
106            // source pages survive until the server is restarted.
107            cache.set_dev_rebuild();
108            Ok(cache)
109        })
110        .await
111        .map_err(|e| anyhow!("build task panicked: {e}"))??
112    };
113    // Borrow the original config only for setup (basePath, paths) — every
114    // rebuild re-reads from disk so live edits to config.json take effect.
115    let livereload = LiveReload::new(RELOAD_PATH);
116
117    // Cache lives across rebuilds. Wrapped in Arc<Mutex<_>> so the
118    // worker thread can mutate it; the only contender is the worker
119    // itself, so the mutex is uncontended in practice.
120    let cache = Arc::new(Mutex::new(initial_cache));
121    let watcher = Arc::new(Mutex::new(None::<WatcherHandle>));
122    let sender = Arc::new(OnceLock::<TickSender>::new());
123
124    let worker = {
125        let config_path = config_path.clone();
126        let config_dir = config_dir.clone();
127        let template_dir = template_dir.clone();
128        let cache = cache.clone();
129        let watcher = watcher.clone();
130        let sender = sender.clone();
131        spawn_rebuild_worker(livereload.clone(), move || {
132            let cfg = clone_config_via_reparse(&config_path, show_override)
133                .map_err(|e| format!("config reload failed: {e}"))?;
134            let mut guard = cache
135                .lock()
136                .unwrap_or_else(|poisoned| poisoned.into_inner());
137            let result = build_docs_with_cache(&cfg, &config_dir, &template_dir, &mut guard);
138            let tx = sender
139                .get()
140                .ok_or_else(|| "Press watcher sender is unavailable".to_string())?;
141            {
142                let mut slot = watcher
143                    .lock()
144                    .map_err(|error| format!("Press watcher lock failed: {error}"))?;
145                let handle = slot
146                    .as_mut()
147                    .ok_or_else(|| "Press watcher is unavailable".to_string())?;
148                refresh_source_watcher(
149                    handle,
150                    source_targets(&cfg, &config_dir, &config_path, &guard.dependencies),
151                    &guard.dependencies,
152                    tx,
153                )
154                .map_err(|error| format!("Cannot refresh Press input watches: {error}"))?;
155            }
156            result.map_err(|e| format!("{e}"))?;
157            // webui-press has no per-rebuild advisories to surface.
158            Ok(Vec::new())
159        })
160    };
161
162    let tx = worker.sender();
163    sender
164        .set(tx.clone())
165        .map_err(|_| anyhow!("Press watcher sender was already initialized"))?;
166    {
167        let guard = cache
168            .lock()
169            .map_err(|error| anyhow!("Press build cache lock failed: {error}"))?;
170        let initial_watcher =
171            source_watcher(&config, &config_dir, &config_path, &guard.dependencies, tx)?;
172        *watcher
173            .lock()
174            .map_err(|error| anyhow!("Press watcher lock failed: {error}"))? =
175            Some(initial_watcher);
176    }
177
178    let static_cfg = StaticServeConfig {
179        root: out_dir.clone(),
180        base_path: base_path.clone(),
181        livereload: livereload.clone(),
182        not_found: NotFoundStrategy::File(PathBuf::from("404.html")),
183    };
184    let static_data = web::Data::new(static_cfg);
185    let lr_data = web::Data::new(livereload.clone());
186
187    let bind = format!("{host}:{port}");
188    let url = format!("http://{host}:{port}{base_path}");
189    println!(
190        "  {} {}",
191        style("➜").green().bold(),
192        style(&url).cyan().underlined()
193    );
194    println!();
195
196    let mut server = HttpServer::new(move || {
197        App::new()
198            .app_data(static_data.clone())
199            .app_data(lr_data.clone())
200            .route(RELOAD_PATH, web::get().to(sse_handler))
201            .default_service(web::get().to(static_handler))
202    });
203    if control.is_some() {
204        server = server.disable_signals();
205    }
206    let server = server
207        .bind(&bind)
208        .with_context(|| format!("Cannot bind {bind}"))?
209        .run();
210    let server_result: Result<()> = match control {
211        Some(control) => control.serve(server).await.map_err(Into::into),
212        None => server.await.map_err(Into::into),
213    };
214    let server_result = server_result.context("Dev server failed");
215
216    worker.shutdown()?;
217    *watcher
218        .lock()
219        .map_err(|error| anyhow!("Press watcher lock failed: {error}"))? = None;
220    server_result?;
221    Ok(())
222}
223
224/// Start the input watcher for the build that produced `dependencies`.
225fn source_watcher(
226    config: &DocsConfig,
227    config_dir: &Path,
228    config_path: &Path,
229    dependencies: &StateDependencies,
230    tx: TickSender,
231) -> Result<WatcherHandle> {
232    let events = tx.clone();
233    let mut watcher = spawn_watcher(
234        WatchConfig {
235            paths: Vec::new(),
236            explicit_files: Vec::new(),
237            ignore: Vec::new(),
238            debounce: DEBOUNCE_DURATION,
239            retry_unchanged_when: None,
240        },
241        move |paths| {
242            let _ = events.try_send(paths);
243        },
244    )?;
245    refresh_source_watcher(
246        &mut watcher,
247        source_targets(config, config_dir, config_path, dependencies),
248        dependencies,
249        &tx,
250    )?;
251    Ok(watcher)
252}
253
254/// Point the running watcher at the inputs of the build that just finished.
255///
256/// The watcher is retargeted rather than replaced: dropping it would discard
257/// source edits it observed during the build but had not debounced yet.
258fn refresh_source_watcher(
259    watcher: &mut WatcherHandle,
260    targets: WatchTargets,
261    dependencies: &StateDependencies,
262    tx: &TickSender,
263) -> Result<()> {
264    watcher.retarget(targets)?;
265    // The previous targets may exclude a newly discovered include. Compare with
266    // the bytes used for rendering only after the new subscriptions are active.
267    let changed = dependencies.changed_paths();
268    if !changed.is_empty() {
269        match tx.try_send(changed) {
270            Ok(()) | Err(std::sync::mpsc::TrySendError::Full(_)) => {}
271            Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
272                return Err(anyhow!("Press rebuild worker disconnected"));
273            }
274        }
275    }
276    Ok(())
277}
278
279/// Inputs that contribute to a build: source roots, generated includes, and
280/// projection manifests.
281fn source_targets(
282    config: &DocsConfig,
283    config_dir: &Path,
284    config_path: &Path,
285    dependencies: &StateDependencies,
286) -> WatchTargets {
287    let mut explicit_files = projection_manifest_paths(
288        config_dir,
289        config
290            .bundler
291            .as_ref()
292            .map_or(&[][..], |bundler| bundler.projection_manifests.as_slice()),
293    );
294    explicit_files.extend_from_slice(&dependencies.paths);
295    let mut ignore = default_ignore_paths();
296    ignore.push(PathBuf::from(&config.out_dir));
297    ignore.push(PathBuf::from(".webui-press/cache"));
298    ignore.push(config_dir.join(crate::scratch::PROJECT_SCRATCH_DIR));
299    WatchTargets {
300        paths: watch_paths(
301            config_dir,
302            config_path,
303            Path::new(&config.content_dir),
304            Path::new(&config.public_dir),
305        ),
306        explicit_files,
307        ignore,
308    }
309}
310
311/// Default actix handler — delegates to the shared static-file
312/// responder. A trivial wrapper because actix needs a function pointer
313/// and the shared crate ships an `&HttpRequest`-taking helper.
314async fn static_handler(req: HttpRequest, cfg: web::Data<StaticServeConfig>) -> HttpResponse {
315    serve_static_file(&req, cfg.get_ref()).await
316}
317
318// ─────────────────────────────────────────────────────────────────────────────
319// Watch-path selection
320// ─────────────────────────────────────────────────────────────────────────────
321
322/// Compute the deduped set of filesystem paths the dev server should
323/// watch. The watcher tracks every path that contributes to the rendered
324/// output:
325///
326///  - `config_dir` — components, theme, and other assets co-located with
327///    `config.json`,
328///  - the parent of `config_path` — only added if it isn't already
329///    covered (it usually equals `config_dir`),
330///  - `content_dir` — markdown source files, typically a sibling of
331///    `config_dir` (e.g. `docs/` while config lives at
332///    `docs/.webui-press/config.json`),
333///  - `public_dir` — verbatim assets copied into the build, often
334///    nested inside `config_dir`.
335///
336/// We deliberately do NOT watch the framework's `template_dir`: that
337/// directory ships with the `webui-press` crate and only changes when
338/// the binary itself is rebuilt, which already restarts the dev server.
339///
340/// A path is dropped if it is already covered (via `starts_with`) by
341/// something we are already watching. When a new path is a parent of an
342/// already-watched one, it replaces the descendant — this keeps `notify`
343/// from receiving overlapping subscriptions.
344fn watch_paths(
345    config_dir: &Path,
346    config_path: &Path,
347    content_dir: &Path,
348    public_dir: &Path,
349) -> Vec<PathBuf> {
350    let mut paths: Vec<PathBuf> = Vec::with_capacity(4);
351    let mut push_dedup = |candidate: PathBuf| {
352        if candidate.as_os_str().is_empty() {
353            return;
354        }
355        // Canonicalize so that subset/superset relations compare on
356        // identical, symlink-resolved roots — e.g. `./` vs an absolute
357        // `/Users/.../docs` would otherwise look disjoint and watch
358        // both. Falls back to the raw candidate when the path doesn't
359        // exist yet (e.g. publicDir a user hasn't created).
360        let candidate = candidate.canonicalize().unwrap_or(candidate);
361        if paths.iter().any(|p| candidate.starts_with(p)) {
362            return;
363        }
364        paths.retain(|p| !p.starts_with(&candidate));
365        paths.push(candidate);
366    };
367    push_dedup(config_dir.to_path_buf());
368    if let Some(parent) = config_path.parent() {
369        push_dedup(parent.to_path_buf());
370    }
371    push_dedup(content_dir.to_path_buf());
372    push_dedup(public_dir.to_path_buf());
373    paths
374}
375
376fn projection_manifest_paths(config_dir: &Path, manifests: &[String]) -> Vec<PathBuf> {
377    manifests
378        .iter()
379        .map(|manifest| {
380            let path = config_dir.join(manifest);
381            path.canonicalize().unwrap_or(path)
382        })
383        .collect()
384}
385
386/// Re-read and parse `config.json`. Used both to seed the initial build
387/// and inside the rebuild worker so live edits to the config take effect.
388fn clone_config_via_reparse(
389    config_path: &Path,
390    show_override: Option<ShowMode>,
391) -> Result<DocsConfig> {
392    let s = std::fs::read_to_string(config_path)
393        .with_context(|| format!("Cannot read {}", config_path.display()))?;
394    let mut config: DocsConfig = serde_json::from_str(&s)
395        .with_context(|| format!("Invalid JSON in {}", config_path.display()))?;
396    if let Some(show) = show_override {
397        config.show = show;
398    }
399    Ok(config)
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn watcher_tracks_external_appearance_sources() -> Result<()> {
408        let project = tempfile::tempdir()?;
409        let external = tempfile::tempdir()?;
410        let file = external.path().join("node_modules/theme/tokens.json");
411        std::fs::create_dir_all(file.parent().context("theme parent")?)?;
412        std::fs::write(
413            &file,
414            r#"{"themes":{"light":{"size":"1px"},"dark":{"size":"2px"}}}"#,
415        )?;
416        let mut config: DocsConfig = serde_json::from_str(
417            r#"{"site":{"title":"Test"},"basePath":"/","contentDir":"missing","nav":[],"sidebar":[],"appearance":{"defaultTheme":"official","themes":[{"id":"official","label":"Official"}]}}"#,
418        )?;
419        config
420            .appearance
421            .as_mut()
422            .context("appearance config")?
423            .themes[0]
424            .source = Some(file.to_string_lossy().into_owned());
425        let mut dependencies = StateDependencies::default();
426        crate::appearance::PreparedAppearance::prepare(&config, project.path(), &mut dependencies)?;
427        let (tx, rx) = std::sync::mpsc::sync_channel(8);
428        let _watcher = source_watcher(
429            &config,
430            project.path(),
431            &project.path().join("config.json"),
432            &dependencies,
433            tx,
434        )?;
435        std::fs::write(
436            &file,
437            r#"{"themes":{"light":{"size":"3px"},"dark":{"size":"4px"}}}"#,
438        )?;
439        let changed = rx.recv_timeout(Duration::from_secs(3))?;
440        assert!(changed.contains(&file.canonicalize()?));
441        Ok(())
442    }
443
444    #[test]
445    fn watcher_ignores_project_scratch_but_rebuilds_authored_sources() -> Result<()> {
446        let root = tempfile::tempdir()?;
447        let config_dir = root.path().join(".webui-press");
448        let scratch_dir = config_dir.join(crate::scratch::PROJECT_SCRATCH_DIR);
449        std::fs::create_dir_all(&scratch_dir)?;
450        let generated = scratch_dir.join("index.html");
451        let authored = root.path().join("index.md");
452        std::fs::write(&generated, "generated")?;
453        std::fs::write(&authored, "# Home")?;
454        let mut config: DocsConfig = serde_json::from_str(
455            r#"{"site":{"title":"Test"},"basePath":"/","contentDir":".","nav":[],"sidebar":[]}"#,
456        )?;
457        config.content_dir = root.path().to_string_lossy().into_owned();
458        config.public_dir = config.content_dir.clone();
459        let (tx, rx) = std::sync::mpsc::sync_channel(8);
460        let _watcher = source_watcher(
461            &config,
462            &config_dir,
463            &config_dir.join("config.json"),
464            &crate::state::StateLoader::new().into_dependencies(),
465            tx,
466        )?;
467
468        std::fs::write(&generated, "rebuilt")?;
469        assert!(matches!(
470            rx.recv_timeout(Duration::from_millis(500)),
471            Err(std::sync::mpsc::RecvTimeoutError::Timeout)
472        ));
473        std::fs::remove_file(&generated)?;
474        assert!(matches!(
475            rx.recv_timeout(Duration::from_millis(500)),
476            Err(std::sync::mpsc::RecvTimeoutError::Timeout)
477        ));
478
479        std::fs::write(&authored, "# Updated home")?;
480        let changed = rx.recv_timeout(Duration::from_secs(3))?;
481        assert_eq!(changed.len(), 1);
482        assert_eq!(changed[0].canonicalize()?, authored.canonicalize()?);
483        Ok(())
484    }
485
486    #[test]
487    fn watcher_requeues_state_changed_after_the_render_read() -> Result<()> {
488        let root = tempfile::tempdir()?;
489        let file = root.path().join("target/state.json");
490        std::fs::create_dir(file.parent().context("state parent")?)?;
491        std::fs::write(&file, r#"{"count":1}"#)?;
492        let mut loader = crate::state::StateLoader::new();
493        loader.load_markdown_state(
494            Some("target/state.json"),
495            None,
496            &root.path().join("page.md"),
497        )?;
498        let dependencies = loader.into_dependencies();
499        std::fs::write(&file, r#"{"count":2}"#)?;
500        let config: DocsConfig = serde_json::from_str(
501            r#"{"site":{"title":"Test"},"basePath":"/","contentDir":"missing","nav":[],"sidebar":[]}"#,
502        )?;
503        let (tx, rx) = std::sync::mpsc::sync_channel(8);
504        let _watcher = source_watcher(
505            &config,
506            root.path(),
507            &root.path().join("config.json"),
508            &dependencies,
509            tx,
510        )?;
511        assert_eq!(
512            rx.recv_timeout(Duration::from_millis(200))?,
513            dependencies.paths
514        );
515        Ok(())
516    }
517
518    #[test]
519    fn source_edits_during_a_watch_refresh_still_rebuild() -> Result<()> {
520        let root = tempfile::tempdir()?;
521        let content = root.path().join("content");
522        std::fs::create_dir(&content)?;
523        let page = content.join("page.md");
524        std::fs::write(&page, "# One")?;
525        let config_dir = root.path().join(".webui-press");
526        std::fs::create_dir(&config_dir)?;
527        let mut config: DocsConfig = serde_json::from_str(
528            r#"{"site":{"title":"Test"},"basePath":"/","contentDir":".","nav":[],"sidebar":[]}"#,
529        )?;
530        config.content_dir = content.to_string_lossy().into_owned();
531        config.public_dir = config.content_dir.clone();
532        let dependencies = crate::state::StateLoader::new().into_dependencies();
533        let config_path = config_dir.join("config.json");
534        let (tx, rx) = std::sync::mpsc::sync_channel(8);
535        let mut watcher = source_watcher(
536            &config,
537            &config_dir,
538            &config_path,
539            &dependencies,
540            tx.clone(),
541        )?;
542
543        // A rebuild that read the old page finishes and refreshes its watches
544        // before the debounce window reports this edit.
545        std::fs::write(&page, "# Two")?;
546        refresh_source_watcher(
547            &mut watcher,
548            source_targets(&config, &config_dir, &config_path, &dependencies),
549            &dependencies,
550            &tx,
551        )?;
552        let changed = rx.recv_timeout(Duration::from_secs(5))?;
553        assert_eq!(changed.len(), 1);
554        assert_eq!(changed[0].canonicalize()?, page.canonicalize()?);
555        Ok(())
556    }
557
558    #[test]
559    fn watch_paths_dedupes_nested_paths() {
560        let cfg = Path::new("/proj/docs/.webui-press");
561        let config_file = Path::new("/proj/docs/.webui-press/config.json");
562        let content = Path::new("/proj/docs/.webui-press");
563        let public = Path::new("/proj/docs/.webui-press/public");
564        let paths = watch_paths(cfg, config_file, content, public);
565        // config_path parent, content_dir, public_dir are all inside
566        // config_dir → only config_dir watched.
567        assert_eq!(paths, vec![PathBuf::from("/proj/docs/.webui-press")]);
568    }
569
570    #[test]
571    fn watch_paths_includes_sibling_content_dir() {
572        // Real-world layout: config lives in docs/.webui-press/config.json,
573        // markdown lives in docs/ (a parent / sibling of .webui-press).
574        let cfg = Path::new("docs/.webui-press");
575        let config_file = Path::new("docs/.webui-press/config.json");
576        let content = Path::new("docs");
577        let public = Path::new("docs/.webui-press/public");
578        let paths = watch_paths(cfg, config_file, content, public);
579        // content_dir (docs) is a parent of everything else → it
580        // promotes to the only watched root.
581        assert_eq!(paths, vec![PathBuf::from("docs")]);
582    }
583
584    #[test]
585    fn watch_paths_includes_external_content_dir() {
586        // content_dir lives outside config_dir.
587        let cfg = Path::new("/proj/.webui-press");
588        let config_file = Path::new("/proj/.webui-press/config.json");
589        let content = Path::new("/proj/markdown");
590        let public = Path::new("/proj/.webui-press/public");
591        let paths = watch_paths(cfg, config_file, content, public);
592        assert_eq!(
593            paths,
594            vec![
595                PathBuf::from("/proj/.webui-press"),
596                PathBuf::from("/proj/markdown"),
597            ]
598        );
599    }
600
601    #[test]
602    fn projection_manifest_paths_resolve_from_config_directory() {
603        let paths = projection_manifest_paths(
604            Path::new("/proj/docs/.webui-press"),
605            &["../../client/dist/webui-projection.json".to_string()],
606        );
607
608        assert_eq!(
609            paths,
610            [PathBuf::from(
611                "/proj/docs/.webui-press/../../client/dist/webui-projection.json"
612            )]
613        );
614    }
615}