/** * @file vite.config.ts * @description Vite build and dev-server configuration for the dashboard client — React plugin, an API/WebSocket proxy that honours DASHBOARD_PORT, build-time injection of the project version as `__APP_VERSION__`, one chunk per UI language, hidden source maps, and pre-compressed `.br`/`.gz` siblings of every text asset ≥ 1 KB (served by the server for `/assets/`). * @author Nguyễn Ngọc Trí Vĩ */ import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { brotliCompressSync, constants, gzipSync } from "node:zlib"; import { defineConfig, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; // The dashboard's displayed version is the canonical project version from the // repo-root package.json (the version CI cuts releases from), injected at build // time as the `__APP_VERSION__` global so the UI footer always shows the real // version instead of a hardcoded string. Vite runs from the client dir, so the // root manifest is normally one level up; fall back to the client manifest, and // finally a placeholder, so the build never fails when the root file is absent // (e.g. a Docker stage that only copies client/). The global is declared in // `client/src/vite-env.d.ts`. function resolveAppVersion(): string { for (const rel of ["../package.json", "package.json"]) { try { const { version } = JSON.parse(readFileSync(resolve(process.cwd(), rel), "utf8")); if (version) return version as string; } catch { // Not found or unreadable at this path — try the next candidate. } } return "0.0.0"; } const APP_VERSION = resolveAppVersion(); // Honour DASHBOARD_PORT so the proxy follows when `npm run dev:server` is // moved off the default 4820 (e.g. when an SSH `LocalForward` already holds // 4820 on `127.0.0.1` and `::1`). The dev server reads the same env var from // `server/index.js`, so a single `DASHBOARD_PORT=4821 npm run dev` keeps // both sides in lockstep. // // We also target `127.0.0.1` rather than `localhost`: when several listeners // exist on the same port across IP families (loopback-specific SSH binds vs. // Node's wildcard listen), macOS routes connections by socket specificity, // so `localhost` can resolve into the wrong process. An explicit IPv4 loopback // is what the embedded server in production binds to anyway. const DASHBOARD_PORT = parseInt(process.env.DASHBOARD_PORT || "4820", 10); // Writes `.br` (quality 11) and `.gz` (level 9) next to every text // asset of 1 KB or more once the build is on disk, so the server can send the // smallest encoding without compressing per request. node:zlib only. function precompress(): Plugin { let outDir = "dist"; const walk = (dir: string): string[] => readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)] ); return { name: "agenthub-precompress", apply: "build", configResolved(c) { outDir = resolve(c.root, c.build.outDir); }, closeBundle() { for (const file of walk(outDir)) { if (!/\.(js|css|svg|json|html)$/.test(file)) continue; const buf = readFileSync(file); if (buf.length < 1024) continue; writeFileSync( `${file}.br`, brotliCompressSync(buf, { params: { [constants.BROTLI_PARAM_QUALITY]: 11, [constants.BROTLI_PARAM_SIZE_HINT]: buf.length, }, }) ); writeFileSync(`${file}.gz`, gzipSync(buf, { level: 9 })); } }, }; } export default defineConfig({ plugins: [react(), precompress()], define: { __APP_VERSION__: JSON.stringify(APP_VERSION), }, server: { port: 5173, proxy: { "/api": { target: `http://127.0.0.1:${DASHBOARD_PORT}`, changeOrigin: true, }, "/ws": { target: `ws://127.0.0.1:${DASHBOARD_PORT}`, ws: true, }, }, }, build: { outDir: "dist", // Maps are still written (for debugging a build), but no bundle points at // them, so a browser never downloads them. sourcemap: "hidden", rollupOptions: { output: { // i18n/index.ts loads locale files lazily: one chunk per language. manualChunks(id) { const m = /[\\/]i18n[\\/]locales[\\/](\w+)[\\/]/.exec(id); return m ? `locale-${m[1]}` : undefined; }, }, }, }, });