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, WatcherHandle,
38};
39
40use crate::build::{build_docs_with_cache, BuildCache};
41use crate::state::StateDependencies;
42use crate::types::{DocsConfig, ShowMode};
43
44const DEBOUNCE_DURATION: Duration = Duration::from_millis(50);
47
48const RELOAD_PATH: &str = "/__webui_press/livereload";
51
52pub struct ServeConfig {
54 pub config: DocsConfig,
55 pub config_dir: PathBuf,
56 pub template_dir: PathBuf,
57 pub config_path: PathBuf,
58 pub host: String,
59 pub port: u16,
60 pub show_override: Option<ShowMode>,
62}
63
64pub async fn run_serve(opts: ServeConfig, control: Option<Control>) -> Result<()> {
66 let ServeConfig {
67 config,
68 config_dir,
69 template_dir,
70 config_path,
71 host,
72 port,
73 show_override,
74 } = opts;
75 let base_path = normalize_base_path(&config.base_path);
76 let out_dir = PathBuf::from(&config.out_dir);
80
81 println!(
82 "{} {}",
83 style("⚡").cyan().bold(),
84 style("WebUI Press dev server").bold()
85 );
86 println!();
87
88 let initial_cache: BuildCache = {
94 let cfg = clone_config_via_reparse(&config_path, show_override)?;
95 let cd = config_dir.clone();
96 let td = template_dir.clone();
97 tokio::task::spawn_blocking(move || -> Result<BuildCache> {
98 let mut cache = BuildCache::new();
99 build_docs_with_cache(&cfg, &cd, &td, &mut cache)
100 .map_err(|e| anyhow!("initial build failed: {e}"))?;
101 cache.set_dev_rebuild();
107 Ok(cache)
108 })
109 .await
110 .map_err(|e| anyhow!("build task panicked: {e}"))??
111 };
112 let livereload = LiveReload::new(RELOAD_PATH);
115
116 let cache = Arc::new(Mutex::new(initial_cache));
120 let watcher = Arc::new(Mutex::new(None::<WatcherHandle>));
121 let sender = Arc::new(OnceLock::<TickSender>::new());
122
123 let worker = {
124 let config_path = config_path.clone();
125 let config_dir = config_dir.clone();
126 let template_dir = template_dir.clone();
127 let cache = cache.clone();
128 let watcher = watcher.clone();
129 let sender = sender.clone();
130 spawn_rebuild_worker(livereload.clone(), move || {
131 let cfg = clone_config_via_reparse(&config_path, show_override)
132 .map_err(|e| format!("config reload failed: {e}"))?;
133 let mut guard = cache
134 .lock()
135 .unwrap_or_else(|poisoned| poisoned.into_inner());
136 let result = build_docs_with_cache(&cfg, &config_dir, &template_dir, &mut guard);
137 let tx = sender
138 .get()
139 .ok_or_else(|| "Press watcher sender is unavailable".to_string())?;
140 let next = source_watcher(
141 &cfg,
142 &config_dir,
143 &config_path,
144 &guard.dependencies,
145 tx.clone(),
146 )
147 .map_err(|error| format!("Cannot refresh Press input watches: {error}"))?;
148 *watcher
149 .lock()
150 .map_err(|error| format!("Press watcher lock failed: {error}"))? = Some(next);
151 result.map_err(|e| format!("{e}"))?;
152 Ok(Vec::new())
154 })
155 };
156
157 let tx = worker.sender();
158 sender
159 .set(tx.clone())
160 .map_err(|_| anyhow!("Press watcher sender was already initialized"))?;
161 {
162 let guard = cache
163 .lock()
164 .map_err(|error| anyhow!("Press build cache lock failed: {error}"))?;
165 let initial_watcher =
166 source_watcher(&config, &config_dir, &config_path, &guard.dependencies, tx)?;
167 *watcher
168 .lock()
169 .map_err(|error| anyhow!("Press watcher lock failed: {error}"))? =
170 Some(initial_watcher);
171 }
172
173 let static_cfg = StaticServeConfig {
174 root: out_dir.clone(),
175 base_path: base_path.clone(),
176 livereload: livereload.clone(),
177 not_found: NotFoundStrategy::File(PathBuf::from("404.html")),
178 };
179 let static_data = web::Data::new(static_cfg);
180 let lr_data = web::Data::new(livereload.clone());
181
182 let bind = format!("{host}:{port}");
183 let url = format!("http://{host}:{port}{base_path}");
184 println!(
185 " {} {}",
186 style("➜").green().bold(),
187 style(&url).cyan().underlined()
188 );
189 println!();
190
191 let mut server = HttpServer::new(move || {
192 App::new()
193 .app_data(static_data.clone())
194 .app_data(lr_data.clone())
195 .route(RELOAD_PATH, web::get().to(sse_handler))
196 .default_service(web::get().to(static_handler))
197 });
198 if control.is_some() {
199 server = server.disable_signals();
200 }
201 let server = server
202 .bind(&bind)
203 .with_context(|| format!("Cannot bind {bind}"))?
204 .run();
205 let server_result: Result<()> = match control {
206 Some(control) => control.serve(server).await.map_err(Into::into),
207 None => server.await.map_err(Into::into),
208 };
209 let server_result = server_result.context("Dev server failed");
210
211 worker.shutdown()?;
212 *watcher
213 .lock()
214 .map_err(|error| anyhow!("Press watcher lock failed: {error}"))? = None;
215 server_result?;
216 Ok(())
217}
218
219fn source_watcher(
220 config: &DocsConfig,
221 config_dir: &Path,
222 config_path: &Path,
223 dependencies: &StateDependencies,
224 tx: TickSender,
225) -> Result<WatcherHandle> {
226 let mut explicit_files = projection_manifest_paths(
227 config_dir,
228 config
229 .bundler
230 .as_ref()
231 .map_or(&[][..], |bundler| bundler.projection_manifests.as_slice()),
232 );
233 explicit_files.extend_from_slice(&dependencies.paths);
234 let mut ignore = default_ignore_paths();
235 ignore.push(PathBuf::from(&config.out_dir));
236 ignore.push(PathBuf::from(".webui-press/cache"));
237 let events = tx.clone();
238 let watcher = spawn_watcher(
239 WatchConfig {
240 paths: watch_paths(
241 config_dir,
242 config_path,
243 Path::new(&config.content_dir),
244 Path::new(&config.public_dir),
245 ),
246 explicit_files,
247 ignore,
248 debounce: DEBOUNCE_DURATION,
249 retry_unchanged_when: None,
250 },
251 move |paths| {
252 let _ = events.try_send(paths);
253 },
254 )?;
255 let changed = dependencies.changed_paths();
258 if !changed.is_empty() {
259 match tx.try_send(changed) {
260 Ok(()) | Err(std::sync::mpsc::TrySendError::Full(_)) => {}
261 Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
262 return Err(anyhow!("Press rebuild worker disconnected"));
263 }
264 }
265 }
266 Ok(watcher)
267}
268
269async fn static_handler(req: HttpRequest, cfg: web::Data<StaticServeConfig>) -> HttpResponse {
273 serve_static_file(&req, cfg.get_ref()).await
274}
275
276fn watch_paths(
303 config_dir: &Path,
304 config_path: &Path,
305 content_dir: &Path,
306 public_dir: &Path,
307) -> Vec<PathBuf> {
308 let mut paths: Vec<PathBuf> = Vec::with_capacity(4);
309 let mut push_dedup = |candidate: PathBuf| {
310 if candidate.as_os_str().is_empty() {
311 return;
312 }
313 let candidate = candidate.canonicalize().unwrap_or(candidate);
319 if paths.iter().any(|p| candidate.starts_with(p)) {
320 return;
321 }
322 paths.retain(|p| !p.starts_with(&candidate));
323 paths.push(candidate);
324 };
325 push_dedup(config_dir.to_path_buf());
326 if let Some(parent) = config_path.parent() {
327 push_dedup(parent.to_path_buf());
328 }
329 push_dedup(content_dir.to_path_buf());
330 push_dedup(public_dir.to_path_buf());
331 paths
332}
333
334fn projection_manifest_paths(config_dir: &Path, manifests: &[String]) -> Vec<PathBuf> {
335 manifests
336 .iter()
337 .map(|manifest| {
338 let path = config_dir.join(manifest);
339 path.canonicalize().unwrap_or(path)
340 })
341 .collect()
342}
343
344fn clone_config_via_reparse(
347 config_path: &Path,
348 show_override: Option<ShowMode>,
349) -> Result<DocsConfig> {
350 let s = std::fs::read_to_string(config_path)
351 .with_context(|| format!("Cannot read {}", config_path.display()))?;
352 let mut config: DocsConfig = serde_json::from_str(&s)
353 .with_context(|| format!("Invalid JSON in {}", config_path.display()))?;
354 if let Some(show) = show_override {
355 config.show = show;
356 }
357 Ok(config)
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn watcher_requeues_state_changed_after_the_render_read() -> Result<()> {
366 let root = tempfile::tempdir()?;
367 let file = root.path().join("target/state.json");
368 std::fs::create_dir(file.parent().context("state parent")?)?;
369 std::fs::write(&file, r#"{"count":1}"#)?;
370 let mut loader = crate::state::StateLoader::new();
371 loader.load_markdown_state(
372 Some("target/state.json"),
373 None,
374 &root.path().join("page.md"),
375 )?;
376 let dependencies = loader.into_dependencies();
377 std::fs::write(&file, r#"{"count":2}"#)?;
378 let config: DocsConfig = serde_json::from_str(
379 r#"{"site":{"title":"Test"},"basePath":"/","contentDir":"missing","nav":[],"sidebar":[]}"#,
380 )?;
381 let (tx, rx) = std::sync::mpsc::sync_channel(8);
382 let _watcher = source_watcher(
383 &config,
384 root.path(),
385 &root.path().join("config.json"),
386 &dependencies,
387 tx,
388 )?;
389 assert_eq!(
390 rx.recv_timeout(Duration::from_millis(200))?,
391 dependencies.paths
392 );
393 Ok(())
394 }
395
396 #[test]
397 fn watch_paths_dedupes_nested_paths() {
398 let cfg = Path::new("/proj/docs/.webui-press");
399 let config_file = Path::new("/proj/docs/.webui-press/config.json");
400 let content = Path::new("/proj/docs/.webui-press");
401 let public = Path::new("/proj/docs/.webui-press/public");
402 let paths = watch_paths(cfg, config_file, content, public);
403 assert_eq!(paths, vec![PathBuf::from("/proj/docs/.webui-press")]);
406 }
407
408 #[test]
409 fn watch_paths_includes_sibling_content_dir() {
410 let cfg = Path::new("docs/.webui-press");
413 let config_file = Path::new("docs/.webui-press/config.json");
414 let content = Path::new("docs");
415 let public = Path::new("docs/.webui-press/public");
416 let paths = watch_paths(cfg, config_file, content, public);
417 assert_eq!(paths, vec![PathBuf::from("docs")]);
420 }
421
422 #[test]
423 fn watch_paths_includes_external_content_dir() {
424 let cfg = Path::new("/proj/.webui-press");
426 let config_file = Path::new("/proj/.webui-press/config.json");
427 let content = Path::new("/proj/markdown");
428 let public = Path::new("/proj/.webui-press/public");
429 let paths = watch_paths(cfg, config_file, content, public);
430 assert_eq!(
431 paths,
432 vec![
433 PathBuf::from("/proj/.webui-press"),
434 PathBuf::from("/proj/markdown"),
435 ]
436 );
437 }
438
439 #[test]
440 fn projection_manifest_paths_resolve_from_config_directory() {
441 let paths = projection_manifest_paths(
442 Path::new("/proj/docs/.webui-press"),
443 &["../../client/dist/webui-projection.json".to_string()],
444 );
445
446 assert_eq!(
447 paths,
448 [PathBuf::from(
449 "/proj/docs/.webui-press/../../client/dist/webui-projection.json"
450 )]
451 );
452 }
453}