feat: separate frontend container (Axum static serving + API proxy)
A dedicated Rust/Axum binary (web/frontend-server/) that: - Serves the built Vite SPA bundle from dist/ via tower-http ServeDir - Falls back to index.html for SPA routing (deep links work) - Reverse-proxies /api/* to the backend container (same-origin, no CORS) - Listens on port 8080 (configurable via FRONTEND_PORT) Dockerfile (web/normogen-web/Dockerfile): 3-stage build: 1. Node: npm ci + npm run build -> dist/ 2. Rust: cargo build --release the frontend-server binary 3. Runtime: debian-slim + binary + dist/ docker-compose.yml: new 'frontend' service on :8080, depends on backend healthy, proxies to http://backend:6500. Independently scalable (run N replicas behind a load balancer). Architecture: Browser -> :8080 (frontend container) /api/* -> proxy -> backend:6500 /* -> static dist/ (SPA)
This commit is contained in:
parent
43a427e2dd
commit
1b5c1e2a06
4 changed files with 227 additions and 0 deletions
|
|
@ -42,5 +42,25 @@ services:
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
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:
|
volumes:
|
||||||
mongodb_data:
|
mongodb_data:
|
||||||
|
|
|
||||||
14
web/frontend-server/Cargo.toml
Normal file
14
web/frontend-server/Cargo.toml
Normal file
|
|
@ -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"] }
|
||||||
139
web/frontend-server/src/main.rs
Normal file
139
web/frontend-server/src/main.rs
Normal file
|
|
@ -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<ProxyState>,
|
||||||
|
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
|
||||||
|
}
|
||||||
54
web/normogen-web/Dockerfile
Normal file
54
web/normogen-web/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue