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