/** * Sandbox server controller: start/close and get URL. * Used by server.ts (prod) and the Vite plugin (dev/test). Same process lifecycle as the main server. */ import { createServer, type Server } from "node:http"; import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); export interface SandboxControllerOptions { /** Port to bind (0 = dynamic). */ port: number; /** Host to bind (default localhost). */ host?: string; } export interface SandboxController { start(): Promise<{ port: number; url: string }>; close(): Promise; getUrl(): string | null; } /** * Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → 0 (dynamic). */ export function resolveSandboxPort(): number { const fromSandbox = process.env.MCP_SANDBOX_PORT; if (fromSandbox !== undefined && fromSandbox !== "") { const n = parseInt(fromSandbox, 10); if (!Number.isNaN(n) && n >= 0) return n; } const fromServer = process.env.SERVER_PORT; if (fromServer !== undefined && fromServer !== "") { const n = parseInt(fromServer, 10); if (!Number.isNaN(n) && n >= 0) return n; } return 0; } export function createSandboxController( options: SandboxControllerOptions, ): SandboxController { const { port, host = "localhost" } = options; let server: Server | null = null; let sandboxUrl: string | null = null; let sandboxHtml: string; try { const sandboxHtmlPath = join(__dirname, "../static/sandbox_proxy.html"); sandboxHtml = readFileSync(sandboxHtmlPath, "utf-8"); } catch (e) { sandboxHtml = "Sandbox not loaded: " + String((e as Error).message) + ""; } return { async start(): Promise<{ port: number; url: string }> { if (server && sandboxUrl) { const p = parseInt(new URL(sandboxUrl).port, 10); return { port: p, url: sandboxUrl }; } return new Promise((resolve) => { // Guard so a `listen` error followed by a (theoretically possible) // late `listening` callback doesn't double-resolve the promise. The // first signal wins. let settled = false; const settle = (value: { port: number; url: string }) => { /* v8 ignore next -- defensive double-settle guard: for a TCP listen the `error` and `listening` events are mutually exclusive, so the second-signal early-return is unreachable in practice. */ if (settled) return; settled = true; resolve(value); }; server = createServer((req, res) => { if ( req.method !== "GET" || (req.url !== "/sandbox" && req.url !== "/sandbox/") ) { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not Found"); return; } res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store, no-cache, must-revalidate", Pragma: "no-cache", }); res.end(sandboxHtml); }); server.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { console.error( `Sandbox: port ${port || "dynamic"} in use. MCP Apps tab may not work.`, ); } else { console.error("Sandbox server error:", err); } // Best-effort degradation: resolve with empty values rather than // rejecting so callers (the Vite plugin in particular) keep // attaching `/api/*`. `sandboxUrl` stays null, `getUrl()` keeps // returning null, and the banner omits the sandbox line. server = null; settle({ port: 0, url: "" }); }); server.listen(port, host, () => { const addr = server!.address(); const actualPort = typeof addr === "object" && addr !== null && "port" in addr ? addr.port : /* v8 ignore next -- unreachable for a TCP listen: `address()` always returns an `AddressInfo` object with `port`. The string form only occurs for unix-socket/pipe listens, which this controller never performs. */ (addr as unknown as number); sandboxUrl = `http://${host}:${actualPort}/sandbox`; settle({ port: actualPort, url: sandboxUrl }); }); }); }, async close(): Promise { if (!server) return; return new Promise((resolve) => { server!.close(() => { server = null; sandboxUrl = null; resolve(); }); }); }, getUrl(): string | null { return sandboxUrl; }, }; }