//! Normogen frontend server — a minimal Axum app that: //! //! 1. Reverse-proxies `/api/*` requests to the backend container. //! 2. Serves the built Vite SPA bundle from `dist/` (static files). //! 3. Falls back to `index.html` for any unmatched route (SPA history mode). use axum::{ body::Body, extract::{Request, State}, http::StatusCode, response::{IntoResponse, Response}, routing::any, Router, }; use std::env; use tower_http::services::{ServeDir, ServeFile}; use tower_http::trace::TraceLayer; #[derive(Clone)] struct ProxyState { backend_url: String, client: reqwest::Client, } #[tokio::main] async fn main() { let backend_url = env::var("BACKEND_URL").unwrap_or_else(|_| "http://backend:6500".to_string()); let port: u16 = env::var("FRONTEND_PORT") .unwrap_or_else(|_| "8080".to_string()) .parse() .expect("FRONTEND_PORT must be a number"); let client = reqwest::Client::builder() .build() .expect("failed to build reqwest client"); let state = ProxyState { backend_url: backend_url.clone(), client, }; // Static file serving with SPA fallback. let serve_dir = ServeDir::new("dist").fallback(ServeFile::new("dist/index.html")); let app = Router::new() // Proxy /api and everything under /api/ to the backend. .route("/api", any(proxy_to_backend)) .route("/api/*path", any(proxy_to_backend)) .with_state(state) // Everything else: static files + SPA fallback. .fallback_service(serve_dir) .layer(TraceLayer::new_for_http()); let addr = format!("0.0.0.0:{port}"); eprintln!("Frontend server listening on {addr}"); eprintln!("Proxying /api/* to {backend_url}"); eprintln!("Serving static files from dist/"); let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind failed"); axum::serve(listener, app).await.expect("server error"); } /// Forward a request to the backend, preserving method, headers, body, and /// query string. Return the backend's response. async fn proxy_to_backend( State(state): State, req: Request, ) -> Response { let path = req.uri().path(); let query = req.uri().query().map(|q| format!("?{q}")).unwrap_or_default(); let target_url = format!("{}{}{}", state.backend_url, path, query); let method = reqwest::Method::from_bytes(req.method().as_str().as_bytes()) .unwrap_or(reqwest::Method::GET); let mut headers = reqwest::header::HeaderMap::new(); for (name, value) in req.headers().iter() { if name == "host" { continue; } if let (Ok(n), Ok(v)) = ( reqwest::header::HeaderName::from_bytes(name.as_str().as_bytes()), reqwest::header::HeaderValue::from_bytes(value.as_bytes()), ) { headers.insert(n, v); } } let body_bytes = axum::body::to_bytes(req.into_body(), usize::MAX) .await .unwrap_or_default(); let backend_req = state .client .request(method, &target_url) .headers(headers) .body(body_bytes); let backend_resp = match backend_req.send().await { Ok(r) => r, Err(e) => { eprintln!("Proxy error to {target_url}: {e}"); return (StatusCode::BAD_GATEWAY, "Failed to reach backend").into_response(); } }; let status = StatusCode::from_u16(backend_resp.status().as_u16()) .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); let mut resp_headers = axum::http::HeaderMap::new(); for (name, value) in backend_resp.headers().iter() { if let (Ok(n), Ok(v)) = ( axum::http::HeaderName::from_bytes(name.as_str().as_bytes()), axum::http::HeaderValue::from_bytes(value.as_bytes()), ) { resp_headers.insert(n, v); } } let body = backend_resp.bytes().await.unwrap_or_default(); let mut response = Response::new(Body::from(body)); *response.status_mut() = status; *response.headers_mut() = resp_headers; response }