497 lines · 19.2 KB · TypeScript
RawDownload
/** * 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 { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server";import { loadOrMintLocalAuthToken } from "./auth";import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store";import { startIntegrationsRefresh } from "./integrations";import { disposeServerHandlers, getServerHandlers } from "./main";import {  DEFAULT_ALLOWED_HOSTS,  hasFileExtension,  isAllowedOrigin,  isUnauthenticatedOAuthClientMetadataPath,  isUnauthenticatedOAuthPath,  makeIsAuthorized,  normalizeCredential,} from "./serve-shared"; // ---------------------------------------------------------------------------// Static files// --------------------------------------------------------------------------- type StaticHandler = () => Response | Promise<Response>; const htmlResponse = (file: Bun.BunFile): Response =>  new Response(file, {    headers: { "content-type": "text/html", "cache-control": "no-store" },  }); const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response =>  new Response(    JSON.stringify(      oauthClientIdMetadataDocumentFromRequest({        requestUrl,        webRequest,        mountPrefix: "/api",      }),    ),    {      headers: {        "content-type": "application/json",        "cache-control": "public, max-age=300",      },    },  ); 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] = () =>          routePath === "/index.html"            ? htmlResponse(file)            : 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}`] = () =>      key === "index.html"        ? htmlResponse(file)        : 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 origins granted credentialed CORS access, on top of localhost/   * 127.0.0.1 (any port). There is no Host allowlist — the bearer token is the   * security boundary — so reaching the server from another host (e.g. over a   * tailnet) needs nothing here; this only widens cross-origin CORS.   */  allowedHosts?: ReadonlyArray<string>;  /**   * Bearer token required for every `/api` and `/mcp` request. Optional — when   * omitted the stable token is loaded from (or minted into) `auth.json`. Pass   * an explicit value to override (e.g. the desktop main process threads its   * own token to the sidecar child).   */  authToken?: string;  /** Test hook for supplying API/MCP handlers without loading the local server graph. */  handlers?: ServerHandlers;} export interface ServerInstance {  port: number;  /** The effective bearer token this server validates. Callers publish it in the   * manifest, print the `?_token=` bootstrap URL, and hand it to MCP clients. */  authToken: string;  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,  allowedHosts: ReadonlySet<string>,): Response => {  const origin = req.headers.get("origin");  // Same-origin requests carry no Origin header — nothing to do. Cross-origin  // requests only get credentialed CORS if their Origin is an allowed loopback  // host; an arbitrary web page (e.g. https://evil.example) gets no ACAO, so  // the browser blocks it reading the response even if it knew the token.  if (!origin || !isAllowedOrigin(origin, allowedHosts)) 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, allowedHosts: ReadonlySet<string>): Response =>  withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts); const closeProvidedHandlers = async (handlers: ServerHandlers): Promise<void> => {  await handlers.mcp.close();  await handlers.api.dispose();}; const ignoreCleanupFailure = async (cleanup: () => Promise<unknown>): Promise<void> => {  // oxlint-disable-next-line executor/no-promise-catch -- boundary: startup cleanup is best-effort and must not mask the original startup failure  await cleanup().catch(() => {});}; 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";  // ONE credential, always present: an explicit override or the stable token  // from auth.json (minted on first run). Auth is unconditionally on — loopback  // is no longer a free pass, since Executor runs arbitrary code that any local  // process could otherwise drive.  const authToken = normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken();  const isAuthorized = makeIsAuthorized(authToken);  // CORS-only origin allowlist (no Host gate — the bearer is the boundary).  const corsAllowedHosts = new Set<string>([    ...DEFAULT_ALLOWED_HOSTS,    ...(opts.allowedHosts ?? []),  ]);  const clientDir = opts.clientDir ?? resolve(import.meta.dirname, "../dist");   startIntegrationsRefresh();   const ownsHandlers = opts.handlers === undefined;  const handlers = opts.handlers ?? (await getServerHandlers(authToken));  let viteChild: ViteChild | null = null;   const disposeOwnedResources = async (): Promise<void> => {    setOAuthCompletionListener(null);    if (ownsHandlers) {      await disposeServerHandlers();    } else {      await closeProvidedHandlers(handlers);    }    if (viteChild) await viteChild.stop();  };   // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: after handlers boot, failed static/dev/Bun startup must release DB ownership before surfacing the startup error  try {    // 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;     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();      // Diagnostic only — this is the internal vite port the daemon proxies to.      // It must NOT read as a destination: the URL to open is the `Open:` line the      // CLI prints (the daemon port, with ?_token). Hitting the vite port directly      // skips that bootstrap and lands on the auth gate.      console.log(`[executor] (internal) vite dev child at ${viteChild.url} — don't open this`);      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 = () => htmlResponse(indexFile);    } else {      staticRoutes = collectStaticRoutes(clientDir);      const indexFile = Bun.file(join(clientDir, "index.html"));      serveIndex = () => htmlResponse(indexFile);    }     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 withCors = (response: Response): Response =>          withCorsHeaders(req, response, corsAllowedHosts);         if (req.method === "OPTIONS" && req.headers.has("origin")) {          return corsPreflightResponse(req, corsAllowedHosts);        }         const url = new URL(req.url);         // Unauthenticated liveness probe — carries no data, used by the CLI        // reachability check (which therefore never forwards a credential).        if (url.pathname === "/api/health" && req.method === "GET") {          return withCors(new Response("ok", { headers: { "content-type": "text/plain" } }));        }         // OAuth callbacks and CIMD documents are reached by the external        // provider, which cannot carry our local bearer. Everything else under        // /api and /mcp requires the bearer.        const skipAuth = isUnauthenticatedOAuthPath(url.pathname);        const isMcpPath = url.pathname === "/mcp" || url.pathname.startsWith("/mcp/");        const isGatedSurface = url.pathname.startsWith("/api") || isMcpPath;         if (isGatedSurface && !skipAuth && !isAuthorized(req)) {          return withCors(            new Response("Unauthorized", {              status: 401,              headers: { "www-authenticate": 'Bearer realm="executor"' },            }),          );        }         if (isUnauthenticatedOAuthClientMetadataPath(url.pathname) && req.method === "GET") {          return withCors(oauthClientMetadataResponse(`${url.pathname}${url.search}`, req));        }         if (isMcpPath) {          return withCors(await handlers.mcp.handleRequest(req));        }         if (url.pathname.startsWith("/api/mcp-sessions/")) {          // GET → paused-execution detail for the approval page; POST → record the          // decision. Both are bearer-gated above.          const handler =            req.method === "GET"              ? handlers.mcp.handlePausedRequest              : handlers.mcp.handleApprovalRequest;          return withCors(await handler(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 withCors(            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 withCors(await handlers.api.handler(new Request(url, req)));        }         // App-level routes the Effect app serves at root, OUTSIDE the `/api`        // prefix the shell strips (e.g. `/v1/app/npm/dist-tags`, which the web        // shell's update check fetches). Public, like `/api/health` above, so no        // bearer gate. Forward verbatim; without this they'd fall through to the        // vite/SPA fallback and answer 200-with-HTML, breaking the JSON parse.        if (url.pathname === "/v1" || url.pathname.startsWith("/v1/")) {          return withCors(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 withCors(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 withCors(new Response("Not Found", { status: 404 }));        }         // SPA fallback (unauthenticated — the browser loads the shell, then reads        // its token and sends the bearer on subsequent /api calls).        return withCors(await serveIndex());      },      error(error) {        console.error("Server error:", error);        return new Response("Internal Server Error", { status: 500 });      },    });     let stopped = false;    return {      port: server.port!,      authToken,      async stop() {        if (stopped) return;        stopped = true;        server.stop(true);        await disposeOwnedResources();      },    };  } catch (cause) {    await ignoreCleanupFailure(disposeOwnedResources);    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: async server start must reject with the original startup failure after cleanup    throw cause;  }} if (import.meta.main) {  const server = await startServer();  console.log(`Executor listening on http://localhost:${server.port}/?_token=${server.authToken}`);}