diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index bb68275..afaf49b 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -42,5 +42,25 @@ services: retries: 3 start_period: 40s + frontend: + build: + context: ../web/normogen-web + 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", "-f", "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..4a8bf6a --- /dev/null +++ b/web/frontend-server/src/main.rs @@ -0,0 +1,139 @@ +//! Normogen frontend server — a minimal Axum app that: +//! +//! 1. Serves the built Vite SPA bundle from `dist/` (static files). +//! 2. Falls back to `index.html` for any unmatched route (SPA history mode). +//! 3. Reverse-proxies `/api/*` requests to the backend container. +//! +//! Designed to run as a standalone container, independently scalable. +//! No business logic, no DB — just static serving + proxying. + +use axum::{ + body::Body, + extract::{Request, State}, + http::{HeaderValue, Method, StatusCode, Uri}, + response::{IntoResponse, Response}, + routing::any, + Router, +}; +use std::env; +use tower_http::services::{ServeDir, ServeFile}; +use tower_http::trace::TraceLayer; + +/// Shared state: the backend URL + a reqwest client for proxying. +#[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/* to the backend. + .route("/api/{*rest}", 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, + mut req: Request, +) -> Response { + // Build the target URL: backend_url + original path + query string. + 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); + + // Map the axum request into a reqwest request. + let method = reqwest::Method::from_bytes(req.method().as_str().as_bytes()) + .unwrap_or(reqwest::Method::GET); + + // Collect headers (skip host — reqwest sets its own). + let mut headers = reqwest::header::HeaderMap::new(); + for (name, value) in req.headers().iter() { + if name == "host" { + continue; + } + if let (Ok(name), Ok(value)) = ( + reqwest::header::HeaderName::from_bytes(name.as_str().as_bytes()), + reqwest::header::HeaderValue::from_bytes(value.as_bytes()), + ) { + headers.insert(name, value); + } + } + + // Collect the body. + 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(); + } + }; + + // Map the reqwest response back into an axum 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..4b22a88 --- /dev/null +++ b/web/normogen-web/Dockerfile @@ -0,0 +1,54 @@ +# 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 package.json package-lock.json ./ +RUN npm ci + +# Copy the source and build. +COPY . . +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 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 \ + && 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"]