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
|
||||
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:
|
||||
|
|
|
|||
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