248 lines · 9.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 { timingSafeEqual } from "node:crypto";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 LOOPBACK_BIND_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); const normalizeCredential = (value: string | undefined): string | null => {  const normalized = value?.trim();  return normalized && normalized.length > 0 ? normalized : null;}; const safeEqual = (actual: string, expected: string): boolean => {  const actualBytes = Buffer.from(actual);  const expectedBytes = Buffer.from(expected);  return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);}; const isLoopbackBindHost = (hostname: string): boolean =>  LOOPBACK_BIND_HOSTS.has(hostname.trim().toLowerCase()); const makeIsAllowedHost =  (allowed: ReadonlySet<string>) =>  (request: Request): boolean => {    const host = request.headers.get("host");    if (!host) return true;    const hostname = host.replace(/:\d+$/, "");    return allowed.has(hostname);  }; const hasBearerToken = (request: Request, token: string): boolean => {  const authorization = request.headers.get("authorization");  const bearer = authorization?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();  return (    (bearer !== undefined && safeEqual(bearer, token)) ||    safeEqual(request.headers.get("x-executor-token") ?? "", token)  );}; const hasBasicPassword = (request: Request, password: string): boolean => {  const authorization = request.headers.get("authorization");  const encoded = authorization?.match(/^Basic\s+(.+)$/i)?.[1]?.trim();  if (!encoded) return false;   let decoded: string;  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Basic auth decoding accepts untrusted header bytes  try {    decoded = Buffer.from(encoded, "base64").toString("utf8");  } catch {    return false;  }   const separator = decoded.indexOf(":");  const actualPassword = separator >= 0 ? decoded.slice(separator + 1) : decoded;  return safeEqual(actualPassword, password);}; const makeIsAuthorized =  (auth: { token: string | null; password: string | null }) =>  (request: Request): boolean =>    (auth.token !== null && hasBearerToken(request, auth.token)) ||    (auth.password !== null && hasBasicPassword(request, auth.password)); // ---------------------------------------------------------------------------// Static files// --------------------------------------------------------------------------- type StaticHandler = () => Response | Promise<Response>; const hasFileExtension = (pathname: string): boolean => {  const lastSegment = pathname.split("/").at(-1) ?? "";  return lastSegment.includes(".");}; 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;} // ---------------------------------------------------------------------------// 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>>; 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");   const handlers = opts.handlers ?? (await getServerHandlers());   // Build static routes from either embedded assets or disk  let staticRoutes: Record<string, StaticHandler>;  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 });      }       if (requiresAuth && !isAuthorized(req)) {        return new Response("Unauthorized", {          status: 401,          headers: { "www-authenticate": 'Bearer realm="executor", Basic realm="executor"' },        });      }       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}`);}