//! Module containing code to serve the dev site. use std::{ collections::HashMap, net::SocketAddr, path::{Path, PathBuf}, sync::{Arc, Mutex}, }; use eyre::Context; use futures::SinkExt; use hotwatch::{EventKind, Hotwatch}; use warp::{ hyper::StatusCode, path::FullPath, reply::Response, ws::{Message, WebSocket}, Filter, }; use crate::{ Site, SiteBuilder, SiteConfig, PAGES_PATH, RESOURCES_PATH, ROOT_PATH, SASS_PATH, TEMPLATES_PATH, }; /// Helper to get the "name" of a path. fn get_name(path: &Path) -> (PathBuf, String) { let name = path.with_extension(""); let name_str = name .display() .to_string() .replace(std::path::MAIN_SEPARATOR, "/"); (name, name_str) } fn with_build_path( build_path: PathBuf, ) -> impl Filter + Clone { warp::any().map(move || build_path.clone()) } /// Helper to make a path relative. fn rel(path: &Path, prefix: &Path) -> Result { Ok(path.strip_prefix(prefix)?.to_owned()) } /// Helper to build resources in the case of creation or removal. fn build_resources(builder: &mut SiteBuilder, path: &Path) -> eyre::Result<()> { let paths: Vec<_> = builder .resource_builders .values() .map(|b| { ( b.config.source_path.clone(), path.strip_prefix(&b.config.source_path), ) }) .filter_map(|(p, v)| v.ok().map(|v| (p, v))) .collect(); if paths.len() > 1 { todo!("handle more than one possible match"); } if let Some((prefix, _path)) = paths.first() { // HACK: this could get very inefficient with a larger number of resources. should definitely optimize builder.reload_resource_builder(prefix)?; builder.build_resources(prefix)?; } Ok(()) } /// Creates or updates a resource. fn create( builder: &mut SiteBuilder, path: &Path, relative_path: &Path, build: bool, ) -> eyre::Result<()> { if path.is_dir() { return Ok(()); } println!("{relative_path:?}"); if let Ok(page_path) = relative_path.strip_prefix(PAGES_PATH) { let (_page_name, page_name_str) = get_name(page_path); builder .site .page_index .insert(page_name_str.clone(), path.to_owned()); if build { builder.build_page(&page_name_str)?; } } else if let Ok(_template_path) = relative_path.strip_prefix(TEMPLATES_PATH) { builder.tera.full_reload()?; if build { builder.site.build_all_pages(builder)?; builder.build_all_resources()?; } } else if relative_path.display().to_string() == SiteConfig::FILENAME { let new_config = serde_yml::from_str(&std::fs::read_to_string(path)?)?; builder.site.config = new_config; builder.reload()?; builder.site.build_all_pages(builder)?; } else if let Ok(_sass_path) = relative_path.strip_prefix(SASS_PATH) { if build { builder.build_sass().wrap_err("Failed to rebuild Sass")?; } } else if let Ok(root_path) = relative_path.strip_prefix(ROOT_PATH) { std::fs::copy(path, builder.build_path.join(root_path))?; } else if let Ok(resources_path) = relative_path.strip_prefix(RESOURCES_PATH) { build_resources(builder, resources_path)?; } Ok(()) } /// Removes an existing resource. fn remove(builder: &mut SiteBuilder, path: &Path, relative_path: &Path) -> eyre::Result<()> { if path.is_dir() { return Ok(()); } if let Ok(page_path) = relative_path.strip_prefix(PAGES_PATH) { let (page_name, page_name_str) = get_name(page_path); builder.site.page_index.remove(&page_name_str); std::fs::remove_file(builder.build_path.join(page_name.with_extension("html"))) .with_context(|| format!("Failed to remove page at {:?}", path))?; } else if let Ok(_template_path) = relative_path.strip_prefix(TEMPLATES_PATH) { builder.tera.full_reload()?; builder .site .build_all_pages(builder) .wrap_err("Failed to rebuild pages")?; } else if let Ok(_sass_path) = relative_path.strip_prefix(SASS_PATH) { builder.build_sass().wrap_err("Failed to rebuild Sass")?; } else if let Ok(root_path) = relative_path.strip_prefix(ROOT_PATH) { std::fs::remove_file(builder.build_path.join(root_path))?; } else if let Ok(resources_path) = relative_path.strip_prefix(RESOURCES_PATH) { build_resources(builder, resources_path)?; } Ok(()) } /// Decides whether to skip a path in the watcher. fn skip_path(builder: &SiteBuilder, path: &Path) -> bool { path.strip_prefix(&builder.build_path).is_ok() } impl Site { /// Serves the site for development. Don't use this in production. pub async fn serve(self, addr: &str) -> eyre::Result<()> { let addr: SocketAddr = addr.parse()?; let mut builder = SiteBuilder::new(self, true)?.prepare()?; let site = &builder.site; let build_path = builder.build_path.clone(); // Perform initial build for page_name in site.page_index.keys() { if let Err(e) = builder.build_page(page_name) { eprintln!("Failed to build page {}: {}", page_name, e); } } builder.build_sass().wrap_err("Failed to build Sass")?; builder .build_all_resources() .wrap_err("Failed to build resources")?; // Map of websocket connections let peers: Arc>> = Arc::new(Mutex::new(HashMap::new())); // Watch for changes to the site let mut hotwatch = Hotwatch::new().expect("Hotwatch failed to initialize"); let hw_peers = peers.clone(); hotwatch .watch(site.site_path.clone(), move |event| { let peers = hw_peers.clone(); let r = (|| { let path = event .paths .first() .expect("Should always be at least one path"); match event.kind { EventKind::Modify(_) => { if skip_path(&builder, path) { Ok(false) } else { let relp = rel(path, &builder.site.site_path)?; if event.paths.len() > 1 { let new = event.paths.last().expect("Can never fail"); let new_rel = rel(new, &builder.site.site_path)?; println!("RENAMED - {:?} -> {:?}", relp, new_rel); create(&mut builder, new, &new_rel, false)?; remove(&mut builder, path, &relp)?; } else { println!("CHANGED - {:?}", relp); create(&mut builder, path, &relp, true)?; } Ok::<_, eyre::Error>(true) } } EventKind::Create(_) => { if skip_path(&builder, path) { Ok(false) } else { let rel = rel(path, &builder.site.site_path)?; println!("CREATED - {:?}", rel); create(&mut builder, path, &rel, true)?; Ok(true) } } EventKind::Remove(_) => { if skip_path(&builder, path) { Ok(false) } else { let rel = rel(path, &builder.site.site_path)?; println!("REMOVED - {:?}", rel); remove(&mut builder, path, &rel)?; Ok(true) } } _ => Ok(false), } })(); match r { Ok(reload) => { if reload { // Send reload event to connected websockets let mut peers = peers.lock().unwrap(); let mut to_remove = Vec::new(); for (addr, peer) in peers.iter_mut() { let task = async { peer.send(Message::text("reload".to_string())).await?; Ok::<_, eyre::Error>(()) }; to_remove.push(*addr); if let Err(e) = futures::executor::block_on(task) { eprintln!("{}", e); } } for addr in &to_remove { peers.remove(addr); } } } Err(e) => eprintln!("Failed to update: {:?}", e), } }) .expect("Failed to watch file"); let routes = warp::any() .and(warp::ws()) .and(warp::filters::addr::remote()) .and_then(move |ws: warp::ws::Ws, addr| { let peers = peers.clone(); async move { // Add websocket connection to peers list if let Some(addr) = addr { let peers = peers.clone(); return Ok(ws.on_upgrade(move |websocket| async move { peers.lock().unwrap().insert(addr, websocket); })); } Err(warp::reject()) } }) .or(warp::any().and(warp::get()).and( warp::path::full() .and(with_build_path(build_path.clone())) .and_then(move |path: FullPath, build_path: PathBuf| async move { // Serve static files let p = &path.as_str()[1..]; let p = percent_encoding::percent_decode_str(p) .decode_utf8() .expect("Failed to decode URL"); if p == "_dev.js" { let res = Response::new( include_str!("./embedded/js/refresh_websocket.js").into(), ); return Ok(res); } let mut p = build_path.join(p.as_ref()); if !p.exists() { p = p.with_extension("html"); } if p.is_dir() { p = p.join("index.html"); } if p.exists() { let mut res = Response::new("".into()); match std::fs::read(&p) { Ok(body) => { *res.body_mut() = body.into(); } Err(e) => { eprintln!("{}", e); *res.body_mut() = format!("Failed to load: {}", e).into(); *res.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; } } return Ok(res); } Err(warp::reject()) }), )) .or(warp::any() .and(warp::path::full()) .and_then(move |path: FullPath| { let build_path = build_path.clone(); async move { // Handle missing files println!("404 - {}", path.as_str()); let body = match std::fs::read_to_string(build_path.join("404.html")) { Ok(body) => body, _ => "404 Not Found".to_string(), }; let mut res = Response::new(body.into()); *res.status_mut() = StatusCode::NOT_FOUND; Ok::<_, std::convert::Infallible>(res) } })); println!("Starting server at http://{}", addr); warp::serve(routes).run(addr).await; Ok(()) } }