diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index bb68275..8b5e777 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -42,5 +42,26 @@ services: retries: 3 start_period: 40s + frontend: + build: + context: .. + dockerfile: web/normogen-web/Dockerfile + container_name: normogen-frontend + restart: unless-stopped + ports: + - "8080:8080" + environment: + - BACKEND_URL=http://backend:6500 + - FRONTEND_PORT=8080 + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:8080/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + volumes: mongodb_data: diff --git a/web/frontend-server/Cargo.toml b/web/frontend-server/Cargo.toml new file mode 100644 index 0000000..00b6189 --- /dev/null +++ b/web/frontend-server/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "normogen-frontend-server" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "normogen-frontend-server" +path = "src/main.rs" + +[dependencies] +axum = "0.7" +tokio = { version = "1", features = ["full"] } +tower-http = { version = "0.5", features = ["fs", "trace"] } +reqwest = { version = "0.12", features = ["json"] } diff --git a/web/frontend-server/src/main.rs b/web/frontend-server/src/main.rs new file mode 100644 index 0000000..e55427b --- /dev/null +++ b/web/frontend-server/src/main.rs @@ -0,0 +1,127 @@ +//! 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 +} diff --git a/web/normogen-web/Dockerfile b/web/normogen-web/Dockerfile new file mode 100644 index 0000000..a456606 --- /dev/null +++ b/web/normogen-web/Dockerfile @@ -0,0 +1,55 @@ +# Multi-stage Dockerfile for the Normogen frontend server. +# +# Stage 1: Build the Vite SPA bundle. +# Stage 2: Build the Axum frontend-server binary. +# Stage 3: Slim runtime with the binary + static files. + +# ============================================================================ +# Stage 1: Node — build the Vite SPA +# ============================================================================ +FROM node:20-slim AS node-builder + +WORKDIR /app + +# Copy package files and install deps. +COPY web/normogen-web/package.json web/normogen-web/package-lock.json ./ +RUN npm ci + +# Copy the source and build. +COPY web/normogen-web/ ./ +RUN npm run build + +# ============================================================================ +# Stage 2: Rust — build the frontend-server binary +# ============================================================================ +FROM rust:latest AS rust-builder + +WORKDIR /server + +# Copy the frontend-server crate. +COPY web/frontend-server/ ./ + +# Build release. +RUN cargo build --release + +# ============================================================================ +# Stage 3: Runtime — slim image with binary + static files +# ============================================================================ +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy the frontend-server binary. +COPY --from=rust-builder /server/target/release/normogen-frontend-server /app/frontend-server + +# Copy the built static files. +COPY --from=node-builder /app/dist /app/dist + +EXPOSE 8080 + +CMD ["./frontend-server"]