/** * Production server for @executor-js/local. * * Serves the Vite-built SPA + Effect API + MCP server. * * Run directly: bun run apps/local/src/serve.ts * Or import: import { startServer } from "@executor-js/local/serve" */ 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 "./integrations";import { getServerHandlers } from "./main";import { DEFAULT_ALLOWED_HOSTS, hasFileExtension, isLoopbackBindHost, isUnauthenticatedOAuthCallbackPath, makeIsAllowedHost, makeIsAuthorized, normalizeCredential,} from "./serve-shared"; // ---------------------------------------------------------------------------// Static files// --------------------------------------------------------------------------- type StaticHandler = () => Response | Promise<Response>; function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> { const routes: Record<string, StaticHandler> = {}; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets try { for (const entry of readdirSync(dir, { withFileTypes: true })) { const fullPath = join(dir, entry.name); const routePath = `${prefix}/${entry.name}`; if (entry.isDirectory()) { Object.assign(routes, collectStaticRoutes(fullPath, routePath)); } else { const file = Bun.file(fullPath); routes[routePath] = () => new Response(file, { headers: { "content-type": file.type || "application/octet-stream" }, }); } } } catch {} return routes;} /** * Convert an embedded web UI map (path → bunfs path) into Bun.serve routes. * The embedded map is generated by the CLI build step using `with { type: "file" }`. */function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string, StaticHandler> { const routes: Record<string, StaticHandler> = {}; for (const [key, bunfsPath] of Object.entries(embedded)) { const file = Bun.file(bunfsPath); routes[`/${key}`] = () => new Response(file, { headers: { "content-type": file.type || "application/octet-stream" }, }); } 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// --------------------------------------------------------------------------- export interface StartServerOptions { port?: number; /** Directory containing built client assets (for dev/disk serving). */ clientDir?: string; /** Embedded web UI map from compiled binary (path → bunfs path). Overrides clientDir. */ embeddedWebUI?: Record<string, string> | null; /** Bind address. Defaults to 127.0.0.1. Use 0.0.0.0 to listen on all interfaces. */ hostname?: string; /** Extra hostnames permitted in the Host header, on top of localhost/127.0.0.1. */ allowedHosts?: ReadonlyArray<string>; /** Bearer token required for requests. Required for non-loopback bind addresses. */ authToken?: string; /** Basic auth password required for requests. Required for non-loopback bind addresses. */ authPassword?: string; /** Test hook for supplying API/MCP handlers without loading the local server graph. */ handlers?: ServerHandlers;} export interface ServerInstance { port: number; stop: () => Promise<void>;} type ServerHandlers = Awaited<ReturnType<typeof getServerHandlers>>; const corsHeaders = { "access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS", "access-control-allow-headers": "authorization, content-type, x-executor-token, x-requested-with, traceparent, tracestate, baggage, b3", "access-control-allow-credentials": "true", "access-control-expose-headers": "*",} as const; const withCorsHeaders = (req: Request, response: Response): Response => { const origin = req.headers.get("origin"); if (!origin) return response; const headers = new Headers(response.headers); headers.set("access-control-allow-origin", origin); for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value); headers.set( "access-control-allow-headers", req.headers.get("access-control-request-headers") ?? corsHeaders["access-control-allow-headers"], ); headers.append("vary", "Origin"); return new Response(response.body, { status: response.status, statusText: response.statusText, headers, });}; const corsPreflightResponse = (req: Request): Response => withCorsHeaders(req, new Response(null, { status: 204 })); export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> { const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10); const hostname = opts.hostname ?? "127.0.0.1"; const auth = { token: normalizeCredential(opts.authToken), password: normalizeCredential(opts.authPassword), }; const isNetworkBind = !isLoopbackBindHost(hostname); const requiresAuth = auth.token !== null || auth.password !== null; if (isNetworkBind && !requiresAuth) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: startServer is a Promise API and rejects invalid bind options throw new Error("Refusing to listen on a non-loopback host without an auth token or password."); } const isAuthorized = makeIsAuthorized(auth); const allowedHostSet = new Set<string>([...DEFAULT_ALLOWED_HOSTS, ...(opts.allowedHosts ?? [])]); const isAllowedHost = makeIsAllowedHost(allowedHostSet); const clientDir = opts.clientDir ?? resolve(import.meta.dirname, "../dist"); startIntegrationsRefresh(); const handlers = opts.handlers ?? (await getServerHandlers()); // Mirror every OAuth callback completion into the local in-memory result // store. The Electron desktop renderer polls /api/oauth/await/:sessionId // for these when the user runs the flow in their system browser (no // shared origin → no postMessage). Cloud doesn't register a listener; // its same-origin web SPA receives results via postMessage directly. setOAuthCompletionListener((result) => publishOAuthResult(result)); // 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; 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" } }); } else { staticRoutes = collectStaticRoutes(clientDir); const indexFile = Bun.file(join(clientDir, "index.html")); serveIndex = () => new Response(indexFile, { headers: { "content-type": "text/html" } }); } const server = Bun.serve({ port, hostname, // Disable Bun's default 10s idle timeout. MCP elicitation and pause/resume // can idle longer during human approval; `0` disables the socket timeout. idleTimeout: 0, routes: { ...staticRoutes }, async fetch(req) { const maybeWithCorsHeaders = (response: Response): Response => requiresAuth ? withCorsHeaders(req, response) : response; if (!isAllowedHost(req)) { return maybeWithCorsHeaders(new Response("Forbidden", { status: 403 })); } if (requiresAuth && req.method === "OPTIONS" && req.headers.has("origin")) { return corsPreflightResponse(req); } const url = new URL(req.url); // OAuth provider callbacks are hit by the user's external browser // and can't carry our Basic auth header. The OAuth `state` // parameter is the security gate — see isUnauthenticatedOAuthCallbackPath. const skipAuth = isUnauthenticatedOAuthCallbackPath(url.pathname); if (requiresAuth && !skipAuth && !isAuthorized(req)) { return maybeWithCorsHeaders( new Response("Unauthorized", { status: 401, headers: { "www-authenticate": 'Bearer realm="executor", Basic realm="executor"' }, }), ); } if (url.pathname.startsWith("/mcp")) { return maybeWithCorsHeaders(await handlers.mcp.handleRequest(req)); } if (url.pathname.startsWith("/api/mcp-sessions/")) { return maybeWithCorsHeaders(await handlers.mcp.handleApprovalRequest(req)); } // OAuth result polling — local-only, served outside the typed API // because cloud (Cloudflare Workers, stateless) can't back the // in-memory store. See setOAuthCompletionListener above. const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname); if (awaitMatch && req.method === "GET") { const result = consumeOAuthResult(awaitMatch[1]); return maybeWithCorsHeaders( new Response(JSON.stringify(result), { headers: { "content-type": "application/json" }, }), ); } if (url.pathname.startsWith("/api/") || url.pathname === "/api") { url.pathname = url.pathname.slice("/api".length) || "/"; return maybeWithCorsHeaders(await 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 maybeWithCorsHeaders(await 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. if (hasFileExtension(url.pathname)) { return maybeWithCorsHeaders(new Response("Not Found", { status: 404 })); } // SPA fallback return maybeWithCorsHeaders(await serveIndex()); }, error(error) { console.error("Server error:", error); return new Response("Internal Server Error", { status: 500 }); }, }); return { port: server.port!, async stop() { setOAuthCompletionListener(null); server.stop(true); await handlers.mcp.close(); await handlers.api.dispose(); if (viteChild) await viteChild.stop(); }, };} if (import.meta.main) { const server = await startServer(); console.log(`Executor listening on http://localhost:${server.port}`);}
/**
* Production server for @executor-js/local.
*
* Serves the Vite-built SPA + Effect API + MCP server.
*
* Run directly: bun run apps/local/src/serve.ts
* Or import: import { startServer } from "@executor-js/local/serve"
*/
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 "./integrations";
import { getServerHandlers } from "./main";
import {
DEFAULT_ALLOWED_HOSTS,
hasFileExtension,
isLoopbackBindHost,
isUnauthenticatedOAuthCallbackPath,
makeIsAllowedHost,
makeIsAuthorized,
normalizeCredential,
} from "./serve-shared";
// ---------------------------------------------------------------------------
// Static files
// ---------------------------------------------------------------------------
type StaticHandler = () => Response | Promise<Response>;
function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> {
const routes: Record<string, StaticHandler> = {};
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
const routePath = `${prefix}/${entry.name}`;
if (entry.isDirectory()) {
Object.assign(routes, collectStaticRoutes(fullPath, routePath));
} else {
const file = Bun.file(fullPath);
routes[routePath] = () =>
new Response(file, {
headers: { "content-type": file.type || "application/octet-stream" },
});
}
}
} catch {}
return routes;
}
/**
* Convert an embedded web UI map (path → bunfs path) into Bun.serve routes.
* The embedded map is generated by the CLI build step using `with { type: "file" }`.
*/
function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string, StaticHandler> {
const routes: Record<string, StaticHandler> = {};
for (const [key, bunfsPath] of Object.entries(embedded)) {
const file = Bun.file(bunfsPath);
routes[`/${key}`] = () =>
new Response(file, {
headers: { "content-type": file.type || "application/octet-stream" },
});
}
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
// ---------------------------------------------------------------------------
export interface StartServerOptions {
port?: number;
/** Directory containing built client assets (for dev/disk serving). */
clientDir?: string;
/** Embedded web UI map from compiled binary (path → bunfs path). Overrides clientDir. */
embeddedWebUI?: Record<string, string> | null;
/** Bind address. Defaults to 127.0.0.1. Use 0.0.0.0 to listen on all interfaces. */
hostname?: string;
/** Extra hostnames permitted in the Host header, on top of localhost/127.0.0.1. */
allowedHosts?: ReadonlyArray<string>;
/** Bearer token required for requests. Required for non-loopback bind addresses. */
authToken?: string;
/** Basic auth password required for requests. Required for non-loopback bind addresses. */
authPassword?: string;
/** Test hook for supplying API/MCP handlers without loading the local server graph. */
handlers?: ServerHandlers;
}
export interface ServerInstance {
port: number;
stop: () => Promise<void>;
}
type ServerHandlers = Awaited<ReturnType<typeof getServerHandlers>>;
const corsHeaders = {
"access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
"access-control-allow-headers":
"authorization, content-type, x-executor-token, x-requested-with, traceparent, tracestate, baggage, b3",
"access-control-allow-credentials": "true",
"access-control-expose-headers": "*",
} as const;
const withCorsHeaders = (req: Request, response: Response): Response => {
const origin = req.headers.get("origin");
if (!origin) return response;
const headers = new Headers(response.headers);
headers.set("access-control-allow-origin", origin);
for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value);
headers.set(
"access-control-allow-headers",
req.headers.get("access-control-request-headers") ??
corsHeaders["access-control-allow-headers"],
);
headers.append("vary", "Origin");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
};
const corsPreflightResponse = (req: Request): Response =>
withCorsHeaders(req, new Response(null, { status: 204 }));
export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> {
const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);
const hostname = opts.hostname ?? "127.0.0.1";
const auth = {
token: normalizeCredential(opts.authToken),
password: normalizeCredential(opts.authPassword),
};
const isNetworkBind = !isLoopbackBindHost(hostname);
const requiresAuth = auth.token !== null || auth.password !== null;
if (isNetworkBind && !requiresAuth) {
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: startServer is a Promise API and rejects invalid bind options
throw new Error("Refusing to listen on a non-loopback host without an auth token or password.");
}
const isAuthorized = makeIsAuthorized(auth);
const allowedHostSet = new Set<string>([...DEFAULT_ALLOWED_HOSTS, ...(opts.allowedHosts ?? [])]);
const isAllowedHost = makeIsAllowedHost(allowedHostSet);
const clientDir = opts.clientDir ?? resolve(import.meta.dirname, "../dist");
startIntegrationsRefresh();
const handlers = opts.handlers ?? (await getServerHandlers());
// Mirror every OAuth callback completion into the local in-memory result
// store. The Electron desktop renderer polls /api/oauth/await/:sessionId
// for these when the user runs the flow in their system browser (no
// shared origin → no postMessage). Cloud doesn't register a listener;
// its same-origin web SPA receives results via postMessage directly.
setOAuthCompletionListener((result) => publishOAuthResult(result));
// 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;
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" } });
} else {
staticRoutes = collectStaticRoutes(clientDir);
const indexFile = Bun.file(join(clientDir, "index.html"));
serveIndex = () => new Response(indexFile, { headers: { "content-type": "text/html" } });
}
const server = Bun.serve({
port,
hostname,
// Disable Bun's default 10s idle timeout. MCP elicitation and pause/resume
// can idle longer during human approval; `0` disables the socket timeout.
idleTimeout: 0,
routes: { ...staticRoutes },
async fetch(req) {
const maybeWithCorsHeaders = (response: Response): Response =>
requiresAuth ? withCorsHeaders(req, response) : response;
if (!isAllowedHost(req)) {
return maybeWithCorsHeaders(new Response("Forbidden", { status: 403 }));
}
if (requiresAuth && req.method === "OPTIONS" && req.headers.has("origin")) {
return corsPreflightResponse(req);
}
const url = new URL(req.url);
// OAuth provider callbacks are hit by the user's external browser
// and can't carry our Basic auth header. The OAuth `state`
// parameter is the security gate — see isUnauthenticatedOAuthCallbackPath.
const skipAuth = isUnauthenticatedOAuthCallbackPath(url.pathname);
if (requiresAuth && !skipAuth && !isAuthorized(req)) {
return maybeWithCorsHeaders(
new Response("Unauthorized", {
status: 401,
headers: { "www-authenticate": 'Bearer realm="executor", Basic realm="executor"' },
}),
);
}
if (url.pathname.startsWith("/mcp")) {
return maybeWithCorsHeaders(await handlers.mcp.handleRequest(req));
}
if (url.pathname.startsWith("/api/mcp-sessions/")) {
return maybeWithCorsHeaders(await handlers.mcp.handleApprovalRequest(req));
}
// OAuth result polling — local-only, served outside the typed API
// because cloud (Cloudflare Workers, stateless) can't back the
// in-memory store. See setOAuthCompletionListener above.
const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname);
if (awaitMatch && req.method === "GET") {
const result = consumeOAuthResult(awaitMatch[1]);
return maybeWithCorsHeaders(
new Response(JSON.stringify(result), {
headers: { "content-type": "application/json" },
}),
);
}
if (url.pathname.startsWith("/api/") || url.pathname === "/api") {
url.pathname = url.pathname.slice("/api".length) || "/";
return maybeWithCorsHeaders(await 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 maybeWithCorsHeaders(await 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.
if (hasFileExtension(url.pathname)) {
return maybeWithCorsHeaders(new Response("Not Found", { status: 404 }));
}
// SPA fallback
return maybeWithCorsHeaders(await serveIndex());
},
error(error) {
console.error("Server error:", error);
return new Response("Internal Server Error", { status: 500 });
},
});
return {
port: server.port!,
async stop() {
setOAuthCompletionListener(null);
server.stop(true);
await handlers.mcp.close();
await handlers.api.dispose();
if (viteChild) await viteChild.stop();
},
};
}
if (import.meta.main) {
const server = await startServer();
console.log(`Executor listening on http://localhost:${server.port}`);
}