1use 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
45const DEBOUNCE_DURATION: Duration = Duration::from_millis(50);
48
49const RELOAD_PATH: &str = "/__webui_press/livereload";
52
53pub 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 pub show_override: Option<ShowMode>,
63}
64
65pub 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 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 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 cache.set_dev_rebuild();
108 Ok(cache)
109 })
110 .await
111 .map_err(|e| anyhow!("build task panicked: {e}"))??
112 };
113 let livereload = LiveReload::new(RELOAD_PATH);
116
117 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 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
224fn 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
254fn refresh_source_watcher(
259 watcher: &mut WatcherHandle,
260 targets: WatchTargets,
261 dependencies: &StateDependencies,
262 tx: &TickSender,
263) -> Result<()> {
264 watcher.retarget(targets)?;
265 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
279fn 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
311async fn static_handler(req: HttpRequest, cfg: web::Data<StaticServeConfig>) -> HttpResponse {
315 serve_static_file(&req, cfg.get_ref()).await
316}
317
318fn 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 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
386fn 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 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 assert_eq!(paths, vec![PathBuf::from("/proj/docs/.webui-press")]);
568 }
569
570 #[test]
571 fn watch_paths_includes_sibling_content_dir() {
572 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 assert_eq!(paths, vec![PathBuf::from("docs")]);
582 }
583
584 #[test]
585 fn watch_paths_includes_external_content_dir() {
586 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}