// Control UI config module wires vite behavior. import { execFileSync } from "node:child_process"; import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Plugin, UserConfig } from "vite"; import { controlUiManualChunk } from "./config/control-ui-chunking.ts"; import { deriveControlUiBuildId, normalizeControlUiBuildId, normalizeControlUiBranch, normalizeControlUiBuildTimestamp, normalizeControlUiCommit, type ControlUiBuildInfo, } from "./src/build-info.ts"; const here = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(here, ".."); const outDir = path.resolve(here, "../dist/control-ui"); const require = createRequire(import.meta.url); const json5EsmPath = require.resolve("json5/dist/index.mjs"); type ControlUiViteAlias = { find: string | RegExp; replacement: string; }; const commonJsOptimizeDeps = [ "highlight.js/lib/core", "highlight.js/lib/languages/bash", "highlight.js/lib/languages/cpp", "highlight.js/lib/languages/css", "highlight.js/lib/languages/diff", "highlight.js/lib/languages/go", "highlight.js/lib/languages/java", "highlight.js/lib/languages/javascript", "highlight.js/lib/languages/json", "highlight.js/lib/languages/markdown", "highlight.js/lib/languages/python", "highlight.js/lib/languages/rust", "highlight.js/lib/languages/typescript", "highlight.js/lib/languages/xml", "highlight.js/lib/languages/yaml", ] as const; function normalizeBase(input: string): string { const trimmed = input.trim(); if (!trimmed) { return "/"; } if (trimmed === "./") { return "./"; } if (trimmed.endsWith("/")) { return trimmed; } return `${trimmed}/`; } function readPackageVersion(): string | null { try { const raw = fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"); const parsed = JSON.parse(raw) as { version?: unknown }; return typeof parsed.version === "string" && parsed.version.trim() ? parsed.version.trim() : null; } catch { return null; } } function readGitCommit(): string | null { try { const raw = execFileSync("git", ["-C", repoRoot, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); return raw.trim() || null; } catch { return null; } } function readGitBranch(): string | null { try { const raw = execFileSync("git", ["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); return raw.trim() || null; } catch { return null; } } function readGitDirty(): boolean | null { try { const raw = execFileSync("git", ["-C", repoRoot, "status", "--porcelain"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); return Boolean(raw.trim()); } catch { return null; } } type ControlUiBuildInfoSources = { env?: NodeJS.ProcessEnv; now?: () => Date; readPackageVersion?: () => string | null; readGitCommit?: () => string | null; readGitBranch?: () => string | null; readGitDirty?: () => boolean | null; }; function normalizeBuildTimestamp(value: string | undefined, now: () => Date): string | null { const explicit = value?.trim(); if (explicit) { const timestamp = normalizeControlUiBuildTimestamp(explicit); if (!timestamp) { throw new Error( "OPENCLAW_BUILD_TIMESTAMP must be a valid UTC ISO-8601 timestamp ending in Z", ); } return timestamp; } const candidate = now(); return Number.isNaN(candidate.getTime()) ? null : candidate.toISOString(); } export function resolveControlUiBuildInfo( sources: ControlUiBuildInfoSources = {}, ): ControlUiBuildInfo { const env = sources.env ?? process.env; const version = (sources.readPackageVersion ?? readPackageVersion)(); const explicitCommitSource = [ { name: "GIT_COMMIT", value: env.GIT_COMMIT?.trim() }, { name: "GIT_SHA", value: env.GIT_SHA?.trim() }, ].find((source) => source.value); const explicitCommit = explicitCommitSource?.value; const envCommit = explicitCommit ? normalizeControlUiCommit(explicitCommit) : null; if (explicitCommitSource && !envCommit) { throw new Error(`${explicitCommitSource.name} must be a full 40-character hexadecimal SHA`); } const gitCommit = explicitCommit ? null : (sources.readGitCommit ?? readGitCommit)(); const normalizedGitCommit = normalizeControlUiCommit(gitCommit); if (gitCommit?.trim() && !normalizedGitCommit) { throw new Error("git rev-parse HEAD must return a full 40-character hexadecimal SHA"); } // GITHUB_SHA names the workflow invocation and can differ from a checked-out tag. const githubCommit = explicitCommit || gitCommit?.trim() ? null : env.GITHUB_SHA?.trim(); const normalizedGithubCommit = normalizeControlUiCommit(githubCommit); if (githubCommit && !normalizedGithubCommit) { throw new Error("GITHUB_SHA must be a full 40-character hexadecimal SHA"); } const commit = envCommit ?? normalizedGitCommit ?? normalizedGithubCommit; const builtAt = normalizeBuildTimestamp( env.OPENCLAW_BUILD_TIMESTAMP, sources.now ?? (() => new Date()), ); // Branch/dirty identity is advisory: the readers return null instead of // throwing, so malformed environment or Git state never blocks a build. // Tags must not be presented as branches in GitHub-built artifacts. const githubBranch = env.GITHUB_REF_TYPE === "branch" ? env.GITHUB_REF_NAME : null; const branch = normalizeControlUiBranch(env.GIT_BRANCH) ?? normalizeControlUiBranch(githubBranch) ?? normalizeControlUiBranch((sources.readGitBranch ?? readGitBranch)()); const dirty = (sources.readGitDirty ?? readGitDirty)(); const metadata = { version, commit, builtAt }; const explicitBuildId = env.OPENCLAW_CONTROL_UI_BUILD_ID?.trim(); return { ...metadata, branch, dirty, buildId: explicitBuildId ? normalizeControlUiBuildId(explicitBuildId) : deriveControlUiBuildId(metadata), }; } function escapeRegExp(input: string): string { return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function sortTsconfigPathEntries(entries: Array<[string, unknown]>): Array<[string, unknown]> { return entries.toSorted(([left], [right]) => { const leftPrefixLength = left.includes("*") ? left.indexOf("*") : left.length; const rightPrefixLength = right.includes("*") ? right.indexOf("*") : right.length; if (leftPrefixLength !== rightPrefixLength) { return rightPrefixLength - leftPrefixLength; } return right.length - left.length || left.localeCompare(right); }); } function resolveTsconfigTargetPath(target: string): string { return path.resolve(repoRoot, target.replace(/^\.\//, "")); } function resolveTsconfigPathAlias(key: string, target: string): ControlUiViteAlias | null { const keyWildcardIndex = key.indexOf("*"); const targetWildcardIndex = target.indexOf("*"); if (keyWildcardIndex === -1 || targetWildcardIndex === -1) { if (keyWildcardIndex !== -1 || targetWildcardIndex !== -1) { return null; } return { find: key, replacement: resolveTsconfigTargetPath(target), }; } if ( key.slice(keyWildcardIndex + 1).includes("*") || target.slice(targetWildcardIndex + 1).includes("*") ) { return null; } const prefix = key.slice(0, keyWildcardIndex); const suffix = key.slice(keyWildcardIndex + 1); return { find: new RegExp(`^${escapeRegExp(prefix)}(.+)${escapeRegExp(suffix)}$`), replacement: resolveTsconfigTargetPath(target).replace("*", "$1"), }; } function sourcePackageAlias(packageId: string, subpath?: string): ControlUiViteAlias { return { find: `@openclaw/${packageId}${subpath ? `/${subpath}` : ""}`, replacement: path.join( repoRoot, "packages", packageId, "src", ...(subpath ? subpath.split("/") : ["index"]).map((part, index, parts) => index === parts.length - 1 ? `${part}.ts` : part, ), ), }; } export function resolveSourcePackageAliasesForVite(): ControlUiViteAlias[] { return [ sourcePackageAlias("normalization-core", "number-coercion"), sourcePackageAlias("normalization-core", "record-coerce"), sourcePackageAlias("normalization-core", "string-coerce"), sourcePackageAlias("normalization-core", "string-normalization"), sourcePackageAlias("normalization-core", "utf16-slice"), sourcePackageAlias("normalization-core"), ]; } export function resolveExternalPackageAliasesForVite(): ControlUiViteAlias[] { return [ { find: "@openclaw/libterminal/browser", replacement: path.join( repoRoot, "node_modules", "@openclaw", "libterminal", "dist", "browser.js", ), }, { find: "@openclaw/uirouter", replacement: path.join(repoRoot, "node_modules", "@openclaw", "uirouter", "dist", "index.js"), }, ]; } export function resolveTsconfigPathAliasesForVite(): ControlUiViteAlias[] { const raw = fs.readFileSync(path.join(repoRoot, "tsconfig.json"), "utf8"); const parsed = JSON.parse(raw) as { compilerOptions?: { paths?: Record }; }; const paths = parsed.compilerOptions?.paths; if (!paths) { return []; } return sortTsconfigPathEntries(Object.entries(paths)).flatMap(([key, targets]) => { if (!Array.isArray(targets) || typeof targets[0] !== "string") { return []; } const alias = resolveTsconfigPathAlias(key, targets[0]); return alias ? [alias] : []; }); } function normalizeViteImporterPath(importer: string): string { return path.normalize(importer.replace(/[?#].*$/u, "")); } export function controlUiBrowserOnlySharedModuleAliases(): Plugin { const browserRedactPath = path.join(here, "src/lib/browser-redact.ts"); const sharedRedactImporters = new Set([ path.join(repoRoot, "src/agents/tool-display-common.ts"), path.join(repoRoot, "src/agents/tool-display-exec.ts"), path.join(repoRoot, "src/agents/tool-display.ts"), ]); return { name: "control-ui-browser-only-shared-module-aliases", enforce: "pre", resolveId(source, importer) { if ( source === "../logging/redact.js" && importer && sharedRedactImporters.has(normalizeViteImporterPath(importer)) ) { return browserRedactPath; } return null; }, }; } function controlUiServiceWorkerBuildIdPlugin(buildId: string): Plugin { return { name: "control-ui-service-worker-build-id", apply: "build", closeBundle() { const swPath = path.join(outDir, "sw.js"); const publicSwPath = path.join(here, "public/sw.js"); const source = fs.readFileSync(publicSwPath, "utf8"); const placeholder = '"__OPENCLAW_CONTROL_UI_BUILD_ID__"'; const updated = source.replace(placeholder, JSON.stringify(buildId)); if (updated === source) { throw new Error(`Control UI service worker build id placeholder missing in ${swPath}`); } fs.mkdirSync(outDir, { recursive: true }); fs.writeFileSync(swPath, updated); }, }; } export default function controlUiViteConfig(): UserConfig { const envBase = process.env.OPENCLAW_CONTROL_UI_BASE_PATH?.trim(); const base = envBase ? normalizeBase(envBase) : "./"; const bootstrapConfigPath = base === "./" ? "/control-ui-config.json" : `${base}control-ui-config.json`; const buildInfo = resolveControlUiBuildInfo(); return { base, define: { "globalThis.OPENCLAW_CONTROL_UI_BUILD_INFO": JSON.stringify(buildInfo), }, publicDir: path.resolve(here, "public"), optimizeDeps: { include: [ "ipaddr.js", "lit/directives/repeat.js", "markdown-it-task-lists", ...commonJsOptimizeDeps, ], }, resolve: { alias: [ { find: "json5", replacement: json5EsmPath }, ...resolveExternalPackageAliasesForVite(), ...resolveSourcePackageAliasesForVite(), ...resolveTsconfigPathAliasesForVite(), ], }, build: { outDir, emptyOutDir: true, sourcemap: true, rollupOptions: { output: { manualChunks: controlUiManualChunk, }, }, // Keep CI/onboard logs clean; the app chunk is split into stable runtime buckets above. chunkSizeWarningLimit: 1024, }, server: { host: true, port: 5173, strictPort: true, }, plugins: [ controlUiBrowserOnlySharedModuleAliases(), controlUiServiceWorkerBuildIdPlugin(buildInfo.buildId), { name: "control-ui-dev-stubs", configureServer(server) { server.middlewares.use(bootstrapConfigPath, (_req, res) => { res.setHeader("Content-Type", "application/json"); res.end( JSON.stringify({ basePath: "/", assistantName: "", assistantAvatar: "", }), ); }); }, }, ], }; }