inspector/clients/web/server/vite-hono-plugin.ts
Cliff Hall 37a4d99575
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>
2026-05-18 19:22:14 -04:00

198 lines
7.5 KiB
TypeScript

/**
* 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);
},
};
}