diff --git a/Cargo.lock b/Cargo.lock index fc78320..2dffe4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -564,8 +564,10 @@ dependencies = [ "config", "db", "email", + "frontend", "futures", "git", + "mime_guess2", "models", "queue", "room", @@ -2544,6 +2546,14 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "frontend" +version = "0.2.9" +dependencies = [ + "lazy_static", + "walkdir", +] + [[package]] name = "funty" version = "2.0.0" @@ -4364,6 +4374,7 @@ checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca" dependencies = [ "mime", "phf", + "phf_codegen", "phf_shared", "unicase", ] @@ -5042,6 +5053,16 @@ dependencies = [ "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]] name = "phf_generator" version = "0.11.3" @@ -6311,6 +6332,15 @@ dependencies = [ "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]] name = "schannel" version = "0.1.29" @@ -8040,6 +8070,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "want" version = "0.3.1" @@ -8266,6 +8306,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -8765,6 +8814,10 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "workspace" +version = "0.2.9" + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 799fadd..e143985 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "libs/frontend", "libs/models", "libs/session", "libs/git", @@ -30,6 +31,7 @@ resolver = "3" [workspace.dependencies] models = { path = "libs/models" } +frontend = { path = "libs/frontend" } session = { path = "libs/session" } git = { path = "libs/git" } email = { path = "libs/email" } @@ -140,11 +142,14 @@ hostname = "0.4" utoipa = { version = "5.4.0", features = ["chrono", "uuid"] } rust_decimal = "1.40.0" walkdir = "2.5.0" +lazy_static = "1.5" moka = "0.12.15" serde = "1.0.228" serde_json = "1.0.149" serde_yaml = "0.9.33" serde_bytes = "0.11.19" +phf = "0.13.1" +phf_codegen = "0.13.1" base64 = "0.22.1" @@ -183,4 +188,23 @@ opt-level = 3 [profile.dev.package.num-bigint-dig] -opt-level = 3 \ No newline at end of file +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"] \ No newline at end of file diff --git a/deploy/templates/frontend-deployment.yaml b/deploy/templates/frontend-deployment.yaml deleted file mode 100644 index dd1aca0..0000000 --- a/deploy/templates/frontend-deployment.yaml +++ /dev/null @@ -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 }} diff --git a/deploy/templates/frontend-ingress.yaml b/deploy/templates/frontend-ingress.yaml deleted file mode 100644 index 7259024..0000000 --- a/deploy/templates/frontend-ingress.yaml +++ /dev/null @@ -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 }} diff --git a/deploy/templates/ingress.yaml b/deploy/templates/ingress.yaml index ed69621..32e0b56 100644 --- a/deploy/templates/ingress.yaml +++ b/deploy/templates/ingress.yaml @@ -24,7 +24,7 @@ spec: - static.gitdata.ai secretName: {{ $fullName }}-tls rules: - # Frontend (default), with /api and /ws routed to app + # SPA (embedded in app), with /api and /ws - host: gitdata.ai http: paths: @@ -46,9 +46,9 @@ spec: pathType: Prefix backend: service: - name: {{ $fullName }}-frontend + name: {{ $fullName }}-app port: - number: 80 + number: {{ .Values.app.service.port }} - host: api.gitdata.ai http: paths: diff --git a/deploy/values.yaml b/deploy/values.yaml index a528c92..21ab647 100644 --- a/deploy/values.yaml +++ b/deploy/values.yaml @@ -123,41 +123,7 @@ qdrant: apiKey: APP_QDRANT_API_KEY # ============================================================================= -# Frontend - React 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 – main web/API service (includes embedded SPA) # ============================================================================= app: enabled: true diff --git a/docker/frontend.Dockerfile b/docker/frontend.Dockerfile deleted file mode 100644 index db73d9f..0000000 --- a/docker/frontend.Dockerfile +++ /dev/null @@ -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;"] diff --git a/lib.rs b/lib.rs new file mode 100644 index 0000000..816f498 --- /dev/null +++ b/lib.rs @@ -0,0 +1 @@ +// Frontend embedding is handled by libs/frontend crate. diff --git a/libs/api/Cargo.toml b/libs/api/Cargo.toml index 76ac35d..0e9ab2d 100644 --- a/libs/api/Cargo.toml +++ b/libs/api/Cargo.toml @@ -27,6 +27,7 @@ slog = { workspace = true } service = { workspace = true } session = { workspace = true } git = { workspace = true } +frontend = { workspace = true } models = { workspace = true } room = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -43,5 +44,6 @@ tokio-stream = { workspace = true, features = ["sync"] } futures = { workspace = true } tokio = { workspace = true, features = ["sync", "rt"] } chrono = { workspace = true } +mime_guess2 = { workspace = true, features = ["phf-map"] } [lints] workspace = true diff --git a/libs/api/build.rs b/libs/api/build.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/libs/api/build.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/libs/api/dist.rs b/libs/api/dist.rs new file mode 100644 index 0000000..826f0c8 --- /dev/null +++ b/libs/api/dist.rs @@ -0,0 +1,29 @@ +use actix_web::{web, HttpResponse}; +use mime_guess2::MimeGuess; + +pub async fn serve_frontend(path: web::Path) -> 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(), + } + } + } +} diff --git a/libs/api/lib.rs b/libs/api/lib.rs index 031eaac..9c2e52a 100644 --- a/libs/api/lib.rs +++ b/libs/api/lib.rs @@ -14,3 +14,4 @@ pub mod user; pub mod workspace; pub use error::{api_success, ApiError, ApiResponse}; +pub mod dist; diff --git a/libs/api/route.rs b/libs/api/route.rs index 5d069c7..a1096f1 100644 --- a/libs/api/route.rs +++ b/libs/api/route.rs @@ -26,4 +26,7 @@ pub fn init_routes(cfg: &mut web::ServiceConfig) { .configure(crate::room::init_room_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)); } diff --git a/libs/frontend/Cargo.toml b/libs/frontend/Cargo.toml new file mode 100644 index 0000000..48f2f2b --- /dev/null +++ b/libs/frontend/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "frontend" +version.workspace = true +edition.workspace = true + +[dependencies] +lazy_static.workspace = true + +[build-dependencies] +walkdir.workspace = true diff --git a/libs/frontend/build.rs b/libs/frontend/build.rs new file mode 100644 index 0000000..f895914 --- /dev/null +++ b/libs/frontend/build.rs @@ -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 { + 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::() + .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()); +} diff --git a/libs/frontend/src/lib.rs b/libs/frontend/src/lib.rs new file mode 100644 index 0000000..af8c602 --- /dev/null +++ b/libs/frontend/src/lib.rs @@ -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) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b8c7c4..391d3d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: '@gitgraph/react': specifier: ^1.6.0 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': 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)) diff --git a/scripts/build.js b/scripts/build.js index 584b825..63c647f 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -27,14 +27,12 @@ const TAG = process.env.TAG || GIT_SHA_SHORT; const BUILD_TARGET = process.env.TARGET || 'x86_64-unknown-linux-gnu'; const RUST_SERVICES = ['app', 'gitserver', 'email-worker', 'git-hook', 'operator', 'static']; -const FRONTEND_SERVICE = 'frontend'; -const ALL_SERVICES = [...RUST_SERVICES, FRONTEND_SERVICE]; +const ALL_SERVICES = RUST_SERVICES; const args = process.argv.slice(2); const targets = args.length > 0 ? args : ALL_SERVICES; -const rustTargets = targets.filter(s => RUST_SERVICES.includes(s)); -const needsFrontend = targets.includes(FRONTEND_SERVICE); +const rustTargets = targets; const rootDir = path.join(__dirname, '..'); console.log(`\n=== Build Configuration ===`); @@ -65,26 +63,7 @@ if (rustTargets.length > 0) { } } -// Step 2: Build frontend (frontend source is at repo root) -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 +// Step 2: Build Docker images (frontend is embedded in app at compile time via libs/frontend) console.log(`\n==> Step 3: Building Docker images`); for (const service of targets) { diff --git a/scripts/deploy.js b/scripts/deploy.js index 5353365..4032ccd 100644 --- a/scripts/deploy.js +++ b/scripts/deploy.js @@ -31,7 +31,7 @@ const args = process.argv.slice(2); const isDryRun = args.includes('--dry-run'); 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 if (!fs.existsSync(KUBECONFIG)) { @@ -61,7 +61,6 @@ const setValues = [ `gitHook.image.tag=${TAG}`, `operator.image.tag=${TAG}`, `static.image.tag=${TAG}`, - `frontend.image.tag=${TAG}`, ]; if (runMigrate) {