use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer};
use anyhow::{anyhow, Context, Result};
use console::style;
use webui_dev_server::path::normalize_base_path;
use webui_dev_server::shutdown::Control;
use webui_dev_server::{
default_ignore_paths, serve_static_file, spawn_rebuild_worker, spawn_watcher, sse_handler,
LiveReload, NotFoundStrategy, StaticServeConfig, TickSender, WatchConfig, WatcherHandle,
};
use crate::build::{build_docs_with_cache, BuildCache};
use crate::state::StateDependencies;
use crate::types::{DocsConfig, ShowMode};
const DEBOUNCE_DURATION: Duration = Duration::from_millis(50);
const RELOAD_PATH: &str = "/__webui_press/livereload";
pub struct ServeConfig {
pub config: DocsConfig,
pub config_dir: PathBuf,
pub template_dir: PathBuf,
pub config_path: PathBuf,
pub host: String,
pub port: u16,
pub show_override: Option<ShowMode>,
}
pub async fn run_serve(opts: ServeConfig, control: Option<Control>) -> Result<()> {
let ServeConfig {
config,
config_dir,
template_dir,
config_path,
host,
port,
show_override,
} = opts;
let base_path = normalize_base_path(&config.base_path);
let out_dir = PathBuf::from(&config.out_dir);
println!(
"{} {}",
style("⚡").cyan().bold(),
style("WebUI Press dev server").bold()
);
println!();
let initial_cache: BuildCache = {
let cfg = clone_config_via_reparse(&config_path, show_override)?;
let cd = config_dir.clone();
let td = template_dir.clone();
tokio::task::spawn_blocking(move || -> Result<BuildCache> {
let mut cache = BuildCache::new();
build_docs_with_cache(&cfg, &cd, &td, &mut cache)
.map_err(|e| anyhow!("initial build failed: {e}"))?;
cache.set_dev_rebuild();
Ok(cache)
})
.await
.map_err(|e| anyhow!("build task panicked: {e}"))??
};
let livereload = LiveReload::new(RELOAD_PATH);
let cache = Arc::new(Mutex::new(initial_cache));
let watcher = Arc::new(Mutex::new(None::<WatcherHandle>));
let sender = Arc::new(OnceLock::<TickSender>::new());
let worker = {
let config_path = config_path.clone();
let config_dir = config_dir.clone();
let template_dir = template_dir.clone();
let cache = cache.clone();
let watcher = watcher.clone();
let sender = sender.clone();
spawn_rebuild_worker(livereload.clone(), move || {
let cfg = clone_config_via_reparse(&config_path, show_override)
.map_err(|e| format!("config reload failed: {e}"))?;
let mut guard = cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let result = build_docs_with_cache(&cfg, &config_dir, &template_dir, &mut guard);
let tx = sender
.get()
.ok_or_else(|| "Press watcher sender is unavailable".to_string())?;
let next = source_watcher(
&cfg,
&config_dir,
&config_path,
&guard.dependencies,
tx.clone(),
)
.map_err(|error| format!("Cannot refresh Press input watches: {error}"))?;
*watcher
.lock()
.map_err(|error| format!("Press watcher lock failed: {error}"))? = Some(next);
result.map_err(|e| format!("{e}"))?;
Ok(Vec::new())
})
};
let tx = worker.sender();
sender
.set(tx.clone())
.map_err(|_| anyhow!("Press watcher sender was already initialized"))?;
{
let guard = cache
.lock()
.map_err(|error| anyhow!("Press build cache lock failed: {error}"))?;
let initial_watcher =
source_watcher(&config, &config_dir, &config_path, &guard.dependencies, tx)?;
*watcher
.lock()
.map_err(|error| anyhow!("Press watcher lock failed: {error}"))? =
Some(initial_watcher);
}
let static_cfg = StaticServeConfig {
root: out_dir.clone(),
base_path: base_path.clone(),
livereload: livereload.clone(),
not_found: NotFoundStrategy::File(PathBuf::from("404.html")),
};
let static_data = web::Data::new(static_cfg);
let lr_data = web::Data::new(livereload.clone());
let bind = format!("{host}:{port}");
let url = format!("http://{host}:{port}{base_path}");
println!(
" {} {}",
style("➜").green().bold(),
style(&url).cyan().underlined()
);
println!();
let mut server = HttpServer::new(move || {
App::new()
.app_data(static_data.clone())
.app_data(lr_data.clone())
.route(RELOAD_PATH, web::get().to(sse_handler))
.default_service(web::get().to(static_handler))
});
if control.is_some() {
server = server.disable_signals();
}
let server = server
.bind(&bind)
.with_context(|| format!("Cannot bind {bind}"))?
.run();
let server_result: Result<()> = match control {
Some(control) => control.serve(server).await.map_err(Into::into),
None => server.await.map_err(Into::into),
};
let server_result = server_result.context("Dev server failed");
worker.shutdown()?;
*watcher
.lock()
.map_err(|error| anyhow!("Press watcher lock failed: {error}"))? = None;
server_result?;
Ok(())
}
fn source_watcher(
config: &DocsConfig,
config_dir: &Path,
config_path: &Path,
dependencies: &StateDependencies,
tx: TickSender,
) -> Result<WatcherHandle> {
let mut explicit_files = projection_manifest_paths(
config_dir,
config
.bundler
.as_ref()
.map_or(&[][..], |bundler| bundler.projection_manifests.as_slice()),
);
explicit_files.extend_from_slice(&dependencies.paths);
let mut ignore = default_ignore_paths();
ignore.push(PathBuf::from(&config.out_dir));
ignore.push(PathBuf::from(".webui-press/cache"));
let events = tx.clone();
let watcher = spawn_watcher(
WatchConfig {
paths: watch_paths(
config_dir,
config_path,
Path::new(&config.content_dir),
Path::new(&config.public_dir),
),
explicit_files,
ignore,
debounce: DEBOUNCE_DURATION,
retry_unchanged_when: None,
},
move |paths| {
let _ = events.try_send(paths);
},
)?;
let changed = dependencies.changed_paths();
if !changed.is_empty() {
match tx.try_send(changed) {
Ok(()) | Err(std::sync::mpsc::TrySendError::Full(_)) => {}
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
return Err(anyhow!("Press rebuild worker disconnected"));
}
}
}
Ok(watcher)
}
async fn static_handler(req: HttpRequest, cfg: web::Data<StaticServeConfig>) -> HttpResponse {
serve_static_file(&req, cfg.get_ref()).await
}
fn watch_paths(
config_dir: &Path,
config_path: &Path,
content_dir: &Path,
public_dir: &Path,
) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = Vec::with_capacity(4);
let mut push_dedup = |candidate: PathBuf| {
if candidate.as_os_str().is_empty() {
return;
}
let candidate = candidate.canonicalize().unwrap_or(candidate);
if paths.iter().any(|p| candidate.starts_with(p)) {
return;
}
paths.retain(|p| !p.starts_with(&candidate));
paths.push(candidate);
};
push_dedup(config_dir.to_path_buf());
if let Some(parent) = config_path.parent() {
push_dedup(parent.to_path_buf());
}
push_dedup(content_dir.to_path_buf());
push_dedup(public_dir.to_path_buf());
paths
}
fn projection_manifest_paths(config_dir: &Path, manifests: &[String]) -> Vec<PathBuf> {
manifests
.iter()
.map(|manifest| {
let path = config_dir.join(manifest);
path.canonicalize().unwrap_or(path)
})
.collect()
}
fn clone_config_via_reparse(
config_path: &Path,
show_override: Option<ShowMode>,
) -> Result<DocsConfig> {
let s = std::fs::read_to_string(config_path)
.with_context(|| format!("Cannot read {}", config_path.display()))?;
let mut config: DocsConfig = serde_json::from_str(&s)
.with_context(|| format!("Invalid JSON in {}", config_path.display()))?;
if let Some(show) = show_override {
config.show = show;
}
Ok(config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn watcher_requeues_state_changed_after_the_render_read() -> Result<()> {
let root = tempfile::tempdir()?;
let file = root.path().join("target/state.json");
std::fs::create_dir(file.parent().context("state parent")?)?;
std::fs::write(&file, r#"{"count":1}"#)?;
let mut loader = crate::state::StateLoader::new();
loader.load_markdown_state(
Some("target/state.json"),
None,
&root.path().join("page.md"),
)?;
let dependencies = loader.into_dependencies();
std::fs::write(&file, r#"{"count":2}"#)?;
let config: DocsConfig = serde_json::from_str(
r#"{"site":{"title":"Test"},"basePath":"/","contentDir":"missing","nav":[],"sidebar":[]}"#,
)?;
let (tx, rx) = std::sync::mpsc::sync_channel(8);
let _watcher = source_watcher(
&config,
root.path(),
&root.path().join("config.json"),
&dependencies,
tx,
)?;
assert_eq!(
rx.recv_timeout(Duration::from_millis(200))?,
dependencies.paths
);
Ok(())
}
#[test]
fn watch_paths_dedupes_nested_paths() {
let cfg = Path::new("/proj/docs/.webui-press");
let config_file = Path::new("/proj/docs/.webui-press/config.json");
let content = Path::new("/proj/docs/.webui-press");
let public = Path::new("/proj/docs/.webui-press/public");
let paths = watch_paths(cfg, config_file, content, public);
assert_eq!(paths, vec![PathBuf::from("/proj/docs/.webui-press")]);
}
#[test]
fn watch_paths_includes_sibling_content_dir() {
let cfg = Path::new("docs/.webui-press");
let config_file = Path::new("docs/.webui-press/config.json");
let content = Path::new("docs");
let public = Path::new("docs/.webui-press/public");
let paths = watch_paths(cfg, config_file, content, public);
assert_eq!(paths, vec![PathBuf::from("docs")]);
}
#[test]
fn watch_paths_includes_external_content_dir() {
let cfg = Path::new("/proj/.webui-press");
let config_file = Path::new("/proj/.webui-press/config.json");
let content = Path::new("/proj/markdown");
let public = Path::new("/proj/.webui-press/public");
let paths = watch_paths(cfg, config_file, content, public);
assert_eq!(
paths,
vec![
PathBuf::from("/proj/.webui-press"),
PathBuf::from("/proj/markdown"),
]
);
}
#[test]
fn projection_manifest_paths_resolve_from_config_directory() {
let paths = projection_manifest_paths(
Path::new("/proj/docs/.webui-press"),
&["../../client/dist/webui-projection.json".to_string()],
);
assert_eq!(
paths,
[PathBuf::from(
"/proj/docs/.webui-press/../../client/dist/webui-projection.json"
)]
);
}
}