/** * Production server for @executor/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/local/serve" */ import { resolve, join } from "node:path"; import { readdirSync } from "node:fs"; import { getServerHandlers } from "./server/main"; // --------------------------------------------------------------------------- // Host allowlist // --------------------------------------------------------------------------- const DEFAULT_ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1"]; const makeIsAllowedHost = (allowed: ReadonlySet) => (request: Request): boolean => { const host = request.headers.get("host"); if (!host) return true; const hostname = host.replace(/:\d+$/, ""); return allowed.has(hostname); }; // --------------------------------------------------------------------------- // Static files // --------------------------------------------------------------------------- type StaticHandler = () => Response | Promise; const hasFileExtension = (pathname: string): boolean => { const lastSegment = pathname.split("/").at(-1) ?? ""; return lastSegment.includes("."); }; function collectStaticRoutes(dir: string, prefix = ""): Record { const routes: Record = {}; 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): Record { const routes: Record = {}; 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; } // --------------------------------------------------------------------------- // 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 | 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; } export interface ServerInstance { port: number; stop: () => Promise; } export async function startServer(opts: StartServerOptions = {}): Promise { const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10); const hostname = opts.hostname ?? "127.0.0.1"; const allowedHostSet = new Set([...DEFAULT_ALLOWED_HOSTS, ...(opts.allowedHosts ?? [])]); const isAllowedHost = makeIsAllowedHost(allowedHostSet); const clientDir = opts.clientDir ?? resolve(import.meta.dirname, "../dist"); const handlers = await getServerHandlers(); // Build static routes from either embedded assets or disk let staticRoutes: Record; let serveIndex: StaticHandler; 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) { if (!isAllowedHost(req)) { return new Response("Forbidden", { status: 403 }); } const url = new URL(req.url); if (url.pathname.startsWith("/mcp")) { return handlers.mcp.handleRequest(req); } if (url.pathname.startsWith("/api/") || url.pathname === "/api") { url.pathname = url.pathname.slice("/api".length) || "/"; return handlers.api.handler(new Request(url, req)); } // 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 new Response("Not Found", { status: 404 }); } // SPA fallback return serveIndex(); }, error(error) { console.error("Server error:", error); return new Response("Internal Server Error", { status: 500 }); }, }); return { port: server.port!, async stop() { server.stop(true); await handlers.mcp.close(); await handlers.api.dispose(); }, }; } if (import.meta.main) { const server = await startServer(); console.log(`Executor listening on http://localhost:${server.port}`); }