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::{
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
42const DEBOUNCE_DURATION: Duration = Duration::from_millis(50);
45
46const RELOAD_PATH: &str = "/__webui_press/livereload";
49
50pub 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
60pub 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 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 let projection_manifest_files = projection_manifest_paths(
78 &config_dir,
79 config
80 .bundler
81 .as_ref()
82 .map_or(&[][..], |bundler| bundler.projection_manifests.as_slice()),
83 );
84
85 println!(
86 "{} {}",
87 style("⚡").cyan().bold(),
88 style("WebUI Press dev server").bold()
89 );
90 println!();
91
92 let initial_cache: BuildCache = {
98 let cfg = clone_config_via_reparse(&config_path)?;
99 let cd = config_dir.clone();
100 let td = template_dir.clone();
101 tokio::task::spawn_blocking(move || -> Result<BuildCache> {
102 let mut cache = BuildCache::new();
103 build_docs_with_cache(&cfg, &cd, &td, &mut cache)
104 .map_err(|e| anyhow!("initial build failed: {e}"))?;
105 cache.set_dev_rebuild();
111 Ok(cache)
112 })
113 .await
114 .map_err(|e| anyhow!("build task panicked: {e}"))??
115 };
116 let _ = config;
119
120 let livereload = LiveReload::new(RELOAD_PATH);
121
122 let cache = Arc::new(Mutex::new(initial_cache));
126
127 let tick_tx = {
128 let config_path = config_path.clone();
129 let config_dir = config_dir.clone();
130 let template_dir = template_dir.clone();
131 let cache = cache.clone();
132 spawn_rebuild_worker(livereload.clone(), move || {
133 let cfg = clone_config_via_reparse(&config_path)
134 .map_err(|e| format!("config reload failed: {e}"))?;
135 let mut guard = cache
136 .lock()
137 .unwrap_or_else(|poisoned| poisoned.into_inner());
138 build_docs_with_cache(&cfg, &config_dir, &template_dir, &mut guard)
139 .map_err(|e| format!("{e}"))?;
140 Ok(Vec::new())
142 })
143 };
144
145 let _watcher: WatcherHandle = {
146 let tx = tick_tx.clone();
147 let watched = watch_paths(
148 &config_dir,
149 &config_path,
150 Path::new(&content_dir_str),
151 Path::new(&public_dir_str),
152 );
153 let mut ignore = default_ignore_paths();
154 ignore.push(out_dir.clone());
155 ignore.push(PathBuf::from(".webui-press/cache"));
156 spawn_watcher(
157 WatchConfig {
158 paths: watched,
159 explicit_files: projection_manifest_files,
160 ignore,
161 debounce: DEBOUNCE_DURATION,
162 retry_unchanged_when: None,
163 },
164 move |paths: Vec<PathBuf>| {
165 let _ = tx.try_send(paths);
170 },
171 )?
172 };
173
174 let static_cfg = StaticServeConfig {
175 root: out_dir.clone(),
176 base_path: base_path.clone(),
177 livereload: livereload.clone(),
178 not_found: NotFoundStrategy::File(PathBuf::from("404.html")),
179 };
180 let static_data = web::Data::new(static_cfg);
181 let lr_data = web::Data::new(livereload.clone());
182
183 let bind = format!("{host}:{port}");
184 let url = format!("http://{host}:{port}{base_path}");
185 println!(
186 " {} {}",
187 style("➜").green().bold(),
188 style(&url).cyan().underlined()
189 );
190 println!();
191
192 HttpServer::new(move || {
193 App::new()
194 .app_data(static_data.clone())
195 .app_data(lr_data.clone())
196 .route(RELOAD_PATH, web::get().to(sse_handler))
197 .default_service(web::get().to(static_handler))
198 })
199 .bind(&bind)
200 .with_context(|| format!("Cannot bind {bind}"))?
201 .run()
202 .await
203 .context("Dev server failed")?;
204
205 drop(_watcher);
207 Ok(())
208}
209
210async fn static_handler(req: HttpRequest, cfg: web::Data<StaticServeConfig>) -> HttpResponse {
214 serve_static_file(&req, cfg.get_ref()).await
215}
216
217fn watch_paths(
244 config_dir: &Path,
245 config_path: &Path,
246 content_dir: &Path,
247 public_dir: &Path,
248) -> Vec<PathBuf> {
249 let mut paths: Vec<PathBuf> = Vec::with_capacity(4);
250 let mut push_dedup = |candidate: PathBuf| {
251 if candidate.as_os_str().is_empty() {
252 return;
253 }
254 let candidate = candidate.canonicalize().unwrap_or(candidate);
260 if paths.iter().any(|p| candidate.starts_with(p)) {
261 return;
262 }
263 paths.retain(|p| !p.starts_with(&candidate));
264 paths.push(candidate);
265 };
266 push_dedup(config_dir.to_path_buf());
267 if let Some(parent) = config_path.parent() {
268 push_dedup(parent.to_path_buf());
269 }
270 push_dedup(content_dir.to_path_buf());
271 push_dedup(public_dir.to_path_buf());
272 paths
273}
274
275fn projection_manifest_paths(config_dir: &Path, manifests: &[String]) -> Vec<PathBuf> {
276 manifests
277 .iter()
278 .map(|manifest| {
279 let path = config_dir.join(manifest);
280 path.canonicalize().unwrap_or(path)
281 })
282 .collect()
283}
284
285fn clone_config_via_reparse(config_path: &Path) -> Result<DocsConfig> {
288 let s = std::fs::read_to_string(config_path)
289 .with_context(|| format!("Cannot read {}", config_path.display()))?;
290 serde_json::from_str(&s).with_context(|| format!("Invalid JSON in {}", config_path.display()))
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn watch_paths_dedupes_nested_paths() {
299 let cfg = Path::new("/proj/docs/.webui-press");
300 let config_file = Path::new("/proj/docs/.webui-press/config.json");
301 let content = Path::new("/proj/docs/.webui-press");
302 let public = Path::new("/proj/docs/.webui-press/public");
303 let paths = watch_paths(cfg, config_file, content, public);
304 assert_eq!(paths, vec![PathBuf::from("/proj/docs/.webui-press")]);
307 }
308
309 #[test]
310 fn watch_paths_includes_sibling_content_dir() {
311 let cfg = Path::new("docs/.webui-press");
314 let config_file = Path::new("docs/.webui-press/config.json");
315 let content = Path::new("docs");
316 let public = Path::new("docs/.webui-press/public");
317 let paths = watch_paths(cfg, config_file, content, public);
318 assert_eq!(paths, vec![PathBuf::from("docs")]);
321 }
322
323 #[test]
324 fn watch_paths_includes_external_content_dir() {
325 let cfg = Path::new("/proj/.webui-press");
327 let config_file = Path::new("/proj/.webui-press/config.json");
328 let content = Path::new("/proj/markdown");
329 let public = Path::new("/proj/.webui-press/public");
330 let paths = watch_paths(cfg, config_file, content, public);
331 assert_eq!(
332 paths,
333 vec![
334 PathBuf::from("/proj/.webui-press"),
335 PathBuf::from("/proj/markdown"),
336 ]
337 );
338 }
339
340 #[test]
341 fn projection_manifest_paths_resolve_from_config_directory() {
342 let paths = projection_manifest_paths(
343 Path::new("/proj/docs/.webui-press"),
344 &["../../client/dist/webui-projection.json".to_string()],
345 );
346
347 assert_eq!(
348 paths,
349 [PathBuf::from(
350 "/proj/docs/.webui-press/../../client/dist/webui-projection.json"
351 )]
352 );
353 }
354}