feat(frontend): embed SPA assets into app binary at compile time
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions

- Add libs/frontend crate: build.rs runs pnpm build, copies dist/ to
  OUT_DIR/dist_blobs/, generates frontend.rs with lazy_static! map
- libs/api/dist.rs serves embedded assets via serve_frontend handler
- Register /{path:.*} SPA fallback in route.rs (after /api/*)
- Remove frontend container from deploy: docker/frontend.Dockerfile,
  deploy/templates/frontend-*.yaml, values.yaml frontend section
- Update ingress: gitdata.ai root now routes to app service
- Update scripts: build.js removes frontend step, deploy.js removes frontend
This commit is contained in:
ZhenYi 2026-04-17 12:04:34 +08:00
parent 3de4fff11d
commit 7e42139989
19 changed files with 221 additions and 249 deletions

53
Cargo.lock generated
View File

@ -564,8 +564,10 @@ dependencies = [
"config", "config",
"db", "db",
"email", "email",
"frontend",
"futures", "futures",
"git", "git",
"mime_guess2",
"models", "models",
"queue", "queue",
"room", "room",
@ -2544,6 +2546,14 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "frontend"
version = "0.2.9"
dependencies = [
"lazy_static",
"walkdir",
]
[[package]] [[package]]
name = "funty" name = "funty"
version = "2.0.0" version = "2.0.0"
@ -4364,6 +4374,7 @@ checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca"
dependencies = [ dependencies = [
"mime", "mime",
"phf", "phf",
"phf_codegen",
"phf_shared", "phf_shared",
"unicase", "unicase",
] ]
@ -5042,6 +5053,16 @@ dependencies = [
"phf_shared", "phf_shared",
] ]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]] [[package]]
name = "phf_generator" name = "phf_generator"
version = "0.11.3" version = "0.11.3"
@ -6311,6 +6332,15 @@ dependencies = [
"cipher 0.4.4", "cipher 0.4.4",
] ]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.29" version = "0.1.29"
@ -8040,6 +8070,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@ -8266,6 +8306,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"
@ -8765,6 +8814,10 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "workspace"
version = "0.2.9"
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.2" version = "0.6.2"

View File

@ -1,5 +1,6 @@
[workspace] [workspace]
members = [ members = [
"libs/frontend",
"libs/models", "libs/models",
"libs/session", "libs/session",
"libs/git", "libs/git",
@ -30,6 +31,7 @@ resolver = "3"
[workspace.dependencies] [workspace.dependencies]
models = { path = "libs/models" } models = { path = "libs/models" }
frontend = { path = "libs/frontend" }
session = { path = "libs/session" } session = { path = "libs/session" }
git = { path = "libs/git" } git = { path = "libs/git" }
email = { path = "libs/email" } email = { path = "libs/email" }
@ -140,11 +142,14 @@ hostname = "0.4"
utoipa = { version = "5.4.0", features = ["chrono", "uuid"] } utoipa = { version = "5.4.0", features = ["chrono", "uuid"] }
rust_decimal = "1.40.0" rust_decimal = "1.40.0"
walkdir = "2.5.0" walkdir = "2.5.0"
lazy_static = "1.5"
moka = "0.12.15" moka = "0.12.15"
serde = "1.0.228" serde = "1.0.228"
serde_json = "1.0.149" serde_json = "1.0.149"
serde_yaml = "0.9.33" serde_yaml = "0.9.33"
serde_bytes = "0.11.19" serde_bytes = "0.11.19"
phf = "0.13.1"
phf_codegen = "0.13.1"
base64 = "0.22.1" base64 = "0.22.1"
@ -183,4 +188,23 @@ opt-level = 3
[profile.dev.package.num-bigint-dig] [profile.dev.package.num-bigint-dig]
opt-level = 3 opt-level = 3
[package]
name = "workspace"
version.workspace = true
edition.workspace = true
authors.workspace = true
description.workspace = true
repository.workspace = true
readme.workspace = true
homepage.workspace = true
license.workspace = true
keywords.workspace = true
categories.workspace = true
documentation.workspace = true
[lib]
path = "lib.rs"
crate-type = ["lib"]

View File

@ -1,86 +0,0 @@
{{- if .Values.frontend.enabled -}}
{{- $fullName := include "gitdata.fullname" . -}}
{{- $ns := include "gitdata.namespace" . -}}
{{- $svc := .Values.frontend -}}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $fullName }}-frontend
namespace: {{ $ns }}
labels:
app.kubernetes.io/name: {{ $fullName }}-frontend
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
spec:
replicas: {{ $svc.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ $fullName }}-frontend
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ $fullName }}-frontend
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
{{- if $.Values.image.pullSecrets }}
imagePullSecrets:
{{- range $.Values.image.pullSecrets }}
- name: {{ . }}
{{- end }}
{{- end }}
containers:
- name: frontend
image: "{{ $.Values.image.registry }}/{{ $svc.image.repository }}:{{ $svc.image.tag }}"
imagePullPolicy: {{ $svc.image.pullPolicy | default $.Values.image.pullPolicy }}
ports:
- name: http
containerPort: 80
protocol: TCP
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: {{ $svc.livenessProbe.initialDelaySeconds }}
periodSeconds: {{ $svc.livenessProbe.periodSeconds }}
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: {{ $svc.readinessProbe.initialDelaySeconds }}
periodSeconds: {{ $svc.readinessProbe.periodSeconds }}
{{- with $svc.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $fullName }}-frontend
namespace: {{ $ns }}
labels:
app.kubernetes.io/name: {{ $fullName }}-frontend
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
type: {{ $svc.service.type }}
ports:
- name: http
port: 80
targetPort: 80
protocol: TCP
selector:
app.kubernetes.io/name: {{ $fullName }}-frontend
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

View File

@ -1,61 +0,0 @@
{{- if .Values.frontend.ingress.enabled -}}
{{- $fullName := include "gitdata.fullname" . -}}
{{- $ns := include "gitdata.namespace" . -}}
{{- $ing := .Values.frontend.ingress -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullName }}-frontend
namespace: {{ $ns }}
labels:
app.kubernetes.io/name: {{ $fullName }}-frontend
app.kubernetes.io/instance: {{ .Release.Name }}
annotations:
cert-manager.io/cluster-issuer: {{ $ing.clusterIssuer | default "cloudflare-acme-cluster-issuer" }}
{{- if $ing.annotations }}
{{ toYaml $ing.annotations | indent 4 }}
{{- end }}
{{- if not (hasKey ($ing.annotations | default dict) "nginx.ingress.kubernetes.io/proxy-body-size") }}
{{- if or (not $ing.className) (eq $ing.className "nginx") (contains "nginx" $ing.className) }}
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
{{- end }}
{{- end }}
spec:
{{- if $ing.className }}
ingressClassName: {{ $ing.className }}
{{- end }}
{{- if $ing.tls }}
tls:
{{- range $ing.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- else }}
tls:
{{- range $ing.hosts }}
- hosts:
- {{ .host | quote }}
secretName: {{ $fullName }}-frontend-tls
{{- end }}
{{- end }}
rules:
{{- range $ing.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType | default "Prefix" }}
backend:
service:
name: {{ $fullName }}-frontend
port:
number: 80
{{- end }}
{{- end }}
{{- end }}

View File

@ -24,7 +24,7 @@ spec:
- static.gitdata.ai - static.gitdata.ai
secretName: {{ $fullName }}-tls secretName: {{ $fullName }}-tls
rules: rules:
# Frontend (default), with /api and /ws routed to app # SPA (embedded in app), with /api and /ws
- host: gitdata.ai - host: gitdata.ai
http: http:
paths: paths:
@ -46,9 +46,9 @@ spec:
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
name: {{ $fullName }}-frontend name: {{ $fullName }}-app
port: port:
number: 80 number: {{ .Values.app.service.port }}
- host: api.gitdata.ai - host: api.gitdata.ai
http: http:
paths: paths:

View File

@ -123,41 +123,7 @@ qdrant:
apiKey: APP_QDRANT_API_KEY apiKey: APP_QDRANT_API_KEY
# ============================================================================= # =============================================================================
# Frontend - React SPA # App main web/API service (includes embedded SPA)
# =============================================================================
frontend:
enabled: true
replicaCount: 2
image:
repository: frontend
tag: latest
service:
type: ClusterIP
ingress:
enabled: false
resources:
requests:
cpu: 50m
memory: 64Mi
livenessProbe:
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
initialDelaySeconds: 5
periodSeconds: 5
nodeSelector: {}
tolerations: []
affinity: {}
# =============================================================================
# App main web/API service
# ============================================================================= # =============================================================================
app: app:
enabled: true enabled: true

View File

@ -1,34 +0,0 @@
# Runtime only — frontend built externally via pnpm
FROM nginx:alpine
# Copy pre-built SPA assets (pnpm build outputs to dist/)
COPY dist /usr/share/nginx/html
# nginx configuration for SPA
RUN echo 'resolver 10.96.0.10 valid=10s ipv6=off; \
server { \
listen 80; \
server_name _; \
root /usr/share/nginx/html; \
index index.html; \
location / { \
try_files $uri $uri/ /index.html; \
} \
location /api/ { \
set $upstream_app http://app:8080; \
proxy_pass $upstream_app/api/; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
} \
location /ws/ { \
set $upstream_app http://app:8080; \
proxy_pass $upstream_app/ws/; \
proxy_http_version 1.1; \
proxy_set_header Upgrade $http_upgrade; \
proxy_set_header Connection "upgrade"; \
proxy_set_header Host $host; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 80
ENTRYPOINT ["nginx", "-g", "daemon off;"]

1
lib.rs Normal file
View File

@ -0,0 +1 @@
// Frontend embedding is handled by libs/frontend crate.

View File

@ -27,6 +27,7 @@ slog = { workspace = true }
service = { workspace = true } service = { workspace = true }
session = { workspace = true } session = { workspace = true }
git = { workspace = true } git = { workspace = true }
frontend = { workspace = true }
models = { workspace = true } models = { workspace = true }
room = { workspace = true } room = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
@ -43,5 +44,6 @@ tokio-stream = { workspace = true, features = ["sync"] }
futures = { workspace = true } futures = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt"] } tokio = { workspace = true, features = ["sync", "rt"] }
chrono = { workspace = true } chrono = { workspace = true }
mime_guess2 = { workspace = true, features = ["phf-map"] }
[lints] [lints]
workspace = true workspace = true

1
libs/api/build.rs Normal file
View File

@ -0,0 +1 @@
fn main() {}

29
libs/api/dist.rs Normal file
View File

@ -0,0 +1,29 @@
use actix_web::{web, HttpResponse};
use mime_guess2::MimeGuess;
pub async fn serve_frontend(path: web::Path<String>) -> HttpResponse {
let path = path.into_inner();
let path = if path.is_empty() || path == "/" {
"index.html"
} else {
&path
};
match frontend::get_frontend_asset(path) {
Some(data) => {
let mime = MimeGuess::from_path(path).first_or_octet_stream();
HttpResponse::Ok()
.content_type(mime.as_ref())
.body(data.to_vec())
}
None => {
// Fallback to index.html for SPA routing
match frontend::get_frontend_asset("index.html") {
Some(data) => HttpResponse::Ok()
.content_type("text/html")
.body(data.to_vec()),
None => HttpResponse::NotFound().finish(),
}
}
}
}

View File

@ -14,3 +14,4 @@ pub mod user;
pub mod workspace; pub mod workspace;
pub use error::{api_success, ApiError, ApiResponse}; pub use error::{api_success, ApiError, ApiResponse};
pub mod dist;

View File

@ -26,4 +26,7 @@ pub fn init_routes(cfg: &mut web::ServiceConfig) {
.configure(crate::room::init_room_routes) .configure(crate::room::init_room_routes)
.configure(crate::skill::init_skill_routes), .configure(crate::skill::init_skill_routes),
); );
// SPA fallback — must be registered last so /api/* takes precedence
cfg.route("/{path:.*}", web::get().to(crate::dist::serve_frontend));
} }

10
libs/frontend/Cargo.toml Normal file
View File

@ -0,0 +1,10 @@
[package]
name = "frontend"
version.workspace = true
edition.workspace = true
[dependencies]
lazy_static.workspace = true
[build-dependencies]
walkdir.workspace = true

80
libs/frontend/build.rs Normal file
View File

@ -0,0 +1,80 @@
use std::{env, fs, path::PathBuf, process::Command};
fn run_pnpm(args: &[&str], cwd: &str) {
let mut cmd = if cfg!(target_os = "windows") {
let mut c = Command::new("cmd");
c.args(["/C", "pnpm"]);
c
} else {
Command::new("pnpm")
};
let status = cmd
.args(args)
.current_dir(cwd)
.status()
.expect("failed to run pnpm");
if !status.success() {
panic!("pnpm command failed: {:?}", args);
}
}
fn find_all_file(path: PathBuf) -> Vec<PathBuf> {
let mut files = vec![];
for entry in fs::read_dir(path).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
if path.is_file() {
files.push(path);
} else if path.is_dir() {
files.extend(find_all_file(path));
}
}
files
}
fn main() {
println!("cargo:rerun-if-changed=../../package.json");
println!("cargo:rerun-if-changed=../../pnpm-lock.yaml");
println!("cargo:rerun-if-changed=../../tsconfig.json");
println!("cargo:rerun-if-changed=../../src/");
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let project_root = manifest_dir.parent().unwrap().parent().unwrap();
let node_modules = project_root.join("node_modules");
let _cache_file = node_modules.join(".cache_hash");
// Build frontend using pnpm in project root
println!("cargo:warning=Building frontend...");
run_pnpm(&["run", "build"], project_root.to_str().unwrap());
// Embed dist/ into OUT_DIR as blob files + generated .rs
let dist = project_root.join("dist");
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let blob_dir = out_dir.join("dist_blobs");
fs::create_dir_all(&blob_dir).unwrap();
let mut strs = vec![];
for file in find_all_file(dist.clone()) {
let key = file.strip_prefix(&dist).unwrap()
.components()
.collect::<PathBuf>()
.to_string_lossy()
.replace('\\', "/");
let safe_name = key.replace('/', "_").replace('\\', "_");
let blob_path = blob_dir.join(&safe_name);
fs::copy(&file, &blob_path).unwrap();
let key_literal = format!("\"{}\"", key.replace('"', "\\\""));
strs.push(format!(" ({}, include_bytes!(\"dist_blobs/{}\")),", key_literal, safe_name));
}
let out_file = out_dir.join("frontend.rs");
let content = format!(
"lazy_static::lazy_static! {{\n pub static ref FRONTEND: Vec<(&'static str, &'static [u8])> = vec![\n{} ];\n}}\n",
strs.join("\n")
);
fs::write(&out_file, content).unwrap();
println!("cargo:include={}", out_file.display());
}

8
libs/frontend/src/lib.rs Normal file
View File

@ -0,0 +1,8 @@
//! Embedded frontend static assets, built via pnpm and embedded at compile time.
include!(concat!(env!("OUT_DIR"), "/frontend.rs"));
/// Returns the embedded frontend static asset for the given path, or `None` if not found.
pub fn get_frontend_asset(path: &str) -> Option<&'static [u8]> {
FRONTEND.iter().find(|(k, _)| *k == path).map(|(_, v)| *v)
}

3
pnpm-lock.yaml generated
View File

@ -26,9 +26,6 @@ importers:
'@gitgraph/react': '@gitgraph/react':
specifier: ^1.6.0 specifier: ^1.6.0
version: 1.6.0(react@19.2.4) version: 1.6.0(react@19.2.4)
'@radix-ui/react-slot':
specifier: ^1.2.0
version: 1.2.4(@types/react@19.2.14)(react@19.2.4)
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)) version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1))

View File

@ -27,14 +27,12 @@ const TAG = process.env.TAG || GIT_SHA_SHORT;
const BUILD_TARGET = process.env.TARGET || 'x86_64-unknown-linux-gnu'; const BUILD_TARGET = process.env.TARGET || 'x86_64-unknown-linux-gnu';
const RUST_SERVICES = ['app', 'gitserver', 'email-worker', 'git-hook', 'operator', 'static']; const RUST_SERVICES = ['app', 'gitserver', 'email-worker', 'git-hook', 'operator', 'static'];
const FRONTEND_SERVICE = 'frontend'; const ALL_SERVICES = RUST_SERVICES;
const ALL_SERVICES = [...RUST_SERVICES, FRONTEND_SERVICE];
const args = process.argv.slice(2); const args = process.argv.slice(2);
const targets = args.length > 0 ? args : ALL_SERVICES; const targets = args.length > 0 ? args : ALL_SERVICES;
const rustTargets = targets.filter(s => RUST_SERVICES.includes(s)); const rustTargets = targets;
const needsFrontend = targets.includes(FRONTEND_SERVICE);
const rootDir = path.join(__dirname, '..'); const rootDir = path.join(__dirname, '..');
console.log(`\n=== Build Configuration ===`); console.log(`\n=== Build Configuration ===`);
@ -65,26 +63,7 @@ if (rustTargets.length > 0) {
} }
} }
// Step 2: Build frontend (frontend source is at repo root) // Step 2: Build Docker images (frontend is embedded in app at compile time via libs/frontend)
if (needsFrontend) {
console.log(`\n==> Step 2: Building frontend`);
if (!fs.existsSync(path.join(rootDir, 'vite.config.ts'))) {
console.error(`\n [FAIL] vite.config.ts not found`);
process.exit(1);
}
try {
execSync(
`corepack enable && corepack prepare pnpm@10 --activate && pnpm install --frozen-lockfile && pnpm build`,
{ stdio: 'inherit', cwd: rootDir }
);
console.log(` [OK] Frontend built`);
} catch (error) {
console.error(` [FAIL] Frontend build failed`);
process.exit(1);
}
}
// Step 3: Build Docker images
console.log(`\n==> Step 3: Building Docker images`); console.log(`\n==> Step 3: Building Docker images`);
for (const service of targets) { for (const service of targets) {

View File

@ -31,7 +31,7 @@ const args = process.argv.slice(2);
const isDryRun = args.includes('--dry-run'); const isDryRun = args.includes('--dry-run');
const runMigrate = args.includes('--migrate'); const runMigrate = args.includes('--migrate');
const SERVICES = ['app', 'gitserver', 'email-worker', 'git-hook', 'operator', 'static', 'frontend']; const SERVICES = ['app', 'gitserver', 'email-worker', 'git-hook', 'operator', 'static'];
// Validate kubeconfig // Validate kubeconfig
if (!fs.existsSync(KUBECONFIG)) { if (!fs.existsSync(KUBECONFIG)) {
@ -61,7 +61,6 @@ const setValues = [
`gitHook.image.tag=${TAG}`, `gitHook.image.tag=${TAG}`,
`operator.image.tag=${TAG}`, `operator.image.tag=${TAG}`,
`static.image.tag=${TAG}`, `static.image.tag=${TAG}`,
`frontend.image.tag=${TAG}`,
]; ];
if (runMigrate) { if (runMigrate) {