use actix_web::{middleware, web, App, HttpResponse, HttpServer};
#[derive(Clone, Debug)]
struct Page {
dir: String,
cache_busted: Vec<String>,
}
impl Page {
fn stamped_html(&self) -> Option<String> {
let dir = std::path::Path::new(&self.dir);
let mut html = std::fs::read_to_string(dir.join("index.html")).ok()?;
for asset in &self.cache_busted {
let stamp = std::fs::metadata(dir.join(asset))
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
html = html.replace(asset.as_str(), &format!("{asset}?v={stamp}"));
}
Some(html)
}
fn index(&self) -> HttpResponse {
let Some(html) = self.stamped_html() else {
return HttpResponse::NotFound().body("index.html is missing from the served directory");
};
HttpResponse::Ok()
.insert_header(("Cache-Control", "no-cache"))
.content_type("text/html; charset=utf-8")
.body(html)
}
}
pub fn lan_address() -> Option<String> {
let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("8.8.8.8:80").ok()?;
Some(socket.local_addr().ok()?.ip().to_string())
}
pub fn init_logging() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,actix_server=warn,actix_web=warn"));
let _ = tracing_subscriber::fmt().with_env_filter(filter).with_target(false).try_init();
});
}
pub struct Host {
bind: String,
page: Option<Page>,
announce: bool,
ws_path: String,
}
impl Host {
pub fn new(bind: impl Into<String>) -> Self {
Self {
bind: bind.into(),
page: None,
announce: true,
ws_path: "/ws".to_owned(),
}
}
pub fn serve_dir(mut self, dir: Option<String>) -> Self {
self.page = dir.map(|dir| Page { dir, cache_busted: Vec::new() });
self
}
pub fn cache_bust(mut self, asset: impl Into<String>) -> Self {
if let Some(page) = &mut self.page {
page.cache_busted.push(asset.into());
}
self
}
pub fn ws_path(mut self, path: impl Into<String>) -> Self {
self.ws_path = path.into();
self
}
pub fn announce(mut self, announce: bool) -> Self {
self.announce = announce;
self
}
fn print_banner(&self) {
let port = self.bind.rsplit(':').next().unwrap_or("8080");
println!("\n listening on {}", self.bind);
if self.page.is_some() {
println!(" play here: http://127.0.0.1:{port}");
if let Some(ip) = lan_address() {
println!(" others at: http://{ip}:{port}");
}
} else {
println!(" no directory to serve, so there is no page to open.");
println!(" clients can still join at ws://127.0.0.1:{port}{}", self.ws_path);
}
println!();
}
pub async fn run<F>(self, configure: F) -> std::io::Result<()>
where
F: Fn(&mut web::ServiceConfig) + Send + Clone + 'static,
{
if let Some(page) = &self.page {
let path = std::path::Path::new(&page.dir);
if !path.is_dir() {
let cwd = std::env::current_dir().unwrap_or_default();
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{}: not a directory (relative to {cwd:?})", page.dir),
));
}
if !path.join("index.html").is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{}: no index.html in it, so there would be nothing to open", page.dir),
));
}
}
let page = self.page.clone();
let server = HttpServer::new(move || {
let app = App::new()
.wrap(middleware::DefaultHeaders::new().add(("Cache-Control", "no-cache")))
.configure(configure.clone());
match &page {
Some(page) => {
let for_root = page.clone();
let for_index = page.clone();
app
.route("/", web::get().to(move || {
let page = for_root.clone();
async move { page.index() }
}))
.route("/index.html", web::get().to(move || {
let page = for_index.clone();
async move { page.index() }
}))
.service(actix_files::Files::new("/", &page.dir).index_file("index.html"))
}
None => app,
}
})
.disable_signals()
.bind(&self.bind)
.map_err(|e| std::io::Error::new(e.kind(), format!("could not bind {}: {e}. Is something already using that port?", self.bind)))?;
tracing::info!(bind = %self.bind, "listening");
if self.announce {
self.print_banner();
}
server.run().await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_page(dir: &std::path::Path, html: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("index.html"), html).unwrap();
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("plaza_host_{name}"));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn a_cache_busted_asset_is_stamped_with_its_own_modification_time() {
let dir = scratch("stamped");
write_page(&dir, "<script src=\"client.wasm\"></script>");
std::fs::write(dir.join("client.wasm"), b"\0asm").unwrap();
let page = Page { dir: dir.to_string_lossy().into_owned(), cache_busted: vec!["client.wasm".to_owned()] };
let html = page.stamped_html().expect("the index is there");
assert!(html.contains("client.wasm?v="), "the asset URL was not stamped: {html}");
let before = html;
std::thread::sleep(std::time::Duration::from_millis(1100));
std::fs::write(dir.join("client.wasm"), b"\0asm\0").unwrap();
let after = page.stamped_html().unwrap();
assert_ne!(before, after, "rebuilding the bundle must change the URL the page asks for");
assert_eq!(
page.index().headers().get("Cache-Control").unwrap(),
"no-cache",
"the referencing page must revalidate, or it keeps quoting the old stamp"
);
}
#[test]
fn an_unmentioned_asset_is_left_alone() {
let dir = scratch("untouched");
write_page(&dir, "<img src=\"logo.png\">");
let page = Page { dir: dir.to_string_lossy().into_owned(), cache_busted: vec!["client.wasm".to_owned()] };
assert_eq!(page.stamped_html().unwrap(), "<img src=\"logo.png\">");
}
#[test]
fn a_missing_index_is_a_404_rather_than_a_panic() {
let dir = scratch("empty");
std::fs::create_dir_all(&dir).unwrap();
let page = Page { dir: dir.to_string_lossy().into_owned(), cache_busted: Vec::new() };
assert_eq!(page.index().status(), actix_web::http::StatusCode::NOT_FOUND);
}
#[actix_web::test]
async fn a_served_directory_that_does_not_exist_fails_before_binding() {
let err = Host::new("127.0.0.1:0")
.serve_dir(Some("/definitely/not/a/directory".to_owned()))
.announce(false)
.run(|_| {})
.await
.expect_err("a missing directory must be refused");
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
assert!(err.to_string().contains("not a directory"), "{err}");
}
#[actix_web::test]
async fn a_directory_without_an_index_fails_before_binding() {
let dir = scratch("noindex");
std::fs::create_dir_all(&dir).unwrap();
let err = Host::new("127.0.0.1:0")
.serve_dir(Some(dir.to_string_lossy().into_owned()))
.announce(false)
.run(|_| {})
.await
.expect_err("a directory with nothing to open must be refused");
assert!(err.to_string().contains("no index.html"), "{err}");
}
}