Menu
Public repository / 3a1d4dc0df9425a910df82402300a2aeb40318ae
AkurAI-Framework / crates/cli/src/cmd_serve.rs
Latest change 7d64b4511fc8d94d569ca5b7df933a213ab69a05 - feat(cli): route layouts (shared shell templates) 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::dev_reload;
use crate::guestbook;
use crate::mime;
use crate::routes;
use crate::seo;
use crate::store;
use akurai_http::{
sse, MiddlewareStack, Reply, Request, RequestLogger, Response, SecurityHeaders, Server, Timing,
};
use akurai_json::Value;
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");
pub struct Config {
pub host: String,
pub port: u16,
pub dir: PathBuf,
/// `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,
}
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 store = store::Store::open(data_dir.join("store.db"))?;
let guestbook = guestbook::State::new(store);
println!("AkurAI-Framework v{VERSION}");
println!(" serving {}", root.display());
println!(" → http://{local}");
if live.is_some() {
println!(" live-reload: on (watching for changes)");
}
// Every response passes out through these (outermost first): security
// headers stamp even short-circuited replies, then request logging + timing.
let middleware = MiddlewareStack::new()
.push(SecurityHeaders)
.push(RequestLogger)
.push(Timing);
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, req)),
// Production: untouched.
None => dispatch(&root, &guestbook, 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`].
fn dispatch(root: &Path, guestbook: &guestbook::State, req: &Request) -> Reply {
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(),
// Form-actions demo: GET lists entries, POST validates + stores + redirects.
"/guestbook" => guestbook::handle(guestbook, root, req),
// SEO essentials, derived from backend/routes.json + page.json.
"/robots.txt" => Reply::Response(seo::robots(root)),
"/sitemap.xml" => Reply::Response(seo::sitemap(root)),
// `/_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, 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));
}
})
}
fn route(root: &Path, req: &Request) -> Response {
match req.path.as_str() {
"/api/health" => json(200, &health()),
"/api/_meta" => json(200, &meta_manifest()),
p if p.starts_with("/api/") => json(
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"),
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)
}
// Static assets (anything with a non-.html extension) are served raw.
p if is_static_asset(p) => serve_static(root, p),
// Everything else is a page, routed through the declarative router.
p => render_page_routed(root, p),
}
}
/// 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).
fn render_page_routed(root: &Path, path: &str) -> 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) => render_page_with(
root,
&matched.value.template,
matched.value.layout.as_deref(),
&matched.params,
&seo::route_meta(root, path),
),
None => render_page_with(root, "index", None, &[], &[]),
},
None => render_page(root, &template_name(path)),
}
}
/// 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.
fn render_content(root: &Path, rel: &str, active: &str) -> 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())));
}
match engine.render("docs", &context) {
Ok(page) => 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, &[], &[])
}
/// 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 `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.
fn render_page_with(
root: &Path,
name: &str,
layout: 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 {
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 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) => 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,
}
}
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());
Response::ok().with_body(ct, bytes)
}
Err(_) => Response::not_found(),
}
}
/// Serialize a JSON value into a response with the given status.
fn json(status: u16, value: &Value) -> Response {
Response::new(status).with_body(
"application/json; charset=utf-8",
value.to_json().into_bytes(),
)
}
fn health() -> Value {
Value::Object(vec![
("framework".into(), Value::Str("AkurAI-Framework".into())),
("version".into(), Value::Str(VERSION.into())),
("status".into(), Value::Str("ok".into())),
])
}
/// A minimal self-describing manifest. Grows automatically as the auto-API
/// layer registers collection routes.
fn meta_manifest() -> Value {
let route = |method: &str, path: &str| {
Value::Object(vec![
("method".into(), Value::Str(method.into())),
("path".into(), Value::Str(path.into())),
])
};
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(), Value::Array(vec![])),
])
}
#[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()
}
#[test]
fn health_returns_version_json() {
let root = Path::new("nonexistent");
let r = route(root, &req("/api/health"));
let body = String::from_utf8(r.body.clone()).unwrap();
assert_eq!(r.status, 200);
assert!(body.contains("\"version\":\"") && body.contains(VERSION));
assert!(body.contains("\"status\":\"ok\""));
}
#[test]
fn unknown_api_route_is_404() {
let r = route(Path::new("."), &req("/api/does-not-exist"));
assert_eq!(r.status, 404);
}
#[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"));
}
}