import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import tailwindcss from "@tailwindcss/vite"; import vue from "@vitejs/plugin-vue"; import { parse } from "smol-toml"; import { defineConfig, type Plugin } from "vite"; const webRoot = path.dirname(fileURLToPath(import.meta.url)); const workspaceRoot = path.resolve(webRoot, "../.."); type TomlTable = Record; function isTable(value: unknown): value is TomlTable { return typeof value === "object" && value !== null && !Array.isArray(value); } function deepMerge(base: TomlTable, overlay: TomlTable): TomlTable { const next: TomlTable = { ...base }; for (const [key, value] of Object.entries(overlay)) { const existing = next[key]; if (isTable(existing) && isTable(value)) { next[key] = deepMerge(existing, value); } else { next[key] = value; } } return next; } function readToml(filePath: string): TomlTable { if (!fs.existsSync(filePath)) { return {}; } return parse(fs.readFileSync(filePath, "utf8")) as TomlTable; } /** * Same load order as `AppConfig::load` in the Rust server: * default.toml → {APP_ENV}.toml. Env overrides stay on the server. */ function loadAppConfig(): TomlTable { const configDir = process.env.APP_CONFIG_DIR ? path.resolve(process.env.APP_CONFIG_DIR) : path.join(workspaceRoot, "config"); const env = process.env.APP_ENV ?? "development"; return deepMerge( readToml(path.join(configDir, "default.toml")), readToml(path.join(configDir, `${env}.toml`)), ); } function section(config: TomlTable, name: string): TomlTable { const value = config[name]; return isTable(value) ? value : {}; } function generateApiFromSpec(): void { const script = path.join(workspaceRoot, "scripts/generate-api.mjs"); const spec = path.join(workspaceRoot, "packages/contracts/openapi.json"); if (!fs.existsSync(script) || !fs.existsSync(spec)) { return; } const result = spawnSync(process.execPath, [script], { cwd: workspaceRoot, stdio: "inherit", }); if (result.status !== 0) { throw new Error("OpenAPI client generation failed"); } } function openapiClientPlugin(): Plugin { const specPath = path.join(workspaceRoot, "packages/contracts/openapi.json"); return { name: "openapi-client", buildStart() { generateApiFromSpec(); this.addWatchFile(specPath); }, handleHotUpdate(ctx) { if (ctx.file === specPath) { generateApiFromSpec(); } }, }; } const appConfig = loadAppConfig(); const server = section(appConfig, "server"); const frontend = section(appConfig, "frontend"); const backendHost = String(server.host ?? "127.0.0.1"); const backendPort = Number(server.port ?? 8080); const frontendHost = String(frontend.host ?? "127.0.0.1"); const frontendPort = Number(frontend.port ?? 5173); const backendOrigin = `http://${backendHost}:${backendPort}`; export default defineConfig({ plugins: [vue(), tailwindcss(), openapiClientPlugin()], resolve: { alias: { "@": path.join(webRoot, "src"), }, }, server: { host: frontendHost, port: frontendPort, strictPort: true, proxy: { "/api": { target: backendOrigin, changeOrigin: true, ws: true }, "/health": { target: backendOrigin, changeOrigin: true }, }, }, preview: { host: frontendHost, port: frontendPort, proxy: { "/api": { target: backendOrigin, changeOrigin: true }, "/health": { target: backendOrigin, changeOrigin: true }, }, }, });