1use 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
43const DEBOUNCE_DURATION: Duration = Duration::from_millis(50);
46
47const RELOAD_PATH: &str = "/__webui_press/livereload";
50
51pub 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 pub show_override: Option<ShowMode>,
61}
62
63pub 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 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 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 cache.set_dev_rebuild();
115 Ok(cache)
116 })
117 .await
118 .map_err(|e| anyhow!("build task panicked: {e}"))??
119 };
120 let _ = config;
123
124 let livereload = LiveReload::new(RELOAD_PATH);
125
126 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 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 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
222async fn static_handler(req: HttpRequest, cfg: web::Data<StaticServeConfig>) -> HttpResponse {
226 serve_static_file(&req, cfg.get_ref()).await
227}
228
229fn 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 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
297fn 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 assert_eq!(paths, vec![PathBuf::from("/proj/docs/.webui-press")]);
327 }
328
329 #[test]
330 fn watch_paths_includes_sibling_content_dir() {
331 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 assert_eq!(paths, vec![PathBuf::from("docs")]);
341 }
342
343 #[test]
344 fn watch_paths_includes_external_content_dir() {
345 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}