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("<"),
'>' => escaped.push_str(">"),
'&' => escaped.push_str("&"),
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);
}
}