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 // webui-press has no per-rebuild advisories to surface.
134 Ok(Vec::new())
135 })
136 };
137
138 let _watcher: WatcherHandle = {
139 let tx = tick_tx.clone();
140 let watched = watch_paths(
141 &config_dir,
142 &config_path,
143 Path::new(&content_dir_str),
144 Path::new(&public_dir_str),
145 );
146 let mut ignore = default_ignore_paths();
147 ignore.push(out_dir.clone());
148 ignore.push(PathBuf::from(".webui-press/cache"));
149 spawn_watcher(
150 WatchConfig {
151 paths: watched,
152 explicit_files: Vec::new(),
153 ignore,
154 debounce: DEBOUNCE_DURATION,
155 retry_unchanged_when: None,
156 },
157 move |paths: Vec<PathBuf>| {
158 // Forward the changed paths so the rebuild line names the
159 // triggering file. Coalesce on full: the worker drains all
160 // queued ticks before starting a build, so a dropped tick is
161 // equivalent to a coalesced one.
162 let _ = tx.try_send(paths);
163 },
164 )?
165 };
166
167 let static_cfg = StaticServeConfig {
168 root: out_dir.clone(),
169 base_path: base_path.clone(),
170 livereload: livereload.clone(),
171 not_found: NotFoundStrategy::File(PathBuf::from("404.html")),
172 };
173 let static_data = web::Data::new(static_cfg);
174 let lr_data = web::Data::new(livereload.clone());
175
176 let bind = format!("{host}:{port}");
177 let url = format!("http://{host}:{port}{base_path}");
178 println!(
179 " {} {}",
180 style("➜").green().bold(),
181 style(&url).cyan().underlined()
182 );
183 println!();
184
185 HttpServer::new(move || {
186 App::new()
187 .app_data(static_data.clone())
188 .app_data(lr_data.clone())
189 .route(RELOAD_PATH, web::get().to(sse_handler))
190 .default_service(web::get().to(static_handler))
191 })
192 .bind(&bind)
193 .with_context(|| format!("Cannot bind {bind}"))?
194 .run()
195 .await
196 .context("Dev server failed")?;
197
198 // Hold the watcher until the server returns so it isn't dropped early.
199 drop(_watcher);
200 Ok(())
201}
202
203/// Default actix handler — delegates to the shared static-file
204/// responder. A trivial wrapper because actix needs a function pointer
205/// and the shared crate ships an `&HttpRequest`-taking helper.
206async fn static_handler(req: HttpRequest, cfg: web::Data<StaticServeConfig>) -> HttpResponse {
207 serve_static_file(&req, cfg.get_ref()).await
208}
209
210// ─────────────────────────────────────────────────────────────────────────────
211// Watch-path selection
212// ─────────────────────────────────────────────────────────────────────────────
213
214/// Compute the deduped set of filesystem paths the dev server should
215/// watch. The watcher tracks every path that contributes to the rendered
216/// output:
217///
218/// - `config_dir` — components, theme, and other assets co-located with
219/// `config.json`,
220/// - the parent of `config_path` — only added if it isn't already
221/// covered (it usually equals `config_dir`),
222/// - `content_dir` — markdown source files, typically a sibling of
223/// `config_dir` (e.g. `docs/` while config lives at
224/// `docs/.webui-press/config.json`),
225/// - `public_dir` — verbatim assets copied into the build, often
226/// nested inside `config_dir`.
227///
228/// We deliberately do NOT watch the framework's `template_dir`: that
229/// directory ships with the `webui-press` crate and only changes when
230/// the binary itself is rebuilt, which already restarts the dev server.
231///
232/// A path is dropped if it is already covered (via `starts_with`) by
233/// something we are already watching. When a new path is a parent of an
234/// already-watched one, it replaces the descendant — this keeps `notify`
235/// from receiving overlapping subscriptions.
236fn watch_paths(
237 config_dir: &Path,
238 config_path: &Path,
239 content_dir: &Path,
240 public_dir: &Path,
241) -> Vec<PathBuf> {
242 let mut paths: Vec<PathBuf> = Vec::with_capacity(4);
243 let mut push_dedup = |candidate: PathBuf| {
244 if candidate.as_os_str().is_empty() {
245 return;
246 }
247 // Canonicalize so that subset/superset relations compare on
248 // identical, symlink-resolved roots — e.g. `./` vs an absolute
249 // `/Users/.../docs` would otherwise look disjoint and watch
250 // both. Falls back to the raw candidate when the path doesn't
251 // exist yet (e.g. publicDir a user hasn't created).
252 let candidate = candidate.canonicalize().unwrap_or(candidate);
253 if paths.iter().any(|p| candidate.starts_with(p)) {
254 return;
255 }
256 paths.retain(|p| !p.starts_with(&candidate));
257 paths.push(candidate);
258 };
259 push_dedup(config_dir.to_path_buf());
260 if let Some(parent) = config_path.parent() {
261 push_dedup(parent.to_path_buf());
262 }
263 push_dedup(content_dir.to_path_buf());
264 push_dedup(public_dir.to_path_buf());
265 paths
266}
267
268/// Re-read and parse `config.json`. Used both to seed the initial build
269/// and inside the rebuild worker so live edits to the config take effect.
270fn clone_config_via_reparse(config_path: &Path) -> Result<DocsConfig> {
271 let s = std::fs::read_to_string(config_path)
272 .with_context(|| format!("Cannot read {}", config_path.display()))?;
273 serde_json::from_str(&s).with_context(|| format!("Invalid JSON in {}", config_path.display()))
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn watch_paths_dedupes_nested_paths() {
282 let cfg = Path::new("/proj/docs/.webui-press");
283 let config_file = Path::new("/proj/docs/.webui-press/config.json");
284 let content = Path::new("/proj/docs/.webui-press");
285 let public = Path::new("/proj/docs/.webui-press/public");
286 let paths = watch_paths(cfg, config_file, content, public);
287 // config_path parent, content_dir, public_dir are all inside
288 // config_dir → only config_dir watched.
289 assert_eq!(paths, vec![PathBuf::from("/proj/docs/.webui-press")]);
290 }
291
292 #[test]
293 fn watch_paths_includes_sibling_content_dir() {
294 // Real-world layout: config lives in docs/.webui-press/config.json,
295 // markdown lives in docs/ (a parent / sibling of .webui-press).
296 let cfg = Path::new("docs/.webui-press");
297 let config_file = Path::new("docs/.webui-press/config.json");
298 let content = Path::new("docs");
299 let public = Path::new("docs/.webui-press/public");
300 let paths = watch_paths(cfg, config_file, content, public);
301 // content_dir (docs) is a parent of everything else → it
302 // promotes to the only watched root.
303 assert_eq!(paths, vec![PathBuf::from("docs")]);
304 }
305
306 #[test]
307 fn watch_paths_includes_external_content_dir() {
308 // content_dir lives outside config_dir.
309 let cfg = Path::new("/proj/.webui-press");
310 let config_file = Path::new("/proj/.webui-press/config.json");
311 let content = Path::new("/proj/markdown");
312 let public = Path::new("/proj/.webui-press/public");
313 let paths = watch_paths(cfg, config_file, content, public);
314 assert_eq!(
315 paths,
316 vec![
317 PathBuf::from("/proj/.webui-press"),
318 PathBuf::from("/proj/markdown"),
319 ]
320 );
321 }
322}