Merge feat/frontend-container
Separate Axum frontend container: static SPA serving + API proxy. Independently scalable — run N replicas behind a load balancer.
This commit is contained in:
commit
a5006bff63
4 changed files with 217 additions and 0 deletions
|
|
@ -42,5 +42,26 @@ services:
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
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:
|
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"] }
|
||||||
127
web/frontend-server/src/main.rs
Normal file
127
web/frontend-server/src/main.rs
Normal file
|
|
@ -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<ProxyState>,
|
||||||
|
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
|
||||||
|
}
|
||||||
55
web/normogen-web/Dockerfile
Normal file
55
web/normogen-web/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue