From 5b02fcad3a3b7e69e680d1ea3f94bf7023d91b19 Mon Sep 17 00:00:00 2001 From: David McKay Date: Wed, 16 Sep 2026 13:10:27 -0700 Subject: [PATCH 1/2] perf: reuse web builds and defer route and preview work --- .github/workflows/ci.yml | 18 +- app/package.json | 2 +- app/scripts/build-cache.ts | 265 +++++++++++++ app/scripts/serve-or-build.ts | 70 ++++ app/src/components/computer/computer-view.tsx | 9 +- app/src/components/computer/live-screen.tsx | 87 ++-- .../components/computer/preview-visibility.ts | 45 +++ app/tests/build-cache.test.ts | 251 ++++++++++++ .../computer-preview-visibility.test.tsx | 370 ++++++++++++++++++ app/tests/router.test.ts | 87 ++++ app/vite.config.ts | 87 +++- 11 files changed, 1261 insertions(+), 30 deletions(-) create mode 100644 app/scripts/build-cache.ts create mode 100644 app/scripts/serve-or-build.ts create mode 100644 app/src/components/computer/preview-visibility.ts create mode 100644 app/tests/build-cache.test.ts create mode 100644 app/tests/computer-preview-visibility.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49ad03394..908e3eb98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -246,6 +246,22 @@ jobs: # files before their tests are registered. - run: bun run test:ci + startup: + name: startup (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + - run: bun test app/tests/build-cache.test.ts + python-harness: name: python harness regressions runs-on: ubuntu-latest @@ -492,7 +508,7 @@ jobs: name: verify runs-on: ubuntu-latest if: always() - needs: [static, deployables, chart, test, python-harness, build, migrations, image, component-dockerfiles] + needs: [static, deployables, chart, test, startup, python-harness, build, migrations, image, component-dockerfiles] steps: - name: Require every check env: diff --git a/app/package.json b/app/package.json index dbb1c8faf..b2b91451b 100644 --- a/app/package.json +++ b/app/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "bun --bun node_modules/vite/bin/vite.js build", "dev": "bun --bun node_modules/vite/bin/vite.js", - "serve": "bun run build && bun serve.ts", + "serve": "bun scripts/serve-or-build.ts && bun serve.ts", "prebuild": "bun run --cwd .. generate:app-config", "predev": "bun run --cwd .. generate:app-config", "pretypecheck": "bun run --cwd .. generate:app-config", diff --git a/app/scripts/build-cache.ts b/app/scripts/build-cache.ts new file mode 100644 index 000000000..44056dbcb --- /dev/null +++ b/app/scripts/build-cache.ts @@ -0,0 +1,265 @@ +import { createHash } from "node:crypto"; +import type { Stats } from "node:fs"; +import { readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { basename, join, relative, resolve, sep } from "node:path"; + +const MANIFEST_VERSION = 1; +const MANIFEST_NAME = ".openbot-build-cache.json"; + +export type BuildCachePaths = { + rootDir: string; + appDir: string; +}; + +type BuildCacheEnv = Record; + +type BuildCacheManifest = { + version: number; + key: string; +}; + +type InputFile = { + path: string; + absolutePath: string; +}; + +const sourceExtensions = new Set([ + ".css", + ".html", + ".js", + ".jsx", + ".json", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".yaml", + ".yml", +]); + +export function buildCacheManifestPath({ appDir }: BuildCachePaths): string { + return join(appDir, "dist", MANIFEST_NAME); +} + +function slashPath(path: string): string { + return path.split(sep).join("/"); +} + +function hasSourceExtension(path: string): boolean { + const name = basename(path); + if (name === "bun.lock") return true; + const dot = name.lastIndexOf("."); + return dot >= 0 && sourceExtensions.has(name.slice(dot)); +} + +async function existingFile(path: string): Promise { + try { + const info = await stat(path); + return info.isFile(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +async function readJson(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")); +} + +function versionFromPackageJson(json: unknown): string { + if ( + json && + typeof json === "object" && + "version" in json && + typeof json.version === "string" + ) { + return json.version; + } + return ""; +} + +async function collectFiles( + base: string, + options: { + prefix: string; + include: (path: string, info: Stats) => boolean; + skipDirectory?: (path: string) => boolean; + }, +): Promise { + const files: InputFile[] = []; + + async function visit(directory: string) { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const absolutePath = join(directory, entry.name); + if (entry.isDirectory()) { + if (!options.skipDirectory?.(absolutePath)) await visit(absolutePath); + continue; + } + if (!entry.isFile()) continue; + const info = await stat(absolutePath); + if (!options.include(absolutePath, info)) continue; + files.push({ + path: `${options.prefix}/${slashPath(relative(base, absolutePath))}`, + absolutePath, + }); + } + } + + try { + await visit(base); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + return files; +} + +function tenantPackageDir(rootDir: string, env: BuildCacheEnv): string { + const configured = env.TENANT_PACKAGE_DIR; + if (!configured) return join(rootDir, "examples", "fintech"); + return resolve(rootDir, "server", configured); +} + +function viteEnvironment(env: BuildCacheEnv): Record { + const values: Record = {}; + for (const [key, value] of Object.entries(env)) { + if ( + value !== undefined && + (key === "BUN_ENV" || + key === "MODE" || + key === "NODE_ENV" || + key === "TENANT_PACKAGE_DIR" || + key.startsWith("VITE_")) + ) { + values[key] = value; + } + } + return values; +} + +async function collectBuildInputs( + { rootDir, appDir }: BuildCachePaths, + env: BuildCacheEnv, +): Promise { + const resolvedRoot = resolve(rootDir); + const resolvedApp = resolve(appDir); + const tenantDir = tenantPackageDir(resolvedRoot, env); + + const explicit = [ + join(resolvedRoot, "bun.lock"), + join(resolvedRoot, "package.json"), + join(resolvedApp, "package.json"), + join(resolvedApp, "vite.config.ts"), + join(resolvedApp, "index.html"), + ]; + const explicitFiles = ( + await Promise.all( + explicit.map(async (absolutePath) => + (await existingFile(absolutePath)) + ? { + path: slashPath(relative(resolvedRoot, absolutePath)), + absolutePath, + } + : null, + ), + ) + ).filter((file): file is InputFile => file !== null); + + const sourceFiles = await collectFiles(join(resolvedApp, "src"), { + prefix: "app/src", + include: (path) => hasSourceExtension(path), + skipDirectory: (path) => basename(path) === "node_modules", + }); + const tenantFiles = await collectFiles(tenantDir, { + prefix: `tenant/${slashPath(relative(resolvedRoot, tenantDir))}`, + include: (path) => hasSourceExtension(path), + skipDirectory: (path) => basename(path) === "node_modules", + }); + + return [...explicitFiles, ...sourceFiles, ...tenantFiles].sort( + (left, right) => left.path.localeCompare(right.path), + ); +} + +export async function buildCacheKey( + paths: BuildCachePaths, + env: BuildCacheEnv, +): Promise { + const hash = createHash("sha256"); + const rootPackage = await readJson(join(paths.rootDir, "package.json")).catch( + () => null, + ); + const appPackage = await readJson(join(paths.appDir, "package.json")).catch( + () => null, + ); + + hash.update( + JSON.stringify({ + manifestVersion: MANIFEST_VERSION, + rootVersion: versionFromPackageJson(rootPackage), + appVersion: versionFromPackageJson(appPackage), + env: viteEnvironment(env), + }), + ); + + for (const input of await collectBuildInputs(paths, env)) { + hash.update("\0"); + hash.update(input.path); + hash.update("\0"); + hash.update(await readFile(input.absolutePath)); + } + + return hash.digest("hex"); +} + +export async function readBuildCacheManifest( + paths: BuildCachePaths, +): Promise { + try { + const raw = JSON.parse( + await readFile(buildCacheManifestPath(paths), "utf8"), + ); + if ( + raw && + typeof raw === "object" && + raw.version === MANIFEST_VERSION && + typeof raw.key === "string" + ) { + return raw; + } + return null; + } catch (error) { + if ( + (error as NodeJS.ErrnoException).code === "ENOENT" || + error instanceof SyntaxError + ) { + return null; + } + throw error; + } +} + +export async function writeBuildCacheManifest( + paths: BuildCachePaths, + env: BuildCacheEnv, +): Promise { + await writeFile( + buildCacheManifestPath(paths), + `${JSON.stringify({ + version: MANIFEST_VERSION, + key: await buildCacheKey(paths, env), + })}\n`, + ); +} + +export async function isReusableBuild( + paths: BuildCachePaths, + env: BuildCacheEnv, +): Promise { + if (!(await existingFile(join(paths.appDir, "dist", "index.html")))) { + return false; + } + const manifest = await readBuildCacheManifest(paths); + return manifest?.key === (await buildCacheKey(paths, env)); +} diff --git a/app/scripts/serve-or-build.ts b/app/scripts/serve-or-build.ts new file mode 100644 index 000000000..4f8e4129f --- /dev/null +++ b/app/scripts/serve-or-build.ts @@ -0,0 +1,70 @@ +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + isReusableBuild, + writeBuildCacheManifest, + type BuildCachePaths, +} from "./build-cache"; + +type RunCommand = (command: string[], cwd: string) => Promise; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const defaultAppDir = resolve(scriptDir, ".."); +const defaultRootDir = resolve(defaultAppDir, ".."); + +function pathsFromEnvironment(): BuildCachePaths { + return { + rootDir: process.env.OPENBOT_BUILD_CACHE_ROOT_DIR + ? resolve(process.env.OPENBOT_BUILD_CACHE_ROOT_DIR) + : defaultRootDir, + appDir: process.env.OPENBOT_BUILD_CACHE_APP_DIR + ? resolve(process.env.OPENBOT_BUILD_CACHE_APP_DIR) + : defaultAppDir, + }; +} + +async function run(command: string[], cwd: string) { + const child = Bun.spawn({ + cmd: command, + cwd, + env: process.env, + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await child.exited; + if (exitCode !== 0) { + throw new Error(`${command.join(" ")} exited with ${exitCode}`); + } +} + +export async function prepareProductionBuild( + paths: BuildCachePaths, + runCommand: RunCommand = run, +): Promise<"reused" | "rebuilt"> { + await runCommand( + [process.execPath, "run", "--cwd", paths.rootDir, "generate:app-config"], + paths.rootDir, + ); + + if (await isReusableBuild(paths, process.env)) { + console.log("Reusing app/dist from build cache"); + return "reused"; + } + + console.log("Building app/dist because the build cache is stale or missing"); + await runCommand( + [ + process.execPath, + "--bun", + join("node_modules", "vite", "bin", "vite.js"), + "build", + ], + paths.appDir, + ); + await writeBuildCacheManifest(paths, process.env); + return "rebuilt"; +} + +if (import.meta.main) { + await prepareProductionBuild(pathsFromEnvironment()); +} diff --git a/app/src/components/computer/computer-view.tsx b/app/src/components/computer/computer-view.tsx index a0990604e..c31179b6a 100644 --- a/app/src/components/computer/computer-view.tsx +++ b/app/src/components/computer/computer-view.tsx @@ -14,6 +14,7 @@ import { } from "@/lib/computers/screen"; import { ChannelAvatar } from "../channels/avatar"; import { LiveScreen } from "./live-screen"; +import { useElementVisible, usePageVisible } from "./preview-visibility"; /** Explicit blank-browser URLs use placeholder artwork; missing URL fields are treated as real pages. */ function isBlankBrowser(shot: Screenshot): boolean { @@ -228,6 +229,8 @@ export function ComputerView({ const [secret, setSecret] = useState(""); const [secretProblem, setSecretProblem] = useState(null); const [sendingSecret, setSendingSecret] = useState(false); + const pageVisible = usePageVisible(); + const [previewRef, previewIntersecting] = useElementVisible(); const driving = control?.holder === "human"; /** Read by the polling loop without restarting it on control changes. */ const drivingRef = useRef(false); @@ -277,6 +280,7 @@ export function ComputerView({ const [, setFrameArrived] = useState(0); const settled = !active && (finished || Boolean(knownPage)); + const visualVisible = pageVisible && (expanded || previewIntersecting); /* * The frame this turn's page was showing, fetched once and then kept. @@ -317,6 +321,7 @@ export function ComputerView({ // biome-ignore lint/correctness/useExhaustiveDependencies: `secretPending` intentionally restarts settled polling. useEffect(() => { if (settled) return; + if (!visualVisible) return; const mine = ++generation.current; let timer: ReturnType; // Consecutive identical frames observed during post-action settling. @@ -364,7 +369,7 @@ export function ComputerView({ generation.current++; clearTimeout(timer); }; - }, [computerId, active, intervalMs, secretPending, settled]); + }, [computerId, active, intervalMs, secretPending, settled, visualVisible]); /** Poll control state independently from screenshot polling so help/secret prompts surface. */ useEffect(() => { @@ -452,7 +457,7 @@ export function ComputerView({ return ( <> -
+
{/* Inline preview remains in transcript; click opens a readable full-size view. */}