portail 0.1.0

Unified proxy/gateway: AI Gateway + MCP Gateway + CDN cache
Documentation
mod cache;
mod purge;

pub use cache::CacheManager;
pub use cache::stats_logger;
pub use purge::purge_loop;

use axum::body::Bytes;
use axum::extract::Request;
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use std::sync::Arc;
use std::sync::OnceLock;
use tracing::{debug, warn};

static CDN_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();

fn client() -> &'static reqwest::Client {
    CDN_CLIENT.get_or_init(|| {
        reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(30))
            .connect_timeout(std::time::Duration::from_secs(5))
            .build()
            .expect("failed to build CDN HTTP client")
    })
}

pub async fn handle(req: Request, cache: Arc<CacheManager>, origin: Option<String>) -> Response {
    let range = req
        .headers()
        .get("range")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("bytes=0-");
    let cache_key = format!("cdn:{}:{}", req.uri().path(), range);

    if let Some(entry) = cache.get(&cache_key).await {
        debug!(%cache_key, "CDN cache HIT");
        return cdn_response(StatusCode::OK, "HIT", entry);
    }

    let Some(origin) = origin else {
        return (StatusCode::NOT_FOUND, "no origin configured").into_response();
    };

    let origin_url = format!("{}{}", origin.trim_end_matches('/'), req.uri().path());
    let origin_req = client().get(&origin_url).header("range", range);

    match origin_req.send().await {
        Ok(resp) if resp.status().is_success() || resp.status() == StatusCode::PARTIAL_CONTENT => {
            let body = resp.bytes().await.unwrap_or_default();
            cache.put(&cache_key, body.clone()).await;
            debug!(%cache_key, "CDN cache MISS");
            cdn_response(StatusCode::OK, "MISS", body)
        }
        Ok(resp) => {
            warn!(%origin_url, status = %resp.status(), "CDN origin fetch failed");
            (resp.status(), "origin error").into_response()
        }
        Err(e) => {
            warn!(%origin_url, error = %e, "CDN origin unreachable");
            (StatusCode::BAD_GATEWAY, "origin unreachable").into_response()
        }
    }
}

fn cdn_response(status: StatusCode, cache_status: &'static str, body: Bytes) -> Response {
    let mut headers = HeaderMap::new();
    headers.insert("x-cache-status", HeaderValue::from_static(cache_status));
    headers.insert("content-type", HeaderValue::from_static("application/octet-stream"));
    headers.insert("content-length", HeaderValue::from_str(&body.len().to_string()).unwrap());
    (status, headers, body).into_response()
}

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

    #[test]
    fn cdn_response_headers() {
        let resp = cdn_response(StatusCode::OK, "HIT", Bytes::from("data"));
        let (parts, _body) = resp.into_parts();
        assert_eq!(parts.status, StatusCode::OK);
        assert_eq!(
            parts.headers.get("x-cache-status").unwrap().to_str().unwrap(),
            "HIT"
        );
        assert_eq!(parts.headers.get("content-length").unwrap(), "4");
    }
}