Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 128 additions & 3 deletions apps/local/src/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -66,6 +67,110 @@ function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string
return routes;
}

// ---------------------------------------------------------------------------
// Dev mode: spawn vite as a child and proxy non-API requests to it
//
// Enabled when EXECUTOR_DEV=1. Lets `dev:cli` serve a fresh UI without
// requiring a manual `bun run build` after every change. The daemon still
// owns /api and /mcp — only SPA/asset requests get proxied.
// ---------------------------------------------------------------------------

interface ViteChild {
readonly url: string;
readonly stop: () => Promise<void>;
}

async function allocatePort(): Promise<number> {
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<ViteChild> {
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",
],
{
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: {
...env,
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<Response> {
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -124,11 +229,24 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server
// its same-origin web SPA receives results via postMessage directly.
setOAuthCompletionListener((result) => publishOAuthResult(result));

// Build static routes from either embedded assets or disk
let staticRoutes: Record<string, StaticHandler>;
// 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<string, StaticHandler> = {};
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" } });
Expand Down Expand Up @@ -188,6 +306,12 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server
return handlers.api.handler(new Request(url, req));
}

// Dev mode: forward everything else (SPA + hashed assets) to the
// vite child so source edits show up without a rebuild.
if (viteChild) {
return proxyToVite(req, viteChild.url);
}

// If a path looks like a static asset (has a file extension), do not
// fall back to SPA HTML. Returning index.html here causes browser module
// MIME errors when hashed chunks are stale/missing.
Expand All @@ -211,6 +335,7 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server
server.stop(true);
await handlers.mcp.close();
await handlers.api.dispose();
if (viteChild) await viteChild.stop();
},
};
}
Expand Down
13 changes: 13 additions & 0 deletions apps/local/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,19 @@ export default defineConfig({
server: {
port: parseInt(process.env.PORT ?? "5173", 10),
host: "127.0.0.1",
// When the CLI daemon spawns this vite as a child and proxies HTTP
// (EXECUTOR_DEV=1), the page is loaded from the daemon's port, but
// the daemon does not proxy WebSockets. Point the HMR client at
// vite's own port so the browser opens a WS directly to vite, side-
// stepping the daemon proxy. Without this, the client tries the
// daemon port and floods the console with reconnect errors.
hmr: process.env.EXECUTOR_DEV_VITE_PORT
? {
host: "127.0.0.1",
clientPort: parseInt(process.env.EXECUTOR_DEV_VITE_PORT, 10),
protocol: "ws",
}
: undefined,
watch: {
// Workspace packages live under packages/ and are symlinked into
// node_modules. Without this, chokidar treats them as ordinary
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"scripts": {
"dev": "turbo run dev --filter='!@executor-js/desktop' --filter='!@executor-js/cloud'",
"dev:desktop": "turbo run dev",
"dev:cli": "bun run apps/cli/src/main.ts",
"dev:cli": "EXECUTOR_DEV=1 bun run apps/cli/src/main.ts",
"test": "turbo run test",
"test:release:bootstrap": "vitest run tests/release-bootstrap-smoke.test.ts",
"build:packages": "bun run --filter='fumadb' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build",
Expand Down
Loading