Menu
Public repository / master
AkurAI-Framework / crates/cli/src/ssg.rs
Latest change e342c90be739365f3071b9ba372896531f98f412 - feat(cli): static generation (SSG) + incremental static regeneration (ISR) by Ólafur Búi Ólafsson
//! Static Generation (SSG) + Incremental Static Regeneration (ISR).
//!
//! Opt-in, per route, via two `backend/routes.json` fields:
//! - `"prerender": true` — pure SSG. The page is rendered once, cached, and the
//! cached HTML is served thereafter (until the binary regenerates it: a
//! `serve`/`dev` startup re-warm rebuilds every prerender route).
//! - `"revalidate": <seconds>` — ISR. The cached HTML is served immediately;
//! once it is older than the TTL, the next request still gets the (stale)
//! cached copy *and* triggers a background regeneration (stale-while-
//! revalidate). Subsequent requests pick up the fresh copy.
//!
//! A route may set both: it is warmed eagerly at startup (prerender) and kept
//! current by the TTL (revalidate). A route with neither field is untouched —
//! today's dynamic SSR, rendered fresh on every request.
//!
//! Generated HTML is persisted in a dedicated [`akurai_storage::BTree`]
//! (`data/ssg.db`) keyed by the route path, so the cache survives restarts. Each
//! entry stores its generated-at timestamp (seconds since the Unix epoch)
//! alongside the HTML, big-endian framed so the body is binary-safe and needs no
//! escaping.
use akurai_storage::BTree;
use std::collections::HashSet;
use std::io;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
/// A durable, clonable handle to the SSG HTML cache (an `Arc`, cheap to share
/// across request threads).
#[derive(Clone)]
pub struct SsgCache {
tree: Arc<Mutex<BTree>>,
/// Route paths with a regeneration in flight, so duplicate stale requests
/// coalesce into a single background re-render instead of a thundering herd.
inflight: Arc<Mutex<HashSet<String>>>,
}
/// A cached page: the rendered HTML bytes plus the moment it was generated.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
/// Seconds since the Unix epoch when this HTML was rendered.
pub generated_at: u64,
/// The rendered page bytes, served verbatim.
pub html: Vec<u8>,
}
/// What to do with a request for an SSG/ISR route, decided purely from the cache
/// state, the current time, and the route's `revalidate` TTL.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
/// No cache entry: render synchronously now, store it, serve it.
Render,
/// A usable, fresh entry: serve the cached bytes as-is.
Serve,
/// A cache entry past its TTL: serve the stale bytes now and regenerate in
/// the background (stale-while-revalidate).
ServeStaleRevalidate,
}
/// Decide how to serve an SSG/ISR route.
///
/// - No entry → [`Decision::Render`].
/// - Entry, no `revalidate` (pure SSG) → [`Decision::Serve`] (cached forever,
/// until a restart re-warm overwrites it).
/// - Entry with `revalidate` TTL → fresh while `age < ttl` ([`Decision::Serve`]),
/// otherwise [`Decision::ServeStaleRevalidate`].
pub fn decide(generated_at: Option<u64>, now: u64, revalidate: Option<u64>) -> Decision {
match generated_at {
None => Decision::Render,
Some(at) => match revalidate {
None => Decision::Serve,
Some(ttl) => {
if now.saturating_sub(at) >= ttl {
Decision::ServeStaleRevalidate
} else {
Decision::Serve
}
}
},
}
}
/// Current wall-clock time in whole seconds since the Unix epoch (0 if the clock
/// is before the epoch, which never happens in practice).
pub fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Frame an entry as `[8-byte big-endian generated_at][html bytes]`.
fn encode(generated_at: u64, html: &[u8]) -> Vec<u8> {
let mut buf = Vec::with_capacity(8 + html.len());
buf.extend_from_slice(&generated_at.to_be_bytes());
buf.extend_from_slice(html);
buf
}
/// Decode a stored frame back into an [`Entry`], or `None` if it is too short to
/// hold a timestamp (a corrupt/legacy value is treated as a cache miss).
fn decode(bytes: &[u8]) -> Option<Entry> {
if bytes.len() < 8 {
return None;
}
let mut ts = [0u8; 8];
ts.copy_from_slice(&bytes[..8]);
Some(Entry {
generated_at: u64::from_be_bytes(ts),
html: bytes[8..].to_vec(),
})
}
impl SsgCache {
/// Open (or create) the cache file at `path`.
pub fn open(path: impl AsRef<Path>) -> io::Result<SsgCache> {
let tree = BTree::open(path)?;
Ok(SsgCache {
tree: Arc::new(Mutex::new(tree)),
inflight: Arc::new(Mutex::new(HashSet::new())),
})
}
/// The cached entry for `path`, or `None` if absent/corrupt.
pub fn get(&self, path: &str) -> io::Result<Option<Entry>> {
let mut tree = self.tree.lock().unwrap();
Ok(tree.get(path.as_bytes())?.as_deref().and_then(decode))
}
/// Store `html` for `path`, stamped `generated_at`. Durable on return.
pub fn put(&self, path: &str, html: &[u8], generated_at: u64) -> io::Result<()> {
let mut tree = self.tree.lock().unwrap();
tree.insert(path.as_bytes(), &encode(generated_at, html))?;
tree.commit()
}
/// Drop every cached page. Used to rebuild the cache from scratch.
pub fn clear(&self) -> io::Result<()> {
let mut tree = self.tree.lock().unwrap();
// Route keys are ASCII paths starting with `/`; a single 0xFF byte sorts
// after any of them, so `[start, end)` covers the whole keyspace.
let keys: Vec<Vec<u8>> = tree
.range(&[], &[0xff])?
.into_iter()
.map(|(k, _)| k)
.collect();
for key in keys {
tree.delete(&key)?;
}
tree.commit()
}
/// Spawn a detached background regeneration for `path` unless one is already
/// running. `render` produces the fresh HTML (`None` = render failed, leave
/// the stale copy in place). On success the new bytes are stored with a fresh
/// timestamp. Concurrent stale requests for the same path coalesce into one
/// re-render.
pub fn regenerate_in_background<F>(&self, path: &str, render: F)
where
F: FnOnce() -> Option<Vec<u8>> + Send + 'static,
{
// Claim the in-flight slot; bail if another thread already holds it.
if !self.inflight.lock().unwrap().insert(path.to_string()) {
return;
}
let cache = self.clone();
let path = path.to_string();
std::thread::spawn(move || {
if let Some(html) = render() {
let _ = cache.put(&path, &html, now_secs());
}
cache.inflight.lock().unwrap().remove(&path);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
/// Scratch base: `CARGO_TARGET_TMPDIR` when present, else this crate's own
/// `target/` — never `/tmp`.
fn scratch(name: &str) -> PathBuf {
let base = match std::env::var_os("CARGO_TARGET_TMPDIR") {
Some(dir) => PathBuf::from(dir),
None => PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target"),
};
let dir = base.join("ssg-test");
std::fs::create_dir_all(&dir).unwrap();
dir.join(format!("{name}-{}.db", std::process::id()))
}
#[test]
fn decide_render_when_absent() {
assert_eq!(decide(None, 100, None), Decision::Render);
assert_eq!(decide(None, 100, Some(30)), Decision::Render);
}
#[test]
fn decide_pure_ssg_serves_forever() {
// No TTL → always fresh once generated, regardless of age.
assert_eq!(decide(Some(0), 0, None), Decision::Serve);
assert_eq!(decide(Some(0), 1_000_000, None), Decision::Serve);
}
#[test]
fn decide_isr_fresh_then_stale() {
// age < ttl → serve; age >= ttl → serve stale + revalidate.
assert_eq!(decide(Some(100), 120, Some(30)), Decision::Serve);
assert_eq!(decide(Some(100), 129, Some(30)), Decision::Serve);
assert_eq!(
decide(Some(100), 130, Some(30)),
Decision::ServeStaleRevalidate
);
assert_eq!(
decide(Some(100), 200, Some(30)),
Decision::ServeStaleRevalidate
);
}
#[test]
fn decide_isr_zero_ttl_is_always_stale() {
// revalidate: 0 → every entry is immediately stale (regenerate each time).
assert_eq!(
decide(Some(100), 100, Some(0)),
Decision::ServeStaleRevalidate
);
}
#[test]
fn put_get_round_trip() {
let path = scratch("round-trip");
let _ = std::fs::remove_file(&path);
let cache = SsgCache::open(&path).unwrap();
assert_eq!(cache.get("/").unwrap(), None);
cache.put("/", b"<h1>hi</h1>", 42).unwrap();
let entry = cache.get("/").unwrap().unwrap();
assert_eq!(entry.generated_at, 42);
assert_eq!(entry.html, b"<h1>hi</h1>");
let _ = std::fs::remove_file(&path);
}
#[test]
fn cache_persists_across_reopen() {
let path = scratch("persist");
let _ = std::fs::remove_file(&path);
{
let cache = SsgCache::open(&path).unwrap();
cache.put("/about", b"<p>about</p>", 7).unwrap();
}
// Reopen: the cached page and its timestamp survive.
let cache = SsgCache::open(&path).unwrap();
let entry = cache.get("/about").unwrap().unwrap();
assert_eq!(entry.generated_at, 7);
assert_eq!(entry.html, b"<p>about</p>");
let _ = std::fs::remove_file(&path);
}
#[test]
fn clear_empties_the_cache() {
let path = scratch("clear");
let _ = std::fs::remove_file(&path);
let cache = SsgCache::open(&path).unwrap();
cache.put("/", b"a", 1).unwrap();
cache.put("/b", b"b", 1).unwrap();
cache.clear().unwrap();
assert_eq!(cache.get("/").unwrap(), None);
assert_eq!(cache.get("/b").unwrap(), None);
let _ = std::fs::remove_file(&path);
}
#[test]
fn background_regeneration_updates_entry() {
let path = scratch("regen");
let _ = std::fs::remove_file(&path);
let cache = SsgCache::open(&path).unwrap();
cache.put("/", b"old", 1).unwrap();
cache.regenerate_in_background("/", || Some(b"new".to_vec()));
// Poll briefly for the detached thread to land the new bytes.
let mut latest = cache.get("/").unwrap().unwrap();
for _ in 0..200 {
if latest.html == b"new" {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
latest = cache.get("/").unwrap().unwrap();
}
assert_eq!(latest.html, b"new");
let _ = std::fs::remove_file(&path);
}
}