use std::collections::HashMap;
use std::env;
use std::path::{Path, PathBuf};
use hyper::header::{
HeaderValue, ACCEPT_RANGES, CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE, ETAG, IF_NONE_MATCH,
VARY,
};
use hyper::{Response, StatusCode};
use mini_serve::{body, handler, PathSegments, RouteBuilder, ServeError};
struct Entry {
bytes: hyper::body::Bytes,
etag: HeaderValue,
content_type: HeaderValue,
len: usize,
}
fn populate(root: &Path) -> HashMap<String, Entry> {
let mut map = HashMap::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = entry.metadata() else { continue };
if meta.is_dir() {
stack.push(path);
continue;
}
if !meta.is_file() {
continue;
}
let Ok(bytes) = std::fs::read(&path) else { continue };
let Ok(rel) = path.strip_prefix(root) else { continue };
let key = rel.to_string_lossy().into_owned();
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
let etag = HeaderValue::from_str(&format!("\"{}-{modified}\"", bytes.len()))
.unwrap_or(HeaderValue::from_static("\"x\""));
let content_type = HeaderValue::from_static("text/css; charset=utf-8");
let len = bytes.len();
map.insert(
key,
Entry { bytes: hyper::body::Bytes::from(bytes), etag, content_type, len },
);
}
}
map
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let port: u16 = env::var("PORT").unwrap_or_else(|_| "8099".into()).parse()?;
let root = PathBuf::from(env::var("ROOT").unwrap_or_else(|_| "./bench/www".into()));
let cache = std::sync::Arc::new(populate(&root));
eprintln!("cached {} entries", cache.len());
let app = RouteBuilder::stateless()
.with_fallback(handler(move |req, _state| {
let cache = std::sync::Arc::clone(&cache);
async move {
let segments = req
.extensions()
.get::<PathSegments>()
.map(|s| s.0.as_slice())
.unwrap_or(&[]);
for segment in segments {
if segment.contains('/')
|| segment.contains('\\')
|| segment.contains('\0')
|| segment == ".."
{
return Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(body("not found\n".into()))
.unwrap());
}
if segment.starts_with('.') && segment != "." {
return Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(body("not found\n".into()))
.unwrap());
}
}
let key = segments.join("/");
let Some(entry) = cache.get(&key) else {
return Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(body("not found\n".into()))
.unwrap());
};
if req.headers().get(IF_NONE_MATCH).is_some_and(|v| v == entry.etag) {
let mut resp = Response::new(body(hyper::body::Bytes::new()));
*resp.status_mut() = StatusCode::NOT_MODIFIED;
resp.headers_mut().insert(ETAG, entry.etag.clone());
return Ok::<_, ServeError>(resp);
}
let mut resp = Response::new(body(entry.bytes.clone()));
let headers = resp.headers_mut();
headers.insert(CONTENT_TYPE, entry.content_type.clone());
headers.insert(CONTENT_LENGTH, entry.len.into());
headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
headers.insert(VARY, HeaderValue::from_static("Accept-Encoding"));
headers.insert(ETAG, entry.etag.clone());
headers.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
Ok(resp)
}
}))
.seal();
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
app.run(listener, mini_serve::shutdown_signal()?).await?;
Ok(())
}