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