use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use super::StaticManifest;
struct IsrRouteState {
in_flight: AtomicBool,
last_attempt: AtomicU64,
}
const REGEN_COOLDOWN_SECS: u64 = 30;
#[derive(Clone)]
pub struct StaticFileLayer {
dist_dir: PathBuf,
manifest: Arc<StaticManifest>,
isr_state: Arc<HashMap<String, IsrRouteState>>,
isr_router: Option<Arc<axum::Router>>,
}
impl StaticFileLayer {
pub fn new(dist_dir: impl Into<PathBuf>) -> Option<Self> {
let dist_dir = dist_dir.into();
let manifest_path = dist_dir.join("manifest.json");
let manifest = StaticManifest::load(&manifest_path).ok()?;
let isr_state = build_isr_state(&manifest);
Some(Self {
dist_dir,
manifest: Arc::new(manifest),
isr_state: Arc::new(isr_state),
isr_router: None,
})
}
#[must_use]
pub fn with_router(mut self, router: axum::Router) -> Self {
self.isr_router = Some(Arc::new(router));
self
}
#[must_use]
pub fn manifest(&self) -> &StaticManifest {
&self.manifest
}
#[must_use]
pub fn dist_dir(&self) -> &Path {
&self.dist_dir
}
#[must_use]
pub fn resolve(&self, request_path: &str) -> Option<PathBuf> {
let entry = self.manifest.routes.get(request_path)?;
let file_path = self.dist_dir.join(&entry.file);
if let Some(revalidate) = entry.revalidate {
self.maybe_trigger_isr(request_path, &file_path, revalidate);
}
Some(file_path)
}
fn maybe_trigger_isr(&self, url_path: &str, file_path: &Path, revalidate_secs: u64) {
let is_stale = file_mtime_age_secs(file_path).is_none_or(|age| age > revalidate_secs);
if !is_stale {
return;
}
let Some(route_state) = self.isr_state.get(url_path) else {
return;
};
let Some(router) = &self.isr_router else {
return;
};
let now = unix_now();
let last = route_state.last_attempt.load(Ordering::Relaxed);
if last > 0 && now.saturating_sub(last) < REGEN_COOLDOWN_SECS {
return;
}
if route_state
.in_flight
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
.is_err()
{
return;
}
route_state.last_attempt.store(now, Ordering::Relaxed);
let router = Arc::clone(router);
let url = url_path.to_owned();
let dest = file_path.to_owned();
let in_flight = Arc::clone(&self.isr_state);
tokio::spawn(async move {
let result = regenerate_page(&router, &url, &dest).await;
if let Some(state) = in_flight.get(&url) {
state.in_flight.store(false, Ordering::Release);
}
match result {
Ok(()) => {
tracing::info!(route = %url, "ISR: page regenerated");
}
Err(e) => {
tracing::warn!(route = %url, error = %e, "ISR: regeneration failed");
}
}
});
}
}
fn build_isr_state(manifest: &StaticManifest) -> HashMap<String, IsrRouteState> {
let mut state = HashMap::new();
for (path, entry) in &manifest.routes {
if entry.revalidate.is_some() {
state.insert(
path.clone(),
IsrRouteState {
in_flight: AtomicBool::new(false),
last_attempt: AtomicU64::new(0),
},
);
}
}
state
}
async fn regenerate_page(
router: &axum::Router,
url: &str,
dest: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
let response = router
.clone()
.oneshot(
Request::builder()
.uri(url)
.body(Body::empty())
.expect("valid request"),
)
.await
.expect("router infallible");
if !response.status().is_success() {
return Err(format!("Handler returned HTTP {} for {}", response.status(), url).into());
}
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
let temp_path = dest.with_extension("tmp");
std::fs::write(&temp_path, &body_bytes)?;
std::fs::rename(&temp_path, dest)?;
Ok(())
}
fn file_mtime_age_secs(path: &Path) -> Option<u64> {
let metadata = std::fs::metadata(path).ok()?;
let mtime = metadata.modified().ok()?;
let elapsed = SystemTime::now().duration_since(mtime).ok()?;
Some(elapsed.as_secs())
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::static_gen::{ManifestEntry, StaticManifest};
use std::collections::HashMap;
fn create_test_dist() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let dist = dir.path().join("dist");
std::fs::create_dir_all(dist.join("about")).expect("mkdir about");
std::fs::write(dist.join("index.html"), "<h1>Home</h1>").expect("write index");
std::fs::write(dist.join("about/index.html"), "<h1>About</h1>").expect("write about");
let mut routes = HashMap::new();
routes.insert(
"/".to_owned(),
ManifestEntry {
file: "index.html".to_owned(),
revalidate: None,
},
);
routes.insert(
"/about".to_owned(),
ManifestEntry {
file: "about/index.html".to_owned(),
revalidate: Some(3600),
},
);
let manifest = StaticManifest {
generated_at: "2026-03-27T12:00:00Z".to_owned(),
autumn_version: "0.3.0".to_owned(),
routes,
};
let json = serde_json::to_string(&manifest).expect("serialize manifest");
std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
dir
}
fn create_parameterized_dist() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let dist = dir.path().join("dist");
std::fs::create_dir_all(dist.join("posts/hello")).expect("mkdir posts/hello");
std::fs::create_dir_all(dist.join("posts/world")).expect("mkdir posts/world");
std::fs::write(dist.join("posts/hello/index.html"), "<h1>Hello</h1>").expect("write hello");
std::fs::write(dist.join("posts/world/index.html"), "<h1>World</h1>").expect("write world");
let mut routes = HashMap::new();
routes.insert(
"/posts/hello".to_owned(),
ManifestEntry {
file: "posts/hello/index.html".to_owned(),
revalidate: None,
},
);
routes.insert(
"/posts/world".to_owned(),
ManifestEntry {
file: "posts/world/index.html".to_owned(),
revalidate: None,
},
);
let manifest = StaticManifest {
generated_at: "2026-03-29T12:00:00Z".to_owned(),
autumn_version: "0.3.0".to_owned(),
routes,
};
let json = serde_json::to_string(&manifest).expect("serialize manifest");
std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
dir
}
fn create_isr_dist(revalidate: u64) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let dist = dir.path().join("dist");
std::fs::create_dir_all(dist.join("about")).expect("mkdir about");
std::fs::write(dist.join("about/index.html"), "<h1>About (stale)</h1>")
.expect("write about");
let mut routes = HashMap::new();
routes.insert(
"/about".to_owned(),
ManifestEntry {
file: "about/index.html".to_owned(),
revalidate: Some(revalidate),
},
);
let manifest = StaticManifest {
generated_at: "2026-03-29T12:00:00Z".to_owned(),
autumn_version: "0.3.0".to_owned(),
routes,
};
let json = serde_json::to_string(&manifest).expect("serialize manifest");
std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
dir
}
#[test]
fn layer_loads_from_valid_dist() {
let tmp = create_test_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist);
assert!(layer.is_some(), "should load from valid dist dir");
}
#[test]
fn layer_returns_none_without_manifest() {
let tmp = tempfile::tempdir().expect("tempdir");
let layer = StaticFileLayer::new(tmp.path());
assert!(layer.is_none(), "should return None without manifest.json");
}
#[test]
fn resolve_finds_known_route() {
let tmp = create_test_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist).expect("layer");
let resolved = layer.resolve("/about");
assert!(resolved.is_some(), "/about should resolve");
assert!(
resolved.unwrap().ends_with("about/index.html"),
"should point to about/index.html"
);
}
#[test]
fn resolve_finds_root() {
let tmp = create_test_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist).expect("layer");
let resolved = layer.resolve("/");
assert!(resolved.is_some(), "/ should resolve");
assert!(
resolved.unwrap().ends_with("index.html"),
"should point to index.html"
);
}
#[test]
fn resolve_returns_none_for_unknown_route() {
let tmp = create_test_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist).expect("layer");
let resolved = layer.resolve("/admin");
assert!(resolved.is_none(), "/admin should not resolve");
}
#[test]
fn manifest_accessor() {
let tmp = create_test_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist).expect("layer");
assert_eq!(layer.manifest().routes.len(), 2);
}
#[test]
fn resolve_finds_parameterized_routes() {
let tmp = create_parameterized_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist).expect("layer");
let hello = layer.resolve("/posts/hello");
assert!(hello.is_some(), "/posts/hello should resolve");
assert!(hello.unwrap().ends_with("posts/hello/index.html"));
let world = layer.resolve("/posts/world");
assert!(world.is_some(), "/posts/world should resolve");
assert!(world.unwrap().ends_with("posts/world/index.html"));
}
#[test]
fn resolve_returns_none_for_non_generated_param() {
let tmp = create_parameterized_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist).expect("layer");
let resolved = layer.resolve("/posts/unknown");
assert!(
resolved.is_none(),
"/posts/unknown should not resolve (not pre-rendered)"
);
}
#[test]
fn isr_state_built_for_revalidate_routes() {
let tmp = create_test_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist).expect("layer");
assert!(layer.isr_state.contains_key("/about"));
assert!(!layer.isr_state.contains_key("/"));
}
#[test]
fn file_mtime_age_fresh_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let file = tmp.path().join("test.html");
std::fs::write(&file, "test").expect("write");
let age = file_mtime_age_secs(&file).expect("mtime");
assert!(age < 5, "Fresh file should be < 5 seconds old, got {age}");
}
#[test]
fn file_mtime_age_missing_file() {
let age = file_mtime_age_secs(Path::new("/nonexistent/file.html"));
assert!(age.is_none(), "Missing file should return None");
}
#[tokio::test]
async fn isr_triggers_regeneration_for_stale_page() {
let tmp = create_isr_dist(1);
let dist = tmp.path().join("dist");
let file = dist.join("about/index.html");
let old_time = std::time::SystemTime::now() - std::time::Duration::from_secs(100);
filetime::set_file_mtime(&file, filetime::FileTime::from_system_time(old_time))
.unwrap_or(());
let router =
axum::Router::new().fallback(axum::routing::get(|| async { "<h1>About (fresh)</h1>" }));
let layer = StaticFileLayer::new(&dist)
.expect("layer")
.with_router(router);
let resolved = layer.resolve("/about");
assert!(resolved.is_some());
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let content = std::fs::read_to_string(&file).unwrap();
assert!(
content == "<h1>About (fresh)</h1>" || content == "<h1>About (stale)</h1>",
"unexpected content: {content}"
);
}
#[tokio::test]
async fn isr_does_not_retrigger_while_in_flight() {
let tmp = create_isr_dist(1);
let dist = tmp.path().join("dist");
let router = axum::Router::new().fallback(axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
"<h1>Slow</h1>"
}));
let layer = StaticFileLayer::new(&dist)
.expect("layer")
.with_router(router);
let file = dist.join("about/index.html");
let old_time = std::time::SystemTime::now() - std::time::Duration::from_secs(100);
let _ = filetime::set_file_mtime(&file, filetime::FileTime::from_system_time(old_time));
let _ = layer.resolve("/about");
let state = layer.isr_state.get("/about").expect("isr state");
let _ = layer.resolve("/about");
tokio::time::sleep(std::time::Duration::from_millis(700)).await;
assert!(
!state.in_flight.load(Ordering::Relaxed),
"in_flight should be cleared after regeneration"
);
}
#[tokio::test]
async fn regenerate_page_writes_atomically() {
let tmp = tempfile::tempdir().expect("tempdir");
let dest = tmp.path().join("page.html");
std::fs::write(&dest, "old content").expect("write old");
let router = axum::Router::new().fallback(axum::routing::get(|| async { "new content" }));
let result = regenerate_page(&router, "/test", &dest).await;
assert!(result.is_ok(), "regeneration failed: {:?}", result.err());
let content = std::fs::read_to_string(&dest).unwrap();
assert_eq!(content, "new content");
assert!(!dest.with_extension("tmp").exists());
}
#[tokio::test]
async fn regenerate_page_fails_on_non_2xx() {
let tmp = tempfile::tempdir().expect("tempdir");
let dest = tmp.path().join("page.html");
std::fs::write(&dest, "old content").expect("write old");
let router = axum::Router::new()
.fallback(|| async { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "error") });
let result = regenerate_page(&router, "/test", &dest).await;
assert!(result.is_err());
let content = std::fs::read_to_string(&dest).unwrap();
assert_eq!(content, "old content");
}
}