use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use axum::extract::State;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::routing::get;
use axum::Router;
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::watch;
use tower_http::services::ServeDir;
use crate::model::WdocDocument;
#[derive(Clone)]
struct ServeState {
reload_rx: watch::Receiver<u64>,
}
pub async fn serve(
build_fn: impl Fn() -> Result<WdocDocument, String> + Send + Sync + 'static,
watch_paths: Vec<PathBuf>,
asset_dirs: Vec<PathBuf>,
output_dir: PathBuf,
port: u16,
open_browser: bool,
) -> Result<(), String> {
let build_fn = Arc::new(build_fn);
let asset_dir_refs: Vec<&std::path::Path> = asset_dirs.iter().map(|p| p.as_path()).collect();
let doc = build_fn().map_err(|e| format!("initial build failed: {e}"))?;
crate::render::render_document(&doc, &output_dir, &asset_dir_refs)?;
eprintln!("wdoc: built to {}", output_dir.display());
let (reload_tx, reload_rx) = watch::channel(0u64);
let state = ServeState { reload_rx };
let build_fn_watch = Arc::clone(&build_fn);
let output_dir_watch = output_dir.clone();
let (notify_tx, mut notify_rx) = tokio::sync::mpsc::channel::<()>(1);
let mut watcher: RecommendedWatcher =
notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
if let Ok(event) = res {
use notify::event::{CreateKind, ModifyKind, RemoveKind};
use notify::EventKind;
match event.kind {
EventKind::Create(CreateKind::File)
| EventKind::Modify(ModifyKind::Data(_))
| EventKind::Remove(RemoveKind::File) => {
let _ = notify_tx.blocking_send(());
}
_ => {}
}
}
})
.map_err(|e| format!("failed to create file watcher: {e}"))?;
for path in &watch_paths {
watcher
.watch(path, RecursiveMode::NonRecursive)
.map_err(|e| format!("failed to watch {}: {e}", path.display()))?;
}
let reload_tx = Arc::new(reload_tx);
tokio::spawn(async move {
let mut generation: u64 = 0;
while notify_rx.recv().await.is_some() {
tokio::time::sleep(Duration::from_millis(500)).await;
while notify_rx.try_recv().is_ok() {}
eprintln!("wdoc: rebuilding...");
match build_fn_watch() {
Ok(doc) => {
let arefs: Vec<&std::path::Path> =
asset_dirs.iter().map(|p| p.as_path()).collect();
if let Err(e) = crate::render::render_document(&doc, &output_dir_watch, &arefs)
{
eprintln!("wdoc: render error: {e}");
continue;
}
generation += 1;
let _ = reload_tx.send(generation);
eprintln!("wdoc: rebuilt successfully");
}
Err(e) => eprintln!("wdoc: build error: {e}"),
}
}
drop(watcher);
});
let app = Router::new()
.route("/_wdoc/reload", get(sse_handler))
.fallback_service(ServeDir::new(&output_dir).append_index_html_on_directories(true))
.with_state(state);
let addr = format!("127.0.0.1:{port}");
let listener = tokio::net::TcpListener::bind(&addr)
.await
.map_err(|e| format!("failed to bind to {addr}: {e}"))?;
eprintln!("wdoc: serving at http://{addr}");
if open_browser {
let _ = open_url(&format!("http://{addr}"));
}
axum::serve(listener, app)
.await
.map_err(|e| format!("server error: {e}"))?;
Ok(())
}
async fn sse_handler(
State(state): State<ServeState>,
) -> Sse<impl futures_core::Stream<Item = Result<Event, std::convert::Infallible>>> {
let rx = state.reload_rx;
let stream = async_stream::stream! {
let mut rx = rx;
while rx.changed().await.is_ok() {
let gen = *rx.borrow();
yield Ok(Event::default().data(format!("{gen}")));
}
};
Sse::new(stream).keep_alive(KeepAlive::default())
}
fn open_url(url: &str) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(url)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(url)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("cmd")
.args(["/C", "start", url])
.spawn()
.map_err(|e| e.to_string())?;
}
Ok(())
}