Port v1.5 web dev backend (vite-hono-plugin + remote-server bootstrap) (#1321)
* feat(web): port v1.5 dev backend (vite-hono-plugin + sandbox + remote-server bootstrap) Ports the v1.5 in-process dev backend into v2 so `npm run dev` serves the Hono remote-server (`/api/*`) alongside the Vite-served browser app. Without this, the browser-side `createRemoteTransport` had nothing to talk to during local development. Adds under `clients/web/server/`: - vite-hono-plugin.ts (Hono middleware on the Vite dev server; gated to `apply: 'serve'` and skipped when `process.env.VITEST` is set so it stays inert in vitest/storybook runs) - sandbox-controller.ts (standalone HTTP server for the MCP Apps sandbox) - web-server-config.ts (env parsing + initial-config payload + banner; `autoOpen` defaults off under VITEST so test runs don't shell out to `open`) - server.ts (standalone Hono prod server for the future v2 launcher, #1246) - start-vite-dev-server.ts (in-process Vite starter for the future launcher) - vite-base-config.ts (shared optimizeDeps exclusions) Wires `honoMiddlewarePlugin(buildWebServerConfigFromEnv())` into `vite.config.ts` with `apply: 'serve'`. Adds the `open` dependency and a DOM lib + `@inspector/core/*` path alias to `tsconfig.node.json` so the dev-backend files type-check alongside vite.config.ts. Coverage: web-server-config.ts (100% lines), sandbox-controller.ts (91% lines), and vite-base-config.ts (100%) all clear the per-file gate. The Vite plugin, prod server, and dev-server starter are excluded from coverage as runtime glue exercised by `npm run dev` end-to-end (callers covered separately; their logic lives in core/mcp/remote/node, which integration tests already exercise). Closes #1320. Unblocks #1244 (Wire App.tsx) — its test plan can now run end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web/server): address PR review feedback on the dev-backend port - sandbox-controller: resolve start() with empty values on listen error (e.g. EADDRINUSE on a fixed MCP_SANDBOX_PORT) instead of leaving the promise pending forever. Without this, the Vite plugin's `await sandboxController.start()` could hang the entire dev backend on a port collision. Banner now silently omits the sandbox line; getUrl() keeps returning null. Adds a regression test exercising the EADDRINUSE path against a real socket. - vite-hono-plugin: listen for `error` and `close` on the incoming request stream (in addition to `end`) so an aborted POST/PUT (browser navigates mid-upload) doesn't leak the middleware indefinitely. - vite-hono-plugin: add a `.catch()` to the initial `pump()` kickoff so a sync throw before the first `await reader.read()` can't surface as an unhandled rejection. - sandbox_proxy.html: drop the stale CSP comment — v1.5's sandbox HTTP server never set CSP either; this PR doesn't regress anything. Replace with the actual isolation model (iframe sandbox attribute + origin allowlist) so future readers don't assume a CSP header is enforced. - Hoist `WebServerHandle` to a shared `server/types.ts` so the future launcher port (#1246) doesn't need to pick one of two duplicate declarations. - Extract `resolveAutoOpen()` helper out of the triple-nested ternary in `buildWebServerConfigFromEnv` for legibility, with the three-way logic documented in one place. - Add an inline comment explaining why `webServerConfigToInitialPayload` falls back to streamable-http for unknown discriminators (forward-compat with future SDK transports) rather than throwing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3569321ceb
commit
37a4d99575
7 changed files with 1636 additions and 14 deletions
136
clients/web/server/sandbox-controller.ts
Normal file
136
clients/web/server/sandbox-controller.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* 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<void>;
|
||||
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 =
|
||||
"<!DOCTYPE html><html><body>Sandbox not loaded: " +
|
||||
String((e as Error).message) +
|
||||
"</body></html>";
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
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
|
||||
: (addr as unknown as number);
|
||||
sandboxUrl = `http://${host}:${actualPort}/sandbox`;
|
||||
settle({ port: actualPort, url: sandboxUrl });
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (!server) return;
|
||||
return new Promise((resolve) => {
|
||||
server!.close(() => {
|
||||
server = null;
|
||||
sandboxUrl = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
getUrl(): string | null {
|
||||
return sandboxUrl;
|
||||
},
|
||||
};
|
||||
}
|
||||
147
clients/web/server/server.ts
Normal file
147
clients/web/server/server.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* Hono production server. Export startHonoServer(config) for in-process use by the runner.
|
||||
* When run as the main module (e.g. node dist/server.js), build config from env and start.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import open from "open";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { Hono } from "hono";
|
||||
import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
|
||||
import { createSandboxController } from "./sandbox-controller.js";
|
||||
import type { WebServerConfig } from "./web-server-config.js";
|
||||
import {
|
||||
webServerConfigToInitialPayload,
|
||||
buildWebServerConfigFromEnv,
|
||||
printServerBanner,
|
||||
} from "./web-server-config.js";
|
||||
import type { WebServerHandle } from "./types.js";
|
||||
|
||||
export type { WebServerHandle };
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* Start the Hono production server in-process. Returns a handle that closes sandbox then HTTP server.
|
||||
* Caller owns SIGINT/SIGTERM; do not register signal handlers here.
|
||||
*/
|
||||
export async function startHonoServer(
|
||||
config: WebServerConfig,
|
||||
): Promise<WebServerHandle> {
|
||||
const sandboxController = createSandboxController({
|
||||
port: config.sandboxPort,
|
||||
host: config.sandboxHost,
|
||||
});
|
||||
await sandboxController.start();
|
||||
|
||||
const resolvedAuthToken =
|
||||
config.authToken ||
|
||||
(config.dangerouslyOmitAuth ? "" : randomBytes(32).toString("hex"));
|
||||
|
||||
const rootPath = config.staticRoot ?? __dirname;
|
||||
|
||||
const { app: apiApp } = createRemoteApp({
|
||||
authToken: config.dangerouslyOmitAuth ? undefined : resolvedAuthToken,
|
||||
dangerouslyOmitAuth: config.dangerouslyOmitAuth,
|
||||
storageDir: config.storageDir,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
sandboxUrl: sandboxController.getUrl() ?? undefined,
|
||||
logger: config.logger,
|
||||
initialConfig: webServerConfigToInitialPayload(config),
|
||||
});
|
||||
|
||||
const app = new Hono();
|
||||
app.use("/api/*", async (c) => {
|
||||
return apiApp.fetch(c.req.raw);
|
||||
});
|
||||
|
||||
app.get("/", async (c) => {
|
||||
try {
|
||||
const indexPath = join(rootPath, "index.html");
|
||||
const html = readFileSync(indexPath, "utf-8");
|
||||
return c.html(html);
|
||||
} catch (error) {
|
||||
console.error("Error serving index.html:", error);
|
||||
return c.notFound();
|
||||
}
|
||||
});
|
||||
|
||||
app.use(
|
||||
"/*",
|
||||
serveStatic({
|
||||
root: rootPath,
|
||||
rewriteRequestPath: (path) => {
|
||||
if (!path.includes(".") && !path.startsWith("/api")) {
|
||||
return "/index.html";
|
||||
}
|
||||
return path;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const httpServer = serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port: config.port,
|
||||
hostname: config.hostname,
|
||||
},
|
||||
(info) => {
|
||||
const sandboxUrl = sandboxController.getUrl();
|
||||
const url = printServerBanner(
|
||||
config,
|
||||
info.port,
|
||||
resolvedAuthToken,
|
||||
sandboxUrl ?? undefined,
|
||||
);
|
||||
if (config.autoOpen) {
|
||||
open(url);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
httpServer.on("error", (err: Error) => {
|
||||
if (err.message.includes("EADDRINUSE")) {
|
||||
console.error(
|
||||
`MCP Inspector PORT IS IN USE at http://${config.hostname}:${config.port}`,
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
async close(): Promise<void> {
|
||||
await sandboxController.close();
|
||||
if ("closeAllConnections" in httpServer) {
|
||||
httpServer.closeAllConnections();
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Run when this file is executed as the main module (e.g. node dist/server.js). */
|
||||
async function runStandalone(): Promise<void> {
|
||||
const config = buildWebServerConfigFromEnv();
|
||||
const handle = await startHonoServer(config);
|
||||
const shutdown = () => {
|
||||
void handle.close().then(() => process.exit(0));
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1] !== undefined &&
|
||||
resolve(process.argv[1]) === resolve(__filename);
|
||||
if (isMain) {
|
||||
void runStandalone();
|
||||
}
|
||||
49
clients/web/server/start-vite-dev-server.ts
Normal file
49
clients/web/server/start-vite-dev-server.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Start Vite dev server in-process via the Node API. Config is passed into the plugin; no shared state.
|
||||
*
|
||||
* Uses the shared base config so Node-only callers don't need to load
|
||||
* `vite.config.ts` (which Node can't import directly).
|
||||
*/
|
||||
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createServer, type InlineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import type { WebServerConfig } from "./web-server-config.js";
|
||||
import { honoMiddlewarePlugin } from "./vite-hono-plugin.js";
|
||||
import { getViteBaseConfig } from "./vite-base-config.js";
|
||||
import type { WebServerHandle } from "./types.js";
|
||||
|
||||
export type { WebServerHandle };
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Start the Vite dev server in-process. Passes config into the plugin. Caller owns SIGINT/SIGTERM.
|
||||
*/
|
||||
export async function startViteDevServer(
|
||||
config: WebServerConfig,
|
||||
): Promise<WebServerHandle> {
|
||||
// Canonicalize so Vite's config hash is stable and matches the deps cache.
|
||||
const root = resolve(join(__dirname, ".."));
|
||||
const baseConfig = getViteBaseConfig();
|
||||
const inlineConfig: InlineConfig = {
|
||||
...baseConfig,
|
||||
configFile: false,
|
||||
root,
|
||||
server: {
|
||||
port: config.port,
|
||||
host: config.hostname,
|
||||
},
|
||||
plugins: [react(), honoMiddlewarePlugin(config)],
|
||||
};
|
||||
const server = await createServer(inlineConfig);
|
||||
|
||||
await server.listen();
|
||||
|
||||
return {
|
||||
async close(): Promise<void> {
|
||||
await server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
9
clients/web/server/types.ts
Normal file
9
clients/web/server/types.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Shared types for the in-process web server runners (`server.ts` and
|
||||
* `start-vite-dev-server.ts`). Both expose the same start/close lifecycle
|
||||
* so the future v2 launcher (#1246) can treat them interchangeably.
|
||||
*/
|
||||
|
||||
export interface WebServerHandle {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
31
clients/web/server/vite-base-config.ts
Normal file
31
clients/web/server/vite-base-config.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Shared Vite config (build externals + optimizeDeps). Consumed by `vite.config.ts`
|
||||
* and by `start-vite-dev-server.ts` so the in-process Vite starter can apply the
|
||||
* same node-only exclusions without re-loading `vite.config.ts` (Node can't
|
||||
* import .ts directly).
|
||||
*
|
||||
* Aliases and the vitest project setup live in `vite.config.ts` itself — only
|
||||
* the parts needed by both the CLI runner and `vite dev` belong here.
|
||||
*/
|
||||
|
||||
export function getViteBaseConfig() {
|
||||
return {
|
||||
optimizeDeps: {
|
||||
// Node-only modules that the dev backend (core/mcp/remote/node/*,
|
||||
// core/mcp/node/*) consumes. Excluding them from Vite's dep-pre-bundling
|
||||
// step keeps `vite dev` from trying to scan/bundle them into the
|
||||
// browser graph during startup.
|
||||
exclude: [
|
||||
"@modelcontextprotocol/sdk/client/stdio.js",
|
||||
// `atomically` is reached only through `core/storage/store-io.ts`,
|
||||
// which is imported by `core/mcp/remote/node/server.ts` (the Hono
|
||||
// app). The module never lands in the browser graph; excluding it
|
||||
// keeps Vite's dev-time scanner from chasing it through the plugin's
|
||||
// node-only import chain.
|
||||
"atomically",
|
||||
"cross-spawn",
|
||||
"which",
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
198
clients/web/server/vite-hono-plugin.ts
Normal file
198
clients/web/server/vite-hono-plugin.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
/**
|
||||
* Vite plugin that adds Hono middleware for /api/* and the MCP Apps sandbox.
|
||||
* Receives WebServerConfig only (from runner or from buildWebServerConfigFromEnv in vite.config).
|
||||
*
|
||||
* `apply: 'serve'` confines the plugin to `vite dev` / `vite preview` — vitest
|
||||
* projects share this config but never run the dev server, so the plugin stays
|
||||
* inert there.
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Plugin } from "vite";
|
||||
import open from "open";
|
||||
// Direct leaf import — the `core/mcp/remote/node/index.ts` barrel re-exports
|
||||
// from `../constants.js`, which would otherwise drag the broader
|
||||
// `core/mcp/remote/index.ts` barrel (including `createRemoteLogger`, with its
|
||||
// `pino/browser.js` import) into Vite's config-time module graph and produce
|
||||
// spurious "could not resolve" warnings during build.
|
||||
import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
|
||||
import { createSandboxController } from "./sandbox-controller.js";
|
||||
import type { WebServerConfig } from "./web-server-config.js";
|
||||
import {
|
||||
webServerConfigToInitialPayload,
|
||||
printServerBanner,
|
||||
} from "./web-server-config.js";
|
||||
|
||||
export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
||||
return {
|
||||
name: "hono-api-middleware",
|
||||
// `apply: 'serve'` keeps the plugin out of `vite build`, but Vitest still
|
||||
// instantiates a Vite server in middleware mode (no HTTP server) for
|
||||
// transforms and invokes `configureServer` regardless. Returning early
|
||||
// when `server.httpServer` is missing keeps the plugin inert in that
|
||||
// context — only an actual `vite dev` (or `vite preview`) instance has
|
||||
// an HTTP server to attach to.
|
||||
apply: "serve",
|
||||
async configureServer(server) {
|
||||
// Skip the plugin entirely under Vitest. The storybook project runs
|
||||
// tests in a real headless Chromium and spins up a Vite server with
|
||||
// `httpServer` attached — that would otherwise pass the next guard and
|
||||
// attach the Hono backend (sandbox HTTP server, banner, auto-open) for
|
||||
// every test run. Component stories never hit `/api/*`, so the plugin
|
||||
// brings no value to that context and only adds noise / port churn.
|
||||
if (process.env.VITEST) {
|
||||
return;
|
||||
}
|
||||
if (!server.httpServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sandboxController = createSandboxController({
|
||||
port: config.sandboxPort,
|
||||
host: config.sandboxHost,
|
||||
});
|
||||
await sandboxController.start();
|
||||
|
||||
const originalClose = server.close.bind(server);
|
||||
server.close = async () => {
|
||||
await sandboxController.close();
|
||||
return originalClose();
|
||||
};
|
||||
|
||||
const { app: honoApp, authToken: resolvedToken } = createRemoteApp({
|
||||
authToken: config.dangerouslyOmitAuth ? undefined : config.authToken,
|
||||
dangerouslyOmitAuth: config.dangerouslyOmitAuth,
|
||||
storageDir: config.storageDir,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
sandboxUrl: sandboxController.getUrl() ?? undefined,
|
||||
logger: config.logger,
|
||||
initialConfig: webServerConfigToInitialPayload(config),
|
||||
});
|
||||
|
||||
const sandboxUrl = sandboxController.getUrl();
|
||||
|
||||
const logBanner = () => {
|
||||
const address = server.httpServer?.address();
|
||||
const actualPort =
|
||||
typeof address === "object" && address !== null
|
||||
? address.port
|
||||
: config.port;
|
||||
|
||||
const url = printServerBanner(
|
||||
config,
|
||||
actualPort,
|
||||
resolvedToken,
|
||||
sandboxUrl ?? undefined,
|
||||
);
|
||||
|
||||
if (config.autoOpen) {
|
||||
open(url);
|
||||
}
|
||||
};
|
||||
|
||||
server.httpServer.once("listening", () => {
|
||||
setImmediate(logBanner);
|
||||
});
|
||||
|
||||
const honoMiddleware = async (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
next: (err?: unknown) => void,
|
||||
) => {
|
||||
try {
|
||||
const pathname = req.url || "";
|
||||
if (!pathname.startsWith("/api")) {
|
||||
return next();
|
||||
}
|
||||
const url = `http://${req.headers.host}${pathname}`;
|
||||
const headers = new Headers();
|
||||
Object.entries(req.headers).forEach(
|
||||
([key, value]: [string, string | string[] | undefined]) => {
|
||||
if (value) {
|
||||
headers.set(
|
||||
key,
|
||||
Array.isArray(value) ? value.join(", ") : value,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
const init: RequestInit = { method: req.method, headers };
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
// Listen for `end`, `error`, AND `close`. Without `error`/`close`
|
||||
// an aborted upload (browser navigates away mid-POST) leaves this
|
||||
// promise pending forever, leaking memory and the connection.
|
||||
// We resolve on every terminal event — partial body bytes flow on
|
||||
// to Hono; the underlying fetch will fail downstream if the
|
||||
// payload is truncated, which is the expected behavior on abort.
|
||||
await new Promise<void>((resolve) => {
|
||||
req.once("end", () => resolve());
|
||||
req.once("error", () => resolve());
|
||||
req.once("close", () => resolve());
|
||||
});
|
||||
if (chunks.length > 0) {
|
||||
init.body = Buffer.concat(chunks);
|
||||
}
|
||||
}
|
||||
const response = await honoApp.fetch(new Request(url, init));
|
||||
res.statusCode = response.status;
|
||||
response.headers.forEach((value, key) => {
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
const isSSE = response.headers
|
||||
.get("content-type")
|
||||
?.includes("text/event-stream");
|
||||
if (isSSE) {
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
}
|
||||
if (response.body) {
|
||||
res.flushHeaders?.();
|
||||
const reader = response.body.getReader();
|
||||
const pump = async () => {
|
||||
try {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
res.end();
|
||||
} else {
|
||||
res.write(Buffer.from(value), (err) => {
|
||||
if (err) {
|
||||
console.error("[Hono Middleware] Write error:", err);
|
||||
reader.cancel().catch(() => {});
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
pump().catch((err) => {
|
||||
console.error("[Hono Middleware] Pump error:", err);
|
||||
reader.cancel().catch(() => {});
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Hono Middleware] Read error:", err);
|
||||
reader.cancel().catch(() => {});
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
// The recursive pump() awaits internally, but a sync throw before
|
||||
// the first `await reader.read()` (e.g. a broken reader) would
|
||||
// surface as an unhandled rejection without this kickoff catch.
|
||||
pump().catch((err) => {
|
||||
console.error("[Hono Middleware] Initial pump error:", err);
|
||||
reader.cancel().catch(() => {});
|
||||
res.end();
|
||||
});
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
server.middlewares.use(honoMiddleware);
|
||||
},
|
||||
};
|
||||
}
|
||||
217
clients/web/server/web-server-config.ts
Normal file
217
clients/web/server/web-server-config.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* Config object for the web server (dev and prod). Passed in-process; no env handoff.
|
||||
*/
|
||||
|
||||
import pino from "pino";
|
||||
import type { Logger } from "pino";
|
||||
import type { MCPServerConfig } from "../../../core/mcp/types.ts";
|
||||
// Import directly from the leaf module — not the `core/mcp/remote/index.ts`
|
||||
// barrel — so Vite's config-time module graph doesn't pull in `createRemoteLogger`
|
||||
// (which references `pino/browser.js`) or other browser-side re-exports that
|
||||
// have no business showing up in this Node-only file's import chain.
|
||||
import {
|
||||
API_SERVER_ENV_VARS,
|
||||
LEGACY_AUTH_TOKEN_ENV,
|
||||
} from "../../../core/mcp/remote/constants.ts";
|
||||
import type { InitialConfigPayload } from "../../../core/mcp/remote/node/server.ts";
|
||||
import { resolveSandboxPort } from "./sandbox-controller.js";
|
||||
|
||||
export interface WebServerConfig {
|
||||
port: number;
|
||||
hostname: string;
|
||||
authToken: string;
|
||||
dangerouslyOmitAuth: boolean;
|
||||
/** Single initial MCP server config, or null when no server specified. */
|
||||
initialMcpConfig: MCPServerConfig | null;
|
||||
storageDir: string | undefined;
|
||||
allowedOrigins: string[];
|
||||
/** Sandbox port (0 = dynamic). */
|
||||
sandboxPort: number;
|
||||
sandboxHost: string;
|
||||
logger: Logger | undefined;
|
||||
/** When true, open browser after server starts. */
|
||||
autoOpen: boolean;
|
||||
/** Root directory for static files (index.html, assets). When runner starts server in-process, pass path to dist/. */
|
||||
staticRoot?: string;
|
||||
}
|
||||
|
||||
function defaultEnvironmentFromProcess(
|
||||
extra?: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const keys =
|
||||
process.platform === "win32"
|
||||
? [
|
||||
"APPDATA",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
"LOCALAPPDATA",
|
||||
"PATH",
|
||||
"PROCESSOR_ARCHITECTURE",
|
||||
"SYSTEMDRIVE",
|
||||
"SYSTEMROOT",
|
||||
"TEMP",
|
||||
"USERNAME",
|
||||
"USERPROFILE",
|
||||
"PROGRAMFILES",
|
||||
]
|
||||
: ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"];
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
for (const key of keys) {
|
||||
const value = process.env[key];
|
||||
if (value && !value.startsWith("()")) {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
if (extra) {
|
||||
Object.assign(out, extra);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert WebServerConfig.initialMcpConfig to the shape expected by GET /api/config.
|
||||
*/
|
||||
export function webServerConfigToInitialPayload(
|
||||
config: WebServerConfig,
|
||||
): InitialConfigPayload {
|
||||
const mc = config.initialMcpConfig;
|
||||
const defaultEnvironment = defaultEnvironmentFromProcess(
|
||||
mc && "env" in mc && mc.env ? mc.env : undefined,
|
||||
);
|
||||
|
||||
if (!mc) {
|
||||
return { defaultEnvironment };
|
||||
}
|
||||
|
||||
if (mc.type === "stdio" || mc.type === undefined) {
|
||||
return {
|
||||
defaultCommand: mc.command,
|
||||
defaultArgs: mc.args ?? [],
|
||||
defaultTransport: "stdio",
|
||||
defaultCwd: mc.cwd,
|
||||
defaultEnvironment,
|
||||
};
|
||||
}
|
||||
if (mc.type === "sse") {
|
||||
return {
|
||||
defaultTransport: "sse",
|
||||
defaultServerUrl: mc.url,
|
||||
defaultHeaders: mc.headers ?? undefined,
|
||||
defaultEnvironment,
|
||||
};
|
||||
}
|
||||
if (mc.type === "streamable-http") {
|
||||
return {
|
||||
defaultTransport: "streamable-http",
|
||||
defaultServerUrl: mc.url,
|
||||
defaultHeaders: mc.headers ?? undefined,
|
||||
defaultEnvironment,
|
||||
};
|
||||
}
|
||||
// Forward-compat fallback: if a future SDK ships a new transport type the
|
||||
// launcher hasn't taught us about yet, prefer streamable-http (the most
|
||||
// permissive shape with `url` + optional `headers`) over throwing. Worst
|
||||
// case the UI shows the URL the user supplied; throwing here would deny
|
||||
// the user a hint at startup. Add a real branch above once the type is
|
||||
// known.
|
||||
const c = mc as unknown as { url: string; headers?: Record<string, string> };
|
||||
return {
|
||||
defaultTransport: "streamable-http",
|
||||
defaultServerUrl: c.url,
|
||||
defaultHeaders: c.headers,
|
||||
defaultEnvironment,
|
||||
};
|
||||
}
|
||||
|
||||
export function printServerBanner(
|
||||
config: WebServerConfig,
|
||||
actualPort: number,
|
||||
resolvedToken: string,
|
||||
sandboxUrl: string | undefined,
|
||||
): string {
|
||||
const baseUrl = `http://${config.hostname}:${actualPort}`;
|
||||
const url =
|
||||
config.dangerouslyOmitAuth || !resolvedToken
|
||||
? baseUrl
|
||||
: `${baseUrl}?${API_SERVER_ENV_VARS.AUTH_TOKEN}=${resolvedToken}`;
|
||||
|
||||
console.log(`\nMCP Inspector Web is up and running at:\n ${url}\n`);
|
||||
if (sandboxUrl) {
|
||||
console.log(` Sandbox (MCP Apps): ${sandboxUrl}\n`);
|
||||
}
|
||||
if (config.dangerouslyOmitAuth) {
|
||||
console.log(" Auth: disabled (DANGEROUSLY_OMIT_AUTH)\n");
|
||||
} else {
|
||||
console.log(` Auth token: ${resolvedToken}\n`);
|
||||
}
|
||||
|
||||
if (config.autoOpen) {
|
||||
console.log("Opening browser...");
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WebServerConfig from process.env. Used when running server as standalone (e.g. node dist/server.js).
|
||||
*/
|
||||
export function buildWebServerConfigFromEnv(): WebServerConfig {
|
||||
const port = parseInt(process.env.CLIENT_PORT ?? "6274", 10);
|
||||
const hostname = process.env.HOST ?? "localhost";
|
||||
const baseUrl = `http://${hostname}:${port}`;
|
||||
const dangerouslyOmitAuth = !!process.env.DANGEROUSLY_OMIT_AUTH;
|
||||
const authToken = dangerouslyOmitAuth
|
||||
? ""
|
||||
: ((process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] as string | undefined) ??
|
||||
(process.env[LEGACY_AUTH_TOKEN_ENV] as string | undefined) ??
|
||||
"");
|
||||
|
||||
const initialMcpConfig: MCPServerConfig | null = null;
|
||||
|
||||
const sandboxPort = resolveSandboxPort();
|
||||
|
||||
let logger: Logger | undefined;
|
||||
if (process.env.MCP_LOG_FILE) {
|
||||
logger = pino(
|
||||
{ level: "info" },
|
||||
pino.destination({
|
||||
dest: process.env.MCP_LOG_FILE,
|
||||
append: true,
|
||||
mkdir: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
hostname,
|
||||
authToken,
|
||||
dangerouslyOmitAuth,
|
||||
initialMcpConfig,
|
||||
storageDir: process.env.MCP_STORAGE_DIR,
|
||||
allowedOrigins: process.env.ALLOWED_ORIGINS?.split(",").filter(Boolean) ?? [
|
||||
baseUrl,
|
||||
],
|
||||
sandboxPort,
|
||||
sandboxHost: hostname,
|
||||
logger,
|
||||
autoOpen: resolveAutoOpen(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-way resolution for `autoOpen`:
|
||||
* - explicit `MCP_AUTO_OPEN_ENABLED=true` → always open (overrides VITEST)
|
||||
* - explicit `MCP_AUTO_OPEN_ENABLED=false` → never open
|
||||
* - anything else → open by default UNLESS Vitest
|
||||
* is running (Vitest sets `VITEST=true`; vite.config.ts is shared with
|
||||
* vitest projects and the Hono plugin would otherwise shell out to
|
||||
* `open()` on every test invocation).
|
||||
*/
|
||||
function resolveAutoOpen(): boolean {
|
||||
const flag = process.env.MCP_AUTO_OPEN_ENABLED;
|
||||
if (flag === "true") return true;
|
||||
if (flag === "false") return false;
|
||||
return !process.env.VITEST;
|
||||
}
|
||||
Loading…
Reference in a new issue