From 1dd65dd81688d1f7baf648c8a301be1a741623ef Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 13 May 2026 10:46:22 -0700 Subject: [PATCH 1/2] feat(local): serve live UI for dev:cli via vite child proxy When EXECUTOR_DEV=1 (set by the root dev:cli script), the daemon spawns `vite dev` as a child on a free port and proxies non-API requests to it, so source edits land without rebuilding the static dist bundle. The HMR WebSocket is pointed at vite's own port via clientPort so the browser sidesteps the daemon's lack of WS proxying. AGENTS.md notes the stack CLI convention while it's nearby. --- apps/local/src/serve.ts | 120 +++++++++++++++++++++++++++++++++++++- apps/local/vite.config.ts | 13 +++++ package.json | 2 +- 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/apps/local/src/serve.ts b/apps/local/src/serve.ts index 11aa5e3bd..990cecd87 100644 --- a/apps/local/src/serve.ts +++ b/apps/local/src/serve.ts @@ -9,6 +9,7 @@ import { resolve, join } from "node:path"; import { readdirSync } from "node:fs"; +import type { Subprocess } from "bun"; import { setOAuthCompletionListener } from "@executor-js/api"; import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store"; import { startIntegrationsRefresh } from "./server/integrations"; @@ -66,6 +67,99 @@ function embeddedToStaticRoutes(embedded: Record): Record Promise; +} + +async function allocatePort(): Promise { + const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response() }); + const port = probe.port ?? 0; + probe.stop(true); + return port; +} + +async function startViteChild(): Promise { + const vitePort = await allocatePort(); + const cwd = resolve(import.meta.dirname, ".."); + // `bunx --bun vite` runs vite under Bun, matching the `dev:vite` script + // already in apps/local. --strictPort keeps the URL we hand back stable. + const child: Subprocess = Bun.spawn( + ["bunx", "--bun", "vite", "dev", "--port", String(vitePort), "--strictPort", "--host", "127.0.0.1"], + { + cwd, + // EXECUTOR_DEV_VITE_PORT — vite.config reads this and points the + // HMR client at vite's real port. The browser loads HTML through + // the daemon proxy but opens the HMR WebSocket directly to vite, + // sidestepping the daemon's lack of WS proxying. + env: { + ...process.env, + PORT: undefined as unknown as string, + EXECUTOR_DEV_VITE_PORT: String(vitePort), + }, + stdout: "inherit", + stderr: "inherit", + }, + ); + + const url = `http://127.0.0.1:${vitePort}`; + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing a child process that may not be listening yet + try { + const r = await fetch(`${url}/`, { redirect: "manual" }); + if (r.status < 500) { + await r.body?.cancel(); + return { + url, + stop: async () => { + child.kill(); + await child.exited; + }, + }; + } + await r.body?.cancel(); + } catch { + // not up yet + } + if (child.exitCode !== null) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: child process aborted before becoming ready + throw new Error(`vite dev exited with code ${child.exitCode} before becoming ready`); + } + await Bun.sleep(150); + } + child.kill(); + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: vite never became reachable + throw new Error(`vite dev did not become reachable on ${url} within 30s`); +} + +async function proxyToVite(req: Request, viteUrl: string): Promise { + const target = new URL(req.url); + target.protocol = "http:"; + target.host = new URL(viteUrl).host; + // Strip hop-by-hop headers that confuse the upstream + const headers = new Headers(req.headers); + headers.delete("host"); + headers.set("host", target.host); + const hasBody = req.method !== "GET" && req.method !== "HEAD"; + return fetch(target, { + method: req.method, + headers, + body: hasBody ? req.body : undefined, + redirect: "manual", + // @ts-expect-error — Bun/undici extension required for streamed bodies + duplex: hasBody ? "half" : undefined, + }); +} + // --------------------------------------------------------------------------- // Server // --------------------------------------------------------------------------- @@ -124,11 +218,24 @@ export async function startServer(opts: StartServerOptions = {}): Promise publishOAuthResult(result)); - // Build static routes from either embedded assets or disk - let staticRoutes: Record; + // Build static routes from either embedded assets, disk, or a spawned + // vite dev child (EXECUTOR_DEV=1). Vite mode takes precedence and + // disables the file-extension 404 short-circuit since vite serves + // hashed asset paths directly. + let staticRoutes: Record = {}; let serveIndex: StaticHandler; + let viteChild: ViteChild | null = null; - if (opts.embeddedWebUI) { + const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI; + if (devMode) { + console.log("[executor] EXECUTOR_DEV=1 — spawning vite dev child for live UI"); + viteChild = await startViteChild(); + console.log(`[executor] proxying SPA requests to ${viteChild.url}`); + serveIndex = () => + // Unused when viteChild is non-null; defined so the type checker + // can keep `serveIndex` non-nullable. + new Response("vite not ready", { status: 503 }); + } else if (opts.embeddedWebUI) { staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI); const indexFile = Bun.file(opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html")); serveIndex = () => new Response(indexFile, { headers: { "content-type": "text/html" } }); @@ -188,6 +295,12 @@ export async function startServer(opts: StartServerOptions = {}): Promise Date: Sat, 16 May 2026 22:28:20 -0700 Subject: [PATCH 2/2] fix(local): satisfy dev cli lint gates --- apps/local/src/serve.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/local/src/serve.ts b/apps/local/src/serve.ts index 990cecd87..c39569544 100644 --- a/apps/local/src/serve.ts +++ b/apps/local/src/serve.ts @@ -90,10 +90,22 @@ async function allocatePort(): Promise { async function startViteChild(): Promise { const vitePort = await allocatePort(); const cwd = resolve(import.meta.dirname, ".."); + const env = { ...process.env }; + delete env.PORT; // `bunx --bun vite` runs vite under Bun, matching the `dev:vite` script // already in apps/local. --strictPort keeps the URL we hand back stable. const child: Subprocess = Bun.spawn( - ["bunx", "--bun", "vite", "dev", "--port", String(vitePort), "--strictPort", "--host", "127.0.0.1"], + [ + "bunx", + "--bun", + "vite", + "dev", + "--port", + String(vitePort), + "--strictPort", + "--host", + "127.0.0.1", + ], { cwd, // EXECUTOR_DEV_VITE_PORT — vite.config reads this and points the @@ -101,8 +113,7 @@ async function startViteChild(): Promise { // the daemon proxy but opens the HMR WebSocket directly to vite, // sidestepping the daemon's lack of WS proxying. env: { - ...process.env, - PORT: undefined as unknown as string, + ...env, EXECUTOR_DEV_VITE_PORT: String(vitePort), }, stdout: "inherit",