AkurAI Build
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);
    }
}