AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/cli/src/cmd_serve.rs

Latest change c8ddf7a50299ad8b8665eeb3266b5bc9947bbe2b - Live collection changes over SSE: GET /api/collections/events by Ólafur Búi Ólafsson

//! `akurai serve` / `akurai dev` — serve a project's frontend with a lean
//! built-in HTTP server.
//!
//! Client-first: static assets are served directly; the server only adds a few
//! JSON endpoints (`/api/health`, `/api/_meta`). Unknown non-API routes fall
//! back to `index.html` so client-side routing works (SPA fallback).

use crate::asset_pipeline;
use crate::auth;
use crate::collections_api::CollectionsApi;
use crate::css_build;
use crate::dev_reload;
use crate::forms;
use crate::guestbook;
use crate::i18n::I18nState;
use crate::mime;
use crate::routes;
use crate::seo;
use crate::ssg;
use akurai_http::{
    sse, Method, MiddlewareStack, Reply, Request, Response, SecurityHeaders, SecurityPolicy,
    Server, SseSink, Timing,
};
use akurai_json::Value;
use akurai_llm::ChatEvent;
use akurai_template::Engine;
use akurai_ws::{Message, WsConn};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;

const VERSION: &str = env!("CARGO_PKG_VERSION");

/// Cache header for HTML and plain static assets that may change on deployment.
/// These resources should never be cached by browsers; revalidate on every request.
const NO_CACHE: &str = "no-cache";

/// Add a `Cache-Control: no-cache` header to a response.
fn with_no_cache(resp: Response) -> Response {
    resp.with_header("Cache-Control", NO_CACHE)
}

/// Read `[security]` from `akurai.toml` (project dir = parent of the frontend
/// dir, else cwd). `None` when the section is absent (legacy headers apply).
fn load_security_policy(frontend: &std::path::Path) -> Option<SecurityPolicy> {
    let candidates = [
        frontend.parent().map(|p| p.join("akurai.toml")),
        Some(PathBuf::from("akurai.toml")),
    ];
    let text = candidates
        .into_iter()
        .flatten()
        .find_map(|p| std::fs::read_to_string(p).ok())?;
    let doc = akurai_toml::parse(&text).ok()?;
    let sec = doc.get("security")?;
    let mut p = SecurityPolicy::default();
    // A string sets the header; an empty string disables it.
    let opt = |key: &str, cur: Option<String>| match sec.get(key).and_then(|v| v.as_str()) {
        Some("") => None,
        Some(v) => Some(v.to_string()),
        None => cur,
    };
    p.csp = opt("csp", p.csp);
    p.hsts = opt("hsts", p.hsts);
    p.referrer_policy = opt("referrer_policy", p.referrer_policy);
    p.permissions_policy = opt("permissions_policy", p.permissions_policy);
    if let Some(v) = sec.get("frame_ancestors").and_then(|v| v.as_str()) {
        p.frame_ancestors = v.to_string();
    }
    if let Some(v) = sec.get("nosniff").and_then(|v| v.as_bool()) {
        p.nosniff = v;
    }
    Some(p)
}

pub struct Config {
    pub host: String,
    pub port: u16,
    pub dir: PathBuf,
    /// App name for health endpoint (`"app"` field).
    pub name: String,
    /// `akurai dev` sets this; it turns on file-watching live-reload. `serve`
    /// leaves it false and the dev machinery stays completely dormant.
    pub dev: bool,
}

/// The CSS-engine + asset-pipeline state threaded through dispatch.
///
/// In `serve` the utility stylesheet is rendered once and cached, and the
/// fingerprint manifest is built once at startup. In `dev` the utilities are
/// regenerated fresh per request (so edits show up on reload) and the manifest
/// is empty (no far-future caching while iterating).
struct AssetState {
    /// Pre-rendered `utilities.css` for production; `None` in dev → regenerate.
    utilities: Option<String>,
    /// Fingerprint + minify manifest; empty in dev.
    manifest: asset_pipeline::Manifest,
}

impl AssetState {
    /// Build the state for a project rooted at `root`. Dev keeps assets fresh and
    /// un-hashed; production renders + fingerprints once.
    fn build(root: &Path, dev: bool) -> AssetState {
        if dev {
            return AssetState {
                utilities: None,
                manifest: asset_pipeline::Manifest::empty(),
            };
        }
        let utilities = css_build::utilities_css(root);
        // Fold the generated utilities.css into the manifest too, so it can be
        // referenced by a fingerprinted, immutably-cached URL like any asset.
        let manifest = asset_pipeline::Manifest::build(
            root,
            &[("/utilities.css".to_string(), utilities.clone().into_bytes())],
        );
        AssetState {
            utilities: Some(utilities),
            manifest,
        }
    }

    /// The current `utilities.css` body: the cached copy in production, or a
    /// freshly regenerated scan in dev.
    fn utilities_css(&self, root: &Path) -> String {
        match &self.utilities {
            Some(css) => css.clone(),
            None => css_build::utilities_css(root),
        }
    }

    /// Serve a static asset. A request for a known fingerprinted path is served
    /// from memory with the immutable cache header; anything else falls back to
    /// the plain on-disk file (backward compatible).
    fn serve_static(&self, root: &Path, req_path: &str) -> Response {
        if let Some(asset) = self.manifest.hashed(req_path) {
            return Response::ok()
                .with_body(asset.content_type, asset.bytes.to_vec())
                .with_header("Cache-Control", asset_pipeline::IMMUTABLE_CACHE);
        }
        serve_static(root, req_path)
    }
}

pub fn run(cfg: Config) -> io::Result<()> {
    if !cfg.dir.is_dir() {
        return Err(io::Error::new(
            io::ErrorKind::NotFound,
            format!("frontend directory not found: {}", cfg.dir.display()),
        ));
    }

    let addr = format!("{}:{}", cfg.host, cfg.port);
    let server = Server::bind(&addr)?;
    let local = server.local_addr()?;
    let root = cfg.dir.clone();

    // Dev mode only: watch the frontend, content, and backend dirs and push a
    // reload to connected browsers on any change. `None` in `serve` mode.
    let live = if cfg.dev {
        let mut dirs = vec![root.clone()];
        if let Some(parent) = root.parent() {
            dirs.push(parent.join("content"));
            dirs.push(parent.join("backend"));
        }
        Some(dev_reload::start(dirs))
    } else {
        None
    };

    // Durable record store for the guestbook form-actions demo, under the
    // project's `data/` dir (a sibling of `frontend/`). Created if absent.
    let data_dir = root
        .parent()
        .map(|p| p.join("data"))
        .unwrap_or_else(|| root.join("data"));
    fs::create_dir_all(&data_dir)?;
    let guestbook = guestbook::State::open(data_dir.join("store.db"))
        .map_err(|e| io::Error::other(format!("cannot open guestbook store: {e}")))?;

    // Declarative `[[forms]]` from akurai.toml (validate, store, notify, PRG).
    let forms_state = forms::FormsState::load(root.parent().unwrap_or(&root), &data_dir)
        .map_err(|msg| io::Error::new(io::ErrorKind::InvalidData, msg))?;

    // Cookie-based session auth: durable session + user stores under `data/`,
    // with the demo user seeded so the login flow works out of the box.
    let auth = auth::State::open(&data_dir)?;

    // Auto-generated REST API, driven by `backend/collections.toml`. Absent file
    // → zero collections and the server behaves exactly as before. A malformed
    // schema fails `serve` loudly here rather than silently dropping routes.
    let project_root = root.parent().map(Path::to_path_buf).unwrap_or(root.clone());
    let collections_api = CollectionsApi::open(&project_root).map_err(|msg| {
        io::Error::new(
            io::ErrorKind::InvalidData,
            format!("collections.toml: {msg}"),
        )
    })?;

    // CSS engine + asset pipeline. Production renders utilities.css and builds
    // the fingerprint manifest once; dev regenerates utilities per request and
    // skips fingerprinting. Either way `/utilities.css` is available to link.
    let assets = AssetState::build(&root, cfg.dev);

    // Static-generation cache (SSG/ISR). Persisted under `data/ssg.db` so it
    // survives restarts. On startup every `prerender` route is (re)rendered and
    // stored — that's the rebuild/invalidation trigger: restart to refresh.
    let ssg_cache = ssg::SsgCache::open(data_dir.join("ssg.db"))?;

    // i18n: load locale catalogs from backend/locales/ if the directory exists.
    // Absent directory → None, and all render paths behave exactly as before.
    let locales_dir = project_root.join("backend/locales");
    let i18n = I18nState::load(&locales_dir);
    let warmed = warm_prerender(&root, &ssg_cache, i18n.as_ref());
    if warmed > 0 {
        println!("  SSG: {warmed} prerendered route(s) warmed");
    }

    println!("AkurAI-Framework v{VERSION}");
    println!("  serving {}", root.display());
    println!("  → http://{local}");
    println!("  utilities.css: served at /utilities.css");
    println!(
        "  themes.css: {} named themes at /themes.css (registry at /themes.json)",
        akurai_css::theme::registry().len()
    );
    if !cfg.dev {
        println!(
            "  asset pipeline: {} fingerprinted asset(s), manifest at /api/_assets",
            assets.manifest.len()
        );
    }
    if live.is_some() {
        println!("  live-reload: on (watching for changes)");
    }
    if let Some(f) = &forms_state {
        println!(
            "  forms: {} declarative form(s) from akurai.toml",
            f.count()
        );
    }
    if i18n.is_some() {
        println!("  i18n: locale catalogs loaded from backend/locales/");
    }

    // Every response passes out through these (outermost first): security
    // headers stamp even short-circuited replies, then timing. Request logging
    // is built into the server itself (access log, on by default).
    let middleware = match load_security_policy(&root) {
        Some(policy) => MiddlewareStack::new().push(policy).push(Timing),
        None => MiddlewareStack::new().push(SecurityHeaders).push(Timing),
    };

    let app_name: String = cfg.name;
    server.run_with(middleware, move |req: &Request| match &live {
        // Dev: serve the reload stream and inject the client script into HTML.
        Some(lr) if req.path == "/_dev/reload" => dev_reload::endpoint(lr),
        Some(_) => inject_livereload(dispatch(
            &root,
            &guestbook,
            forms_state.as_ref(),
            &auth,
            &collections_api,
            &assets,
            &ssg_cache,
            i18n.as_ref(),
            &app_name,
            req,
        )),
        // Production: untouched.
        None => dispatch(
            &root,
            &guestbook,
            forms_state.as_ref(),
            &auth,
            &collections_api,
            &assets,
            &ssg_cache,
            i18n.as_ref(),
            &app_name,
            req,
        ),
    })
}

/// In dev, splice the live-reload `<script>` into HTML responses; leave every
/// other response (and all upgrades) exactly as-is.
fn inject_livereload(reply: Reply) -> Reply {
    let Reply::Response(resp) = reply else {
        return reply; // SSE/WS upgrades are never HTML
    };
    let is_html = resp
        .headers
        .iter()
        .any(|(k, v)| k.eq_ignore_ascii_case("content-type") && v.contains("text/html"));
    if !is_html {
        return Reply::Response(resp);
    }
    match String::from_utf8(resp.body.clone()) {
        Ok(html) => Reply::Response(resp.with_body(
            "text/html; charset=utf-8",
            dev_reload::inject(&html).into_bytes(),
        )),
        Err(_) => Reply::Response(resp),
    }
}

/// Top-level dispatch. Realtime endpoints upgrade the connection and so return
/// a [`Reply`]; everything else is an ordinary buffered [`Response`].
#[allow(clippy::too_many_arguments)]
fn dispatch(
    root: &Path,
    guestbook: &guestbook::State,
    forms: Option<&forms::FormsState>,
    auth: &auth::State,
    collections_api: &CollectionsApi,
    assets: &AssetState,
    ssg_cache: &ssg::SsgCache,
    i18n: Option<&I18nState>,
    app_name: &str,
    req: &Request,
) -> Reply {
    if let Some(reply) = forms.and_then(|f| f.handle(root, i18n, req)) {
        return reply;
    }

    // Live record changes as Server-Sent Events: `event: create|update|delete`,
    // `data: {"collection","record"}`. `?collection=a,b` narrows the stream.
    if req.path == "/api/collections/events" && req.method == Method::Get {
        let events = collections_api.subscribe();
        let query = req.query.clone();
        return akurai_http::sse(move |mut sink| loop {
            match events.recv_timeout(Duration::from_secs(15)) {
                Ok((action, data)) => {
                    let name = akurai_json::parse(&data)
                        .ok()
                        .and_then(|v| {
                            v.get("collection")
                                .and_then(Value::as_str)
                                .map(String::from)
                        })
                        .unwrap_or_default();
                    if crate::collections_api::wants_collection(query.as_deref(), &name) {
                        sink.event(&action, &data)?;
                    }
                }
                // A heartbeat keeps proxies from closing an idle stream and
                // surfaces a gone client as a write error.
                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => sink.comment("ping")?,
                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Ok(()),
            }
        });
    }

    // Auto-API: `/api/collections...` is handled here, before the generic
    // 404-for-`/api/*` arm in `route`. A non-collections shape returns `None`
    // and falls through to the normal routing below.
    if req.path == "/api/collections" || req.path.starts_with("/api/collections/") {
        let rest = req.path.trim_start_matches("/api/collections");
        let segments: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
        // File download: GET /<name>/records/<id>/<field> serves the stored blob
        // as raw bytes (not JSON), so it bypasses the tuple-returning dispatch.
        if let [name, "records", id, field] = segments.as_slice() {
            if req.method == Method::Get {
                return Reply::Response(collections_api.download(name, id, field));
            }
        }
        // Pass the Content-Type (to detect multipart uploads) and the raw body
        // bytes (binary-safe — a multipart payload is not valid UTF-8).
        let content_type = req.header("Content-Type");
        if let Some((status, value)) = collections_api.dispatch(
            &req.method,
            &segments,
            req.query.as_deref(),
            content_type,
            &req.body,
        ) {
            return Reply::Response(super::json_api::response(status, &value));
        }
    }

    match req.path.as_str() {
        // A WebSocket echo: send a text/binary frame, get it back. Verifies the
        // RFC 6455 stack end to end.
        "/api/ws" => akurai_ws::upgrade(req, ws_echo),
        // A Server-Sent Events stream: a `tick` event every second, counting up.
        "/api/events" => sse_clock(),
        // Browser telemetry stays token-free in the client; apps opt in with
        // backend/client-logs.json and this proxy adds server-only credentials.
        "/api/client-logs" => Reply::Response(crate::client_logs::handle(root, req)),
        // AI chat backend: relay an OpenAI-compatible streaming completion as SSE.
        "/api/assistant" => assistant_sse(req),
        // Image generation: relay to AKURAI_IMAGE_URL, store the image in a collection file field.
        "/api/images/generations" => Reply::Response(crate::images::handle(collections_api, req)),
        // Form-actions demo: GET lists entries, POST validates + stores + redirects.
        "/guestbook" => guestbook::handle(guestbook, root, req),
        // Cookie-based session auth: login form, protected page, logout.
        "/login" => auth::login(auth, root, req),
        "/account" => auth::account(auth, root, req),
        "/logout" => auth::logout(auth, req),
        // Passwordless email-OTP login: request a code, then verify it.
        "/login/otp" => auth::otp(auth, root, req),
        // The atomic CSS engine's generated stylesheet. Cached in production,
        // regenerated fresh per request in dev. Always available to link, even
        // when empty (a project that uses no utility classes).
        "/utilities.css" => Reply::Response(with_no_cache(Response::ok().with_body(
            "text/css; charset=utf-8",
            assets.utilities_css(root).into_bytes(),
        ))),
        // The named multi-theme stylesheet — every theme as a
        // `:root[data-theme="…"]` block over the framework's semantic tokens.
        // Fully static (project-independent); link it once and switch themes by
        // setting `data-theme` on `<html>`.
        "/themes.css" => Reply::Response(with_no_cache(Response::ok().with_body(
            "text/css; charset=utf-8",
            akurai_css::theme::themes_css().into_bytes(),
        ))),
        // The theme registry as JSON (slug/family/label/variant) so the picker is
        // data-driven and never drifts from the generated CSS.
        "/themes.json" => Reply::Response(with_no_cache(Response::ok().with_body(
            "application/json; charset=utf-8",
            akurai_css::theme::registry_json().into_bytes(),
        ))),
        // Asset fingerprint manifest: plain path → hashed path. Empty in dev.
        "/api/_assets" => {
            Reply::Response(super::json_api::response(200, &assets.manifest.to_json()))
        }
        // SEO essentials, derived from backend/routes.json + page.json.
        "/robots.txt" => Reply::Response(seo::robots(root)),
        "/sitemap.xml" => Reply::Response(seo::sitemap(root, i18n)),
        // `/_dev/*` is a reserved internal namespace. In `dev` the run loop
        // intercepts `/_dev/reload` before dispatch; anything else here (and the
        // whole namespace under `serve`) is a hard 404, never the SPA fallback.
        p if p.starts_with("/_dev/") => Reply::Response(Response::not_found()),
        _ => Reply::Response(route(
            root,
            collections_api.collections(),
            assets,
            ssg_cache,
            i18n,
            app_name,
            req,
        )),
    }
}

/// Echo every text/binary message back to the client until it closes.
fn ws_echo(mut conn: WsConn) -> io::Result<()> {
    while let Some(msg) = conn.recv()? {
        match msg {
            Message::Text(text) => conn.send_text(&text)?,
            Message::Binary(data) => conn.send_binary(&data)?,
            Message::Close(_) => break,
            _ => {} // ping/pong handled inside recv
        }
    }
    Ok(())
}

/// Stream an incrementing counter as a named SSE event once per second. Loops
/// until the client disconnects, at which point the write fails and the
/// dedicated connection thread ends.
fn sse_clock() -> Reply {
    sse(|mut sink| {
        let mut n: u64 = 0;
        loop {
            n += 1;
            sink.event("tick", &n.to_string())?;
            std::thread::sleep(Duration::from_secs(1));
        }
    })
}

/// `POST /api/assistant` — the AI chat backend.
///
/// Reads `{ "messages": [{"role","content"}, ...], "tools": [...]? }` from the
/// request body and relays an OpenAI-compatible streaming completion as SSE.
/// The endpoint and model come from `AKURAI_LLM_URL` / `AKURAI_LLM_MODEL`; when
/// either is unset (or the body is invalid, or the upstream fails) a single
/// `error` event is emitted and the stream ends — the client always gets a
/// well-formed `text/event-stream`, never a hard failure.
///
/// SSE events: `delta` (token text), `tool_call` (JSON `{id,name,arguments}`),
/// `done`, `error`.
fn assistant_sse(req: &Request) -> Reply {
    let url = std::env::var("AKURAI_LLM_URL")
        .ok()
        .filter(|s| !s.is_empty());
    let model = std::env::var("AKURAI_LLM_MODEL")
        .ok()
        .filter(|s| !s.is_empty());
    assistant_reply(req, url, model)
}

/// The env-free core of [`assistant_sse`], so the SSE relay can be tested
/// without touching the process environment or a live LLM.
fn assistant_reply(req: &Request, url: Option<String>, model: Option<String>) -> Reply {
    if req.method != Method::Post {
        return Reply::Response(
            Response::new(405)
                .with_header("Allow", "POST")
                .with_text("POST only"),
        );
    }

    let parsed = parse_assistant_body(&req.body);
    sse(move |mut sink| {
        let outcome: Result<(), String> = match parsed {
            Ok((messages, tools)) => match (url, model) {
                (Some(url), Some(model)) => {
                    return relay_completion(&mut sink, &url, &model, &messages, &tools);
                }
                _ => Err(
                    "LLM endpoint not configured (set AKURAI_LLM_URL and AKURAI_LLM_MODEL)"
                        .to_string(),
                ),
            },
            Err(e) => Err(e),
        };
        if let Err(message) = outcome {
            sink.event("error", &error_payload(&message))?;
        }
        Ok(())
    })
}

/// Parse the assistant request body into `(messages, tools)`. Returns a
/// human-readable error string on any shape mismatch.
fn parse_assistant_body(body: &[u8]) -> Result<(Vec<akurai_llm::Message>, Vec<Value>), String> {
    let value = super::json_api::parse_body(body)?;

    let Some(Value::Array(items)) = value.get("messages") else {
        return Err("missing `messages` array".to_string());
    };
    let mut messages = Vec::with_capacity(items.len());
    for (i, item) in items.iter().enumerate() {
        let role = item
            .get("role")
            .and_then(Value::as_str)
            .ok_or_else(|| format!("messages[{i}] missing string `role`"))?;
        let content = item
            .get("content")
            .and_then(Value::as_str)
            .ok_or_else(|| format!("messages[{i}] missing string `content`"))?;
        messages.push(akurai_llm::Message::new(role, content));
    }
    if messages.is_empty() {
        return Err("`messages` is empty".to_string());
    }

    let tools = match value.get("tools") {
        Some(Value::Array(items)) => items.clone(),
        Some(_) => return Err("`tools` must be an array".to_string()),
        None => Vec::new(),
    };
    Ok((messages, tools))
}

/// Stream a completion and relay each [`ChatEvent`] as an SSE event. An upstream
/// error becomes a final `error` event rather than dropping the connection.
fn relay_completion(
    sink: &mut SseSink,
    url: &str,
    model: &str,
    messages: &[akurai_llm::Message],
    tools: &[Value],
) -> io::Result<()> {
    let tools_opt = if tools.is_empty() { None } else { Some(tools) };
    let mut relay_err: Option<io::Error> = None;
    let result = akurai_llm::stream_chat(url, model, messages, tools_opt, |event| {
        if relay_err.is_some() {
            return; // client already gone; drain the upstream quietly
        }
        if let Err(e) = relay_event(sink, event) {
            relay_err = Some(e);
        }
    });
    if let Some(e) = relay_err {
        return Err(e); // the client disconnected; let the upgrade thread end
    }
    if let Err(e) = result {
        sink.event("error", &error_payload(&e.to_string()))?;
    }
    Ok(())
}

/// Map one [`ChatEvent`] to its SSE event on the wire.
fn relay_event(sink: &mut SseSink, event: ChatEvent) -> io::Result<()> {
    match event {
        ChatEvent::Delta(text) => sink.event("delta", &text),
        ChatEvent::ToolCall {
            id,
            name,
            arguments,
        } => {
            let payload = Value::Object(vec![
                ("id".into(), Value::Str(id)),
                ("name".into(), Value::Str(name)),
                ("arguments".into(), Value::Str(arguments)),
            ])
            .to_json();
            sink.event("tool_call", &payload)
        }
        ChatEvent::Done => sink.event("done", "[DONE]"),
    }
}

/// A JSON `{"error": <message>}` payload for an `error` SSE event.
fn error_payload(message: &str) -> String {
    Value::Object(vec![("error".into(), Value::Str(message.to_string()))]).to_json()
}

fn route(
    root: &Path,
    collections: &[akurai_collections::Collection],
    assets: &AssetState,
    ssg_cache: &ssg::SsgCache,
    i18n: Option<&I18nState>,
    app_name: &str,
    req: &Request,
) -> Response {
    // Locale comes from the `akurai-lang` cookie (set by the language toggle).
    // With no cookie the suite default locale (Icelandic) is used — Accept-Language
    // is intentionally NOT consulted, so visitors get Icelandic by default and the
    // toggle is the single source of language choice. Empty when no catalogs load.
    let lang_cookie = req
        .header("Cookie")
        .and_then(|c| crate::auth::parse_cookie(c, "akurai-lang"));
    // Path-prefix mode (routes.json `"locale_prefix": true`): the URL decides the
    // locale — `/` is the default, `/<code>/...` the others; the cookie is ignored.
    let mut path_owned: Option<String> = None;
    let i18n_extras: Vec<(String, Value)> = match i18n {
        Some(s) if routes::locale_prefix(root) => {
            let (loc, rest) = seo::strip_locale_prefix(&req.path, s.locales(), s.default_locale());
            let locale = loc.unwrap_or_else(|| s.default_locale().to_string());
            path_owned = Some(rest.to_string());
            let mut e = s.extras_for_lang(Some(&locale));
            e.extend(seo::hreflang_extras(
                root,
                rest,
                s.locales(),
                s.default_locale(),
            ));
            e
        }
        _ => i18n
            .map(|s| s.extras_for_lang(lang_cookie.as_deref()))
            .unwrap_or_default(),
    };
    let req_path = path_owned.as_deref().unwrap_or(req.path.as_str());

    match req_path {
        "/api/health" => super::json_api::response(200, &health(app_name, &app_version(root))),
        "/api/_meta" => super::json_api::response(200, &meta_manifest(collections)),
        p if p.starts_with("/api/") => super::json_api::response(
            404,
            &Value::Object(vec![("error".into(), Value::Str("not found".into()))]),
        ),
        // Markdown content pages, rendered into the "docs" layout.
        "/changelog" => render_content(root, "changelog", "changelog", &i18n_extras),
        p if p == "/docs" || p.starts_with("/docs/") => {
            let slug = p.trim_start_matches("/docs").trim_matches('/');
            let slug = if slug.is_empty() {
                "getting-started"
            } else {
                slug
            };
            render_content(root, &format!("docs/{slug}"), slug, &i18n_extras)
        }
        // Static assets (anything with a non-.html extension). A fingerprinted
        // path is served from memory with an immutable cache header; a plain path
        // resolves to the raw file on disk exactly as before.
        p if is_static_asset(p) => assets.serve_static(root, p),
        // Everything else is a page, routed through the declarative router.
        p => render_page_routed(root, ssg_cache, p, &i18n_extras),
    }
}

/// Resolve a page path. If `backend/routes.json` exists, match it through the
/// router (capturing `:param`/`*wildcard` segments into the context); otherwise
/// fall back to the legacy "template named after the last path segment". An
/// unmatched path renders `index` (client-side routing picks it up from there).
///
/// A matched route that opts into static generation (`prerender` and/or
/// `revalidate`) is served from the SSG cache via [`serve_ssg`]; every other
/// route renders dynamically exactly as before.
///
/// `i18n_extras` carries the pre-computed `__locale` + `__messages` pairs for
/// this request (empty slice when no catalogs are loaded).
fn render_page_routed(
    root: &Path,
    ssg_cache: &ssg::SsgCache,
    path: &str,
    i18n_extras: &[(String, Value)],
) -> Response {
    match routes::load(root) {
        Some(router) => match router.match_path(path) {
            // Inject any per-route `<head>` metadata (meta_title/meta_description)
            // declared for this path; absent fields leave rendering unchanged.
            Some(matched) => {
                let def = matched.value;
                let mut meta = seo::route_meta(root, path);
                // Merge i18n extras into the meta/extra slice so they reach
                // render_page_with (and through it the SSG paths).
                meta.extend_from_slice(i18n_extras);
                if def.prerender || def.revalidate.is_some() {
                    serve_ssg(root, ssg_cache, path, def, &matched.params, meta)
                } else {
                    render_page_with(
                        root,
                        &def.template,
                        def.layout.as_deref(),
                        def.data.as_deref(),
                        &matched.params,
                        &meta,
                    )
                }
            }
            None => render_page_with(root, "index", None, None, &[], i18n_extras),
        },
        None => render_page(root, &template_name(path)),
    }
}

/// Serve a static-generation (SSG/ISR) route through the cache.
///
/// The freshness decision is pure ([`ssg::decide`]): a cache miss renders now,
/// stores, and serves; a fresh hit serves the cached bytes; a stale hit (past the
/// `revalidate` TTL) serves the stale bytes immediately and kicks off a
/// background re-render (stale-while-revalidate). A render that doesn't produce a
/// `200` is never cached — the live error/redirect response is returned as-is.
fn serve_ssg(
    root: &Path,
    ssg_cache: &ssg::SsgCache,
    path: &str,
    def: &routes::RouteDef,
    params: &[(String, String)],
    meta: Vec<(String, Value)>,
) -> Response {
    let now = ssg::now_secs();
    let key = ssg_key(root, path, &meta);
    let path = key.as_str();
    let entry = ssg_cache.get(path).ok().flatten();
    match ssg::decide(entry.as_ref().map(|e| e.generated_at), now, def.revalidate) {
        ssg::Decision::Render => match render_page_bytes(root, def, params, &meta) {
            Some(html) => {
                let _ = ssg_cache.put(path, &html, now);
                ssg_response(html, def.revalidate)
            }
            // Render failed (non-200): fall back to the live response, uncached.
            None => render_page_with(
                root,
                &def.template,
                def.layout.as_deref(),
                def.data.as_deref(),
                params,
                &meta,
            ),
        },
        ssg::Decision::Serve => ssg_response(
            entry.expect("Serve implies a cache entry").html,
            def.revalidate,
        ),
        ssg::Decision::ServeStaleRevalidate => {
            let stale = entry.expect("ServeStaleRevalidate implies a cache entry");
            // Capture owned inputs so the detached thread can re-render safely.
            let owned_root = root.to_path_buf();
            let owned_def = def.clone();
            let owned_params = params.to_vec();
            let owned_meta = meta.clone();
            ssg_cache.regenerate_in_background(path, move || {
                render_page_bytes(&owned_root, &owned_def, &owned_params, &owned_meta)
            });
            ssg_response(stale.html, def.revalidate)
        }
    }
}

/// SSG cache key: the path, prefixed with the active locale in path-prefix mode
/// so locale variants of one route never collide.
fn ssg_key(root: &Path, path: &str, meta: &[(String, Value)]) -> String {
    if routes::locale_prefix(root) {
        if let Some(Value::Str(l)) = meta.iter().find(|(k, _)| k == "__locale").map(|(_, v)| v) {
            return format!("{l}:{path}");
        }
    }
    path.to_string()
}

/// Render a route's page to HTML bytes for caching, returning `Some(bytes)` only
/// on a successful `200` render. A 500 (template error) or any non-200 yields
/// `None` so it is never cached.
fn render_page_bytes(
    root: &Path,
    def: &routes::RouteDef,
    params: &[(String, String)],
    meta: &[(String, Value)],
) -> Option<Vec<u8>> {
    let resp = render_page_with(
        root,
        &def.template,
        def.layout.as_deref(),
        def.data.as_deref(),
        params,
        meta,
    );
    (resp.status == 200).then_some(resp.body)
}

/// Wrap cached SSG HTML in a `200` response. ISR routes (with `revalidate`) get a
/// browser `Cache-Control: max-age=<ttl>` matching the regeneration window; pure
/// prerender routes keep `no-cache` so the browser always revalidates against the
/// server (which still serves from cache) — never serving silently-stale HTML.
fn ssg_response(html: Vec<u8>, revalidate: Option<u64>) -> Response {
    let resp = Response::ok().with_body("text/html; charset=utf-8", html);
    match revalidate {
        Some(ttl) => resp.with_header("Cache-Control", &format!("public, max-age={ttl}")),
        None => with_no_cache(resp),
    }
}

/// Rebuild the SSG cache at startup: drop every stale entry, then render and
/// store each `prerender` route fresh. This is the invalidation trigger — a
/// restart clears the cache and re-warms pure-SSG pages (ISR routes then
/// regenerate lazily on first request). Only literal paths are warmed (pattern
/// routes have no single URL); a route that fails to render is skipped. Returns
/// the number of routes successfully warmed.
///
/// SSG renders use the **default locale** (no live `Accept-Language` header
/// is available at warm time). Dynamic per-request locale negotiation only
/// applies to the non-SSG render paths.
fn warm_prerender(root: &Path, ssg_cache: &ssg::SsgCache, i18n: Option<&I18nState>) -> usize {
    // Start from a clean slate so removed/edited routes don't leave stale HTML.
    let _ = ssg_cache.clear();
    let now = ssg::now_secs();
    // Default locale extras for SSG renders (no request → empty Accept-Language).
    let mut i18n_extras: Vec<(String, Value)> = i18n.map(|s| s.extras("")).unwrap_or_default();
    let prefix_mode = routes::locale_prefix(root);
    let mut warmed = 0;
    for entry in routes::entries(root) {
        if !entry.def.prerender {
            continue;
        }
        // Pattern routes can't be warmed: no concrete URL to key on.
        if entry.path.contains(':') || entry.path.contains('*') {
            continue;
        }
        if let (true, Some(st)) = (prefix_mode, i18n) {
            i18n_extras = st.extras_for_lang(Some(st.default_locale()));
            i18n_extras.extend(seo::hreflang_extras(
                root,
                &entry.path,
                st.locales(),
                st.default_locale(),
            ));
        }
        let mut meta = seo::route_meta(root, &entry.path);
        meta.extend_from_slice(&i18n_extras);
        if let Some(html) = render_page_bytes(root, &entry.def, &[], &meta) {
            if ssg_cache
                .put(&ssg_key(root, &entry.path, &meta), &html, now)
                .is_ok()
            {
                warmed += 1;
            }
        }
    }
    warmed
}

/// Render a Markdown file from `content/<rel>.md` into the `docs` layout
/// template. `active` marks the current sidebar entry. 404 if the file is
/// missing; that keeps unknown `/docs/*` routes honest rather than blank.
///
/// `i18n_extras` carries the pre-computed `__locale` + `__messages` pairs for
/// this request (empty slice when no catalogs are loaded).
fn render_content(
    root: &Path,
    rel: &str,
    active: &str,
    i18n_extras: &[(String, Value)],
) -> Response {
    let Some(content_dir) = root.parent().map(|p| p.join("content")) else {
        return Response::not_found();
    };
    let md_path = content_dir.join(format!("{rel}.md"));
    let markdown = match fs::read_to_string(&md_path) {
        Ok(text) => text,
        Err(_) => {
            return Response::new(404).with_html(&error_page(&format!("no content: {rel}.md")))
        }
    };
    let html = akurai_markdown::to_html(&markdown);

    let engine = match build_engine(root) {
        Ok(engine) => engine,
        Err(msg) => return Response::new(500).with_html(&error_page(&msg)),
    };
    let mut context = load_context(root);
    if let Value::Object(pairs) = &mut context {
        pairs.push(("content".into(), Value::Str(html)));
        pairs.push(("active".into(), Value::Str(active.to_string())));
        pairs.extend_from_slice(i18n_extras);
    }
    match engine.render("docs", &context) {
        Ok(page) => with_no_cache(Response::ok().with_html(&page)),
        Err(e) => Response::new(500).with_html(&error_page(&e.message)),
    }
}

fn serve_static(root: &Path, req_path: &str) -> Response {
    match resolve(root, req_path) {
        Some(path) if path.is_file() => read_file(&path),
        _ => Response::not_found(),
    }
}

/// A request is a static asset if its last segment has an extension other than
/// `.html` (e.g. `.css`, `.js`, `.png`).
fn is_static_asset(path: &str) -> bool {
    let last = path.rsplit('/').next().unwrap_or("");
    match last.rsplit_once('.') {
        Some((_, ext)) => !ext.eq_ignore_ascii_case("html"),
        None => false,
    }
}

/// Map a request path to a template name: `/` → `index`, `/about` or
/// `/about.html` → `about`.
fn template_name(path: &str) -> String {
    let trimmed = path.trim_matches('/');
    if trimmed.is_empty() {
        return "index".to_string();
    }
    let last = trimmed.rsplit('/').next().unwrap_or("index");
    last.strip_suffix(".html").unwrap_or(last).to_string()
}

/// Render a page server-side: register every `frontend/*.html` as a template,
/// load the JSON context, and render `name` (falling back to `index` for
/// unknown routes — SPA-style). Template/parse errors surface as a 500 so they
/// are visible during development.
fn render_page(root: &Path, name: &str) -> Response {
    render_page_with(root, name, None, None, &[], &[])
}

/// Render template `name` with `params` (captured route segments) merged into
/// the context under `params`, so templates can read `{{ params.id }}`. Extra
/// context pairs (e.g. per-route `meta_title`/`meta_description`) are merged at
/// the top level. Falls back to the `index` template for unknown names
/// (SPA-style).
///
/// When `data` is `Some`, the named JSON file under `backend/` is loaded and
/// merged into the context before rendering (see [`load_route_data`] for the
/// merge rule): a loaded object's top-level keys are spread at the root and the
/// whole document is also exposed under `data`, so a template can both read
/// `{{ title }}` and iterate `{% for x in members %}`. `None` adds nothing.
///
/// When `layout` is `Some`, the page is rendered first and its HTML wrapped by
/// the layout shell: the page output is injected into the same context as
/// `content` and the layout template is rendered (consuming it with
/// `{{{ content }}}`). `None` renders the page directly, exactly as before.
pub(crate) fn render_page_with(
    root: &Path,
    name: &str,
    layout: Option<&str>,
    data: Option<&str>,
    params: &[(String, String)],
    extra: &[(String, Value)],
) -> Response {
    let engine = match build_engine(root) {
        Ok(engine) => engine,
        Err(msg) => return Response::new(500).with_html(&error_page(&msg)),
    };
    let mut context = load_context(root);
    if let Value::Object(pairs) = &mut context {
        // Declarative per-route data: spread an object's top-level keys at the
        // root (skipping the reserved `params`), then expose the whole document
        // under `data`. A non-object payload is exposed under `data` only.
        if let Some(rel) = data {
            if let Some(loaded) = load_route_data(root, rel) {
                if let Value::Object(loaded_pairs) = &loaded {
                    for (k, v) in loaded_pairs {
                        if k != "params" {
                            pairs.push((k.clone(), v.clone()));
                        }
                    }
                }
                pairs.push(("data".into(), loaded));
            }
        }
        pairs.push(("params".into(), routes::params_value(params)));
        pairs.extend(extra.iter().cloned());
    }

    // Render the page template (SPA-style fallback to `index` for unknown names).
    let page = match engine
        .render(name, &context)
        .or_else(|_| engine.render("index", &context))
    {
        Ok(html) => html,
        Err(e) => return Response::new(500).with_html(&error_page(&e.message)),
    };

    // No layout: the page output is the response. Otherwise wrap it in the shell.
    let Some(layout) = layout else {
        return with_no_cache(Response::ok().with_html(&page));
    };
    if let Value::Object(pairs) = &mut context {
        pairs.push(("content".into(), Value::Str(page)));
    }
    match engine.render(layout, &context) {
        Ok(html) => with_no_cache(Response::ok().with_html(&html)),
        Err(e) => Response::new(500).with_html(&error_page(&e.message)),
    }
}

/// Build an engine from every `*.html` file in `root`, keyed by file stem.
pub(crate) fn build_engine(root: &Path) -> Result<Engine, String> {
    let mut engine = Engine::new();
    let entries = fs::read_dir(root).map_err(|e| format!("cannot read {}: {e}", root.display()))?;
    for entry in entries {
        let path = entry.map_err(|e| e.to_string())?.path();
        if path.extension().and_then(|e| e.to_str()) != Some("html") {
            continue;
        }
        let stem = match path.file_stem().and_then(|s| s.to_str()) {
            Some(s) => s.to_string(),
            None => continue,
        };
        let source = fs::read_to_string(&path).map_err(|e| e.to_string())?;
        engine
            .register(&stem, &source)
            .map_err(|e| format!("{stem}.html: {}", e.message))?;
    }
    Ok(engine)
}

/// Load the page context from `<project>/backend/page.json`, or an empty object
/// if it is missing or unparseable.
pub(crate) fn load_context(root: &Path) -> Value {
    let empty = Value::Object(vec![]);
    let Some(path) = root.parent().map(|p| p.join("backend/page.json")) else {
        return empty;
    };
    match fs::read_to_string(&path) {
        Ok(text) => akurai_json::parse(&text).unwrap_or(empty),
        Err(_) => empty,
    }
}

/// Load and parse a per-route data file named `rel`, resolved under the
/// project's `backend/` directory. Returns `None` if the path escapes `backend/`
/// (any `..`/`.` segment), the file is missing, or the JSON is unparseable —
/// rendering then proceeds with the data simply absent, never a crash. The
/// returned [`Value`] is merged into the render context by [`render_page_with`].
fn load_route_data(root: &Path, rel: &str) -> Option<Value> {
    // Reject traversal: every segment must be a plain name within `backend/`.
    let rel = rel.trim_start_matches('/');
    if rel.is_empty() || rel.split('/').any(|seg| seg == ".." || seg == ".") {
        return None;
    }
    let path = root.parent()?.join("backend").join(rel);
    let text = fs::read_to_string(&path).ok()?;
    akurai_json::parse(&text).ok()
}

pub(crate) fn error_page(message: &str) -> String {
    let mut escaped = String::new();
    for c in message.chars() {
        match c {
            '<' => escaped.push_str("&lt;"),
            '>' => escaped.push_str("&gt;"),
            '&' => escaped.push_str("&amp;"),
            c => escaped.push(c),
        }
    }
    format!(
        "<!doctype html><meta charset=utf-8><title>Template error</title>\
         <body style=\"font:16px system-ui;background:#0a0c11;color:#ff6b6b;padding:40px\">\
         <h1>Template error</h1><pre style=\"color:#e7ebf3\">{escaped}</pre></body>"
    )
}

/// Map a request path to a file under `root`, rejecting traversal (`..`).
fn resolve(root: &Path, req_path: &str) -> Option<PathBuf> {
    let rel = req_path.trim_start_matches('/');
    let rel = if rel.is_empty() { "index.html" } else { rel };
    if rel.split('/').any(|seg| seg == ".." || seg == ".") {
        return None;
    }
    Some(root.join(rel))
}

fn read_file(path: &Path) -> Response {
    match fs::read(path) {
        Ok(bytes) => {
            let ct = mime::for_path(&path.to_string_lossy());
            with_no_cache(Response::ok().with_body(ct, bytes))
        }
        Err(_) => Response::not_found(),
    }
}

/// The app's own version: `<project>/VERSION`, else the framework version.
fn app_version(root: &Path) -> String {
    root.parent()
        .and_then(|p| std::fs::read_to_string(p.join("VERSION")).ok())
        .map(|v| v.trim().to_string())
        .filter(|v| !v.is_empty())
        .unwrap_or_else(|| VERSION.to_string())
}

fn health(app_name: &str, app_version: &str) -> Value {
    Value::Object(vec![
        ("app".into(), Value::Str(app_name.into())),
        ("framework".into(), Value::Str("AkurAI-Framework".into())),
        ("framework_version".into(), Value::Str(VERSION.into())),
        ("version".into(), Value::Str(app_version.into())),
        ("status".into(), Value::Str("ok".into())),
    ])
}

/// A minimal self-describing manifest. The `collections` key carries the live
/// auto-API schema (the array from [`akurai_collections::meta`]), so it grows
/// automatically as `backend/collections.toml` declares more collections.
fn meta_manifest(collections: &[akurai_collections::Collection]) -> Value {
    let route = |method: &str, path: &str| {
        Value::Object(vec![
            ("method".into(), Value::Str(method.into())),
            ("path".into(), Value::Str(path.into())),
        ])
    };
    // `meta` returns `{ "collections": [...] }`; lift the array out so it nests
    // under our own `collections` key (not double-wrapped).
    let collections_value = akurai_collections::meta(collections)
        .get("collections")
        .cloned()
        .unwrap_or(Value::Array(vec![]));
    Value::Object(vec![
        ("framework".into(), Value::Str("AkurAI-Framework".into())),
        (
            "routes".into(),
            Value::Array(vec![
                route("GET", "/api/health"),
                route("GET", "/api/_meta"),
            ]),
        ),
        ("collections".into(), collections_value),
    ])
}

#[cfg(test)]
mod tests {
    use super::*;
    use akurai_http::Request;

    fn req(path: &str) -> Request {
        Request::parse_head(format!("GET {path} HTTP/1.1\r\n\r\n").as_bytes()).unwrap()
    }

    /// Scratch base: `CARGO_TARGET_TMPDIR` when present, else this crate's own
    /// `target/` — never `/tmp`. Mirrors the helper in `collections_api.rs`.
    fn scratch_base() -> PathBuf {
        match std::env::var_os("CARGO_TARGET_TMPDIR") {
            Some(dir) => PathBuf::from(dir),
            None => PathBuf::from(env!("CARGO_MANIFEST_DIR"))
                .join("target")
                .join("serve-test-tmp"),
        }
    }

    /// An empty asset state (no utilities, no fingerprint manifest) — the shape
    /// dev uses and a fine default for routing tests.
    fn empty_assets() -> AssetState {
        AssetState::build(Path::new("nonexistent"), true)
    }

    /// A throwaway SSG cache under the scratch base. Routing tests that don't
    /// exercise SSG just need a live handle to pass through.
    fn scratch_ssg(name: &str) -> ssg::SsgCache {
        let dir = scratch_base().join("ssg-serve-test");
        fs::create_dir_all(&dir).unwrap();
        let path = dir.join(format!("{name}-{}.db", std::process::id()));
        let _ = fs::remove_file(&path);
        ssg::SsgCache::open(&path).unwrap()
    }

    fn header(r: &Response, name: &str) -> Option<String> {
        r.headers
            .iter()
            .find(|(k, _)| k.eq_ignore_ascii_case(name))
            .map(|(_, v)| v.clone())
    }

    #[test]
    fn health_returns_version_json() {
        let root = Path::new("nonexistent");
        let r = route(
            root,
            &[],
            &empty_assets(),
            &scratch_ssg("health"),
            None,
            "akurai-framework",
            &req("/api/health"),
        );
        let body = String::from_utf8(r.body.clone()).unwrap();
        assert_eq!(r.status, 200);
        assert!(body.contains("\"app\":\""));
        assert!(body.contains("\"version\":\"") && body.contains(VERSION));
        assert!(body.contains("\"status\":\"ok\""));
        assert!(body.contains("\"framework\":\"AkurAI-Framework\""));
    }

    #[test]
    fn health_reports_the_project_version_file() {
        let base = scratch_base().join(format!("health-version-{}", std::process::id()));
        std::fs::create_dir_all(base.join("frontend")).unwrap();
        std::fs::write(base.join("VERSION"), "1.2.3\n").unwrap();
        let r = route(
            &base.join("frontend"),
            &[],
            &empty_assets(),
            &scratch_ssg("health-version"),
            None,
            "myapp",
            &req("/api/health"),
        );
        let body = String::from_utf8(r.body.clone()).unwrap();
        assert!(body.contains("\"version\":\"1.2.3\""), "{body}");
        assert!(
            body.contains(&format!("\"framework_version\":\"{VERSION}\"")),
            "{body}"
        );
    }

    #[test]
    fn locale_prefix_routes_render_lang_and_hreflang() {
        let base = scratch_base().join(format!("lp-{}", std::process::id()));
        let _ = fs::remove_dir_all(&base);
        for d in ["frontend", "backend/locales"] {
            fs::create_dir_all(base.join(d)).unwrap();
        }
        fs::write(base.join("backend/locales/is.json"), r#"{"k":"halló"}"#).unwrap();
        fs::write(base.join("backend/locales/en.json"), r#"{"k":"hello"}"#).unwrap();
        fs::write(
            base.join("backend/page.json"),
            r#"{"site_url":"https://x.is"}"#,
        )
        .unwrap();
        fs::write(
            base.join("backend/routes.json"),
            r#"{"locale_prefix":true,"routes":[{"path":"/","template":"index"},{"path":"/about","template":"index","prerender":true}]}"#,
        )
        .unwrap();
        fs::write(
            base.join("frontend/index.html"),
            r#"<html lang="{{ __locale }}">{{{ hreflang_links }}}{{ t "k" }}"#,
        )
        .unwrap();
        let root = base.join("frontend");
        let i18n = I18nState::load(&base.join("backend/locales"));
        let get = |p: &str| {
            let r = route(
                &root,
                &[],
                &empty_assets(),
                &scratch_ssg("lp"),
                i18n.as_ref(),
                "a",
                &req(p),
            );
            String::from_utf8(r.body).unwrap()
        };
        let is = get("/");
        assert!(is.contains("lang=\"is\"") && is.contains("halló"));
        assert!(is.contains("hreflang=\"en\" href=\"https://x.is/en\""));
        let en = get("/en");
        assert!(en.contains("lang=\"en\"") && en.contains("hello"));
        let en_about = get("/en/about");
        assert!(en_about.contains("lang=\"en\"") && en_about.contains("x-default"));
        assert!(get("/about").contains("lang=\"is\""));
        let _ = fs::remove_dir_all(&base);
    }

    #[test]
    fn unknown_api_route_is_404() {
        let r = route(
            Path::new("."),
            &[],
            &empty_assets(),
            &scratch_ssg("unknown-api"),
            None,
            "akurai-framework",
            &req("/api/does-not-exist"),
        );
        assert_eq!(r.status, 404);
    }

    #[test]
    fn utilities_css_is_served() {
        // A frontend referencing utility classes yields a non-empty utilities.css
        // at /utilities.css with the right content type and no-cache header.
        let dir = scratch_base().join("serve_utilities");
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("index.html"), r#"<div class="flex p-2">hi</div>"#).unwrap();

        let assets = AssetState::build(&dir, false);
        let reply = dispatch(
            &dir,
            &guestbook::State::open(dir.join("s.db")).unwrap(),
            None,
            &auth::State::open(&dir).unwrap(),
            &CollectionsApi::open(&dir).unwrap(),
            &assets,
            &scratch_ssg("utilities"),
            None,
            "akurai-framework",
            &req("/utilities.css"),
        );
        let Reply::Response(r) = reply else {
            panic!("expected a buffered response")
        };
        assert_eq!(r.status, 200);
        assert_eq!(
            header(&r, "Content-Type").as_deref(),
            Some("text/css; charset=utf-8")
        );
        assert_eq!(
            header(&r, "Cache-Control").as_deref(),
            Some(NO_CACHE),
            "utilities.css should have no-cache header"
        );
        let body = String::from_utf8(r.body).unwrap();
        assert!(body.contains(".flex{display:flex}"), "got: {body}");
    }

    #[test]
    fn fingerprinted_asset_gets_immutable_cache() {
        // A fingerprinted static request resolves from the manifest with the
        // immutable cache header; the plain path still resolves off disk.
        let dir = scratch_base().join("serve_fingerprint");
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("styles.css"), b"a {\n  color: red;\n}\n").unwrap();

        let assets = AssetState::build(&dir, false);
        let hashed = assets.manifest.to_json().to_json(); // JSON string contains the hashed path
        assert!(hashed.contains("/styles.css"));

        // Resolve the hashed path from the manifest and request it.
        let hashed_path = {
            // Pull the hashed value out of the manifest JSON object directly.
            let Value::Object(pairs) = assets.manifest.to_json() else {
                panic!("manifest is an object")
            };
            let (_, v) = pairs
                .into_iter()
                .find(|(k, _)| k == "/styles.css")
                .expect("styles.css in manifest");
            match v {
                Value::Str(s) => s,
                _ => panic!("hashed path is a string"),
            }
        };

        let r = assets.serve_static(&dir, &hashed_path);
        assert_eq!(r.status, 200);
        assert_eq!(
            header(&r, "Cache-Control").as_deref(),
            Some(asset_pipeline::IMMUTABLE_CACHE)
        );
        assert_eq!(
            header(&r, "Content-Type").as_deref(),
            Some("text/css; charset=utf-8")
        );
        // Minified bytes are what gets served.
        assert_eq!(String::from_utf8(r.body).unwrap(), "a{color:red}");

        // Backward compatible: the plain path resolves the raw file with no-cache.
        let plain = assets.serve_static(&dir, "/styles.css");
        assert_eq!(plain.status, 200);
        assert_eq!(header(&plain, "Cache-Control").as_deref(), Some(NO_CACHE));
    }

    #[test]
    fn dev_utilities_regenerate_per_request() {
        // In dev (empty AssetState), utilities are regenerated from disk each call,
        // so an edit between requests is reflected without a restart.
        let dir = scratch_base().join("serve_dev_regen");
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("index.html"), r#"<div class="flex">x</div>"#).unwrap();

        let assets = AssetState::build(&dir, true); // dev: utilities = None
        let first = assets.utilities_css(&dir);
        assert!(first.contains(".flex{display:flex}"));
        assert!(!first.contains("text-center"));

        fs::write(
            dir.join("index.html"),
            r#"<div class="flex text-center">x</div>"#,
        )
        .unwrap();
        let second = assets.utilities_css(&dir);
        assert!(
            second.contains(".text-center{text-align:center}"),
            "got: {second}"
        );
    }

    #[test]
    fn rejects_path_traversal() {
        assert!(resolve(Path::new("/srv/app"), "/../../etc/passwd").is_none());
        assert!(resolve(Path::new("/srv/app"), "/ok/file.js").is_some());
    }

    #[test]
    fn root_maps_to_index() {
        let p = resolve(Path::new("/srv/app"), "/").unwrap();
        assert!(p.ends_with("index.html"));
    }

    #[test]
    fn route_data_rejects_traversal() {
        // Escaping `backend/` is refused; an empty name is too.
        assert!(load_route_data(Path::new("/srv/app/frontend"), "../secrets.json").is_none());
        assert!(load_route_data(Path::new("/srv/app/frontend"), "a/../../b.json").is_none());
        assert!(load_route_data(Path::new("/srv/app/frontend"), "").is_none());
        // A safe, non-existent name simply yields None (no panic).
        assert!(load_route_data(Path::new("/srv/app/frontend"), "data/missing.json").is_none());
    }

    #[test]
    fn html_page_gets_no_cache() {
        // HTML pages should have Cache-Control: no-cache so they're always revalidated.
        let dir = scratch_base().join("serve_html_cache");
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("index.html"), r#"<!doctype html><h1>Hello</h1>"#).unwrap();

        let assets = empty_assets();
        let r = route(
            &dir,
            &[],
            &assets,
            &scratch_ssg("html-cache"),
            None,
            "akurai-framework",
            &req("/"),
        );
        assert_eq!(r.status, 200);
        assert_eq!(
            header(&r, "Cache-Control").as_deref(),
            Some(NO_CACHE),
            "HTML pages should have no-cache header"
        );
        assert_eq!(
            header(&r, "Content-Type").as_deref(),
            Some("text/html; charset=utf-8")
        );
    }

    #[test]
    fn plain_static_asset_gets_no_cache() {
        // Plain (non-fingerprinted) static assets should have Cache-Control: no-cache.
        let dir = scratch_base().join("serve_plain_asset_cache");
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("app.js"), b"console.log('hi');").unwrap();

        let assets = empty_assets();
        let r = route(
            &dir,
            &[],
            &assets,
            &scratch_ssg("plain-asset"),
            None,
            "akurai-framework",
            &req("/app.js"),
        );
        assert_eq!(r.status, 200);
        assert_eq!(
            header(&r, "Cache-Control").as_deref(),
            Some(NO_CACHE),
            "Plain static assets should have no-cache header"
        );
        assert_eq!(
            header(&r, "Content-Type").as_deref(),
            Some("text/javascript; charset=utf-8")
        );
    }

    #[test]
    fn fingerprinted_asset_keeps_immutable_cache() {
        // Fingerprinted assets should keep their far-future immutable cache.
        let dir = scratch_base().join("serve_fingerprint_immutable");
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("app.js"), b"var x = 1;").unwrap();

        let assets = AssetState::build(&dir, false);
        let hashed = assets.manifest.to_json().to_json();
        assert!(hashed.contains("/app.js"));

        // Find the hashed path from manifest
        let hashed_path = {
            let Value::Object(pairs) = assets.manifest.to_json() else {
                panic!("manifest is an object")
            };
            let (_, v) = pairs
                .into_iter()
                .find(|(k, _)| k == "/app.js")
                .expect("app.js in manifest");
            match v {
                Value::Str(s) => s,
                _ => panic!("hashed path is a string"),
            }
        };

        let r = assets.serve_static(&dir, &hashed_path);
        assert_eq!(r.status, 200);
        assert_eq!(
            header(&r, "Cache-Control").as_deref(),
            Some(asset_pipeline::IMMUTABLE_CACHE),
            "Fingerprinted assets should keep immutable cache"
        );
    }

    /// Build a throwaway project (frontend + backend/routes.json + a `page`
    /// template) under the scratch base, returning the frontend dir and a fresh
    /// SSG cache keyed in the project's own `ssg.db`.
    fn setup_project(name: &str, routes_json: &str, page_html: &str) -> (PathBuf, ssg::SsgCache) {
        let root = scratch_base().join(name);
        let _ = fs::remove_dir_all(&root);
        let frontend = root.join("frontend");
        let backend = root.join("backend");
        fs::create_dir_all(&frontend).unwrap();
        fs::create_dir_all(&backend).unwrap();
        fs::write(frontend.join("index.html"), "<h1>index</h1>").unwrap();
        fs::write(frontend.join("page.html"), page_html).unwrap();
        fs::write(backend.join("routes.json"), routes_json).unwrap();
        let cache = ssg::SsgCache::open(root.join("ssg.db")).unwrap();
        (frontend, cache)
    }

    /// Poll the cache for `path` until its HTML contains `needle` (or time out),
    /// to observe a detached background regeneration landing.
    fn await_cache(cache: &ssg::SsgCache, path: &str, needle: &str) -> String {
        let mut body = String::new();
        for _ in 0..400 {
            if let Some(entry) = cache.get(path).unwrap() {
                body = String::from_utf8(entry.html).unwrap();
                if body.contains(needle) {
                    return body;
                }
            }
            std::thread::sleep(Duration::from_millis(5));
        }
        body
    }

    #[test]
    fn prerender_serves_cache_and_ignores_source_change() {
        // First render caches v1; a later source change is NOT reflected because
        // a prerender route serves the cached bytes (proves no re-render).
        let routes = r#"{"routes":[{"path":"/p","template":"page","prerender":true}]}"#;
        let (frontend, cache) = setup_project("ssg_prerender", routes, "<main>v1</main>");

        let first = render_page_routed(&frontend, &cache, "/p", &[]);
        assert_eq!(first.status, 200);
        assert!(String::from_utf8(first.body).unwrap().contains("v1"));

        fs::write(frontend.join("page.html"), "<main>v2</main>").unwrap();
        let second = render_page_routed(&frontend, &cache, "/p", &[]);
        let body = String::from_utf8(second.body.clone()).unwrap();
        assert!(
            body.contains("v1"),
            "prerender must serve cache, got: {body}"
        );
        assert!(!body.contains("v2"));
        // Pure prerender keeps the no-cache browser header.
        assert_eq!(header(&second, "Cache-Control").as_deref(), Some(NO_CACHE));
    }

    #[test]
    fn isr_serves_stale_then_regenerates_after_ttl() {
        // A stale entry (generated at epoch 0, ttl 1s) is served immediately,
        // then a background regen refreshes the cache from the live template.
        let routes = r#"{"routes":[{"path":"/i","template":"page","revalidate":1}]}"#;
        let (frontend, cache) = setup_project("ssg_isr", routes, "<main>fresh</main>");
        cache.put("/i", b"<main>stale</main>", 0).unwrap();

        let resp = render_page_routed(&frontend, &cache, "/i", &[]);
        assert_eq!(resp.status, 200);
        // ISR routes carry a max-age header matching the revalidate window.
        assert_eq!(
            header(&resp, "Cache-Control").as_deref(),
            Some("public, max-age=1")
        );
        // Stale-while-revalidate: the stale copy is returned right away.
        assert!(String::from_utf8(resp.body).unwrap().contains("stale"));

        let refreshed = await_cache(&cache, "/i", "fresh");
        assert!(
            refreshed.contains("fresh"),
            "regen failed, got: {refreshed}"
        );
    }

    #[test]
    fn isr_fresh_entry_is_served_without_regen() {
        // A just-generated entry (ttl 3600s) is fresh, so the live template is
        // never consulted — the cached bytes are returned verbatim.
        let routes = r#"{"routes":[{"path":"/i","template":"page","revalidate":3600}]}"#;
        let (frontend, cache) = setup_project("ssg_isr_fresh", routes, "<main>live</main>");
        cache
            .put("/i", b"<main>cached</main>", ssg::now_secs())
            .unwrap();

        let resp = render_page_routed(&frontend, &cache, "/i", &[]);
        let body = String::from_utf8(resp.body).unwrap();
        assert!(body.contains("cached") && !body.contains("live"));
    }

    #[test]
    fn dynamic_route_is_unaffected_and_uncached() {
        // No prerender/revalidate → today's dynamic SSR: every request re-renders
        // from disk and nothing is written to the SSG cache.
        let routes = r#"{"routes":[{"path":"/d","template":"page"}]}"#;
        let (frontend, cache) = setup_project("ssg_dynamic", routes, "<main>one</main>");

        let first = render_page_routed(&frontend, &cache, "/d", &[]);
        assert_eq!(header(&first, "Cache-Control").as_deref(), Some(NO_CACHE));
        assert!(String::from_utf8(first.body).unwrap().contains("one"));

        fs::write(frontend.join("page.html"), "<main>two</main>").unwrap();
        let second = render_page_routed(&frontend, &cache, "/d", &[]);
        assert!(
            String::from_utf8(second.body).unwrap().contains("two"),
            "dynamic route must re-render fresh"
        );
        assert!(
            cache.get("/d").unwrap().is_none(),
            "dynamic route is uncached"
        );
    }

    #[test]
    fn prerender_cache_persists_across_reopen() {
        let routes = r#"{"routes":[{"path":"/p","template":"page","prerender":true}]}"#;
        let (frontend, cache) = setup_project("ssg_persist", routes, "<main>kept</main>");
        let _ = render_page_routed(&frontend, &cache, "/p", &[]);
        drop(cache);

        // Reopen the same db file: the cached page survives the restart.
        let db = scratch_base().join("ssg_persist").join("ssg.db");
        let reopened = ssg::SsgCache::open(db).unwrap();
        let entry = reopened.get("/p").unwrap().expect("cache persisted");
        assert!(String::from_utf8(entry.html).unwrap().contains("kept"));
    }

    #[test]
    fn warm_prerender_populates_only_prerender_routes() {
        let routes = r#"{"routes":[
            {"path":"/p","template":"page","prerender":true},
            {"path":"/d","template":"page"},
            {"path":"/x/:id","template":"page","prerender":true}
        ]}"#;
        let (frontend, cache) = setup_project("ssg_warm", routes, "<main>warm</main>");

        let warmed = warm_prerender(&frontend, &cache, None);
        assert_eq!(warmed, 1, "only the literal prerender route is warmed");
        assert!(cache.get("/p").unwrap().is_some());
        assert!(
            cache.get("/d").unwrap().is_none(),
            "non-prerender not warmed"
        );
        assert!(
            cache.get("/x/:id").unwrap().is_none(),
            "pattern route has no concrete URL to warm"
        );
    }

    /// Build a `POST /api/assistant` request with the given JSON body.
    fn assistant_post(body: &str) -> Request {
        let head = format!(
            "POST /api/assistant HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
            body.len()
        );
        let mut req = Request::parse_head(head.as_bytes()).unwrap();
        req.body = body.as_bytes().to_vec();
        req
    }

    /// Run an SSE upgrade reply's hijack against a loopback socket and return
    /// everything it wrote (the framework writes the head; the hijack writes the
    /// event-stream body).
    fn drain_sse(reply: Reply) -> String {
        use std::io::Read as _;
        use std::net::{TcpListener, TcpStream};

        let Reply::Upgrade(up) = reply else {
            panic!("expected an SSE upgrade reply");
        };
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();
        let mut client = TcpStream::connect(addr).unwrap();
        let (server, _) = listener.accept().unwrap();

        let handle = std::thread::spawn(move || {
            // The hijack owns the connection for the rest of its life.
            let _ = (up.hijack)(server);
        });
        let mut out = String::new();
        client.read_to_string(&mut out).unwrap();
        handle.join().unwrap();
        out
    }

    #[test]
    fn assistant_without_llm_env_emits_error_event() {
        // No AKURAI_LLM_URL/MODEL configured: a single graceful `error` event,
        // no network touched.
        let req = assistant_post(r#"{"messages":[{"role":"user","content":"hi"}]}"#);
        let body = drain_sse(assistant_reply(&req, None, None));
        assert!(body.contains("event: error"), "got: {body:?}");
        assert!(body.contains("not configured"), "got: {body:?}");
        assert!(!body.contains("event: delta"));
    }

    #[test]
    fn assistant_with_bad_body_emits_error_event() {
        // A configured endpoint still rejects a malformed body before any
        // network call.
        let req = assistant_post("{not json");
        let body = drain_sse(assistant_reply(
            &req,
            Some("http://127.0.0.1:1".into()),
            Some("m".into()),
        ));
        assert!(body.contains("event: error"), "got: {body:?}");
        assert!(body.contains("invalid JSON"), "got: {body:?}");
    }

    #[test]
    fn assistant_rejects_non_post() {
        let reply = assistant_reply(&req("/api/assistant"), None, None);
        let Reply::Response(resp) = reply else {
            panic!("expected a plain response for a non-POST");
        };
        assert_eq!(resp.status, 405);
    }
}