Menu
Public repository / 3ad787ae157fc60f711c536c1811b3b199b0ac1a
AkurAI-Framework / crates/cli/src/cmd_serve.rs
Latest change 3ad787ae157fc60f711c536c1811b3b199b0ac1a - init: AkurAI-Framework foundation (history reset after FS corruption) 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::mime;
use akurai_http::{Request, Response, Server};
use akurai_json::Value;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
const VERSION: &str = env!("CARGO_PKG_VERSION");
pub struct Config {
pub host: String,
pub port: u16,
pub dir: PathBuf,
}
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();
println!("AkurAI-Framework v{VERSION}");
println!(" serving {}", root.display());
println!(" → http://{local}");
server.run(move |req: &Request| route(&root, req))
}
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()))]),
),
_ => serve_static(root, &req.path),
}
}
fn serve_static(root: &Path, req_path: &str) -> Response {
match resolve(root, req_path) {
Some(path) if path.is_file() => read_file(&path),
// SPA fallback: hand unknown routes to index.html for client routing.
_ => {
let index = root.join("index.html");
if index.is_file() {
read_file(&index)
} else {
Response::not_found()
}
}
}
}
/// 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"));
}
}