| 46 | // stripping — the self-host API is served under /api by the prefixed router, so |
| 47 | // the handler expects the full path. Handler rebuilds when src/ changes. |
| 48 | function executorApiPlugin(): Plugin { |
| 49 | let handlerPromise: Promise<{ handler: (request: Request) => Promise<Response> }> | null = null; |
| 50 | const getHandler = async () => { |
| 51 | if (!handlerPromise) { |
| 52 | // Computed specifier so Vite's Node-based config loader does NOT statically |
| 53 | // follow this into ./src/api/api (which imports @executor-js/host-mcp, whose |
| 54 | // extensionless re-exports resolve under Bun but not Node ESM). It only runs |
| 55 | // at dev-server request time, under `bunx --bun vite dev`. |
| 56 | const apiModule = new URL("./src/api/api.ts", import.meta.url).href; |
| 57 | handlerPromise = import(apiModule).then((m) => m.makeSelfHostApiHandler()); |
| 58 | } |
| 59 | return handlerPromise; |
| 60 | }; |
| 61 | |
| 62 | return { |
| 63 | name: "executor-selfhost-api", |
| 64 | apply: "serve", |
| 65 | configureServer(server) { |
| 66 | server.watcher.on("change", (path) => { |
| 67 | if (path.includes("/src/") || path.endsWith("/executor.config.ts")) handlerPromise = null; |
| 68 | }); |
| 69 | server.middlewares.use(async (req, res, next) => { |
| 70 | let rawUrl = req.url ?? "/"; |
| 71 | // The "Connect an agent" card prints `/<organizationId>/mcp`; self-host |
| 72 | // serves the bare `/mcp`, so rewrite it here (prod does the same in |
| 73 | // serve.ts) — otherwise this org-pinned path isn't recognized as an MCP |
| 74 | // path and falls through to the SPA as a 404. Mirrors ./src/mcp/org-path. |
| 75 | const devOrigin = `http://${req.headers.host ?? `localhost:${DEV_PORT}`}`; |
| 76 | const originalPathname = new URL(rawUrl, devOrigin).pathname; |
| 77 | const pathname = stripMcpOrgSegment(originalPathname) ?? ""; |
| 78 | // Carries the ORIGINAL org-scoped pathname through to the handler (see |
| 79 | // ./src/mcp/auth.ts) so the protected-resource metadata can echo it |
| 80 | // back to a client that dialed org-scoped — mirrors serve.ts's prod |
| 81 | // middleware. Set only when we ourselves rewrote this request; any |
| 82 | // client-supplied value is dropped below so it can't be spoofed. |
| 83 | let originalPathHeader: string | null = null; |
| 84 | if (pathname !== "") { |
| 85 | const original = new URL(rawUrl, devOrigin); |
| 86 | rawUrl = `${pathname}${original.search}`; |
| 87 | originalPathHeader = originalPathname; |
| 88 | } |
| 89 | // Match on PATHNAME, not a raw-URL prefix: `/mcp` must NOT swallow the |
| 90 | // SPA route `/mcp-consent`, or the dev server misroutes it to the API |
| 91 | // handler and returns a 404. |
| 92 | const path = new URL(rawUrl, devOrigin).pathname; |
| 93 | const handled = |
| 94 | path === "/api" || |
| 95 | path.startsWith("/api/") || |
| 96 | path === "/mcp" || |
| 97 | path.startsWith("/mcp/") || |
| 98 | path === "/docs" || |
| 99 | path.startsWith("/docs/") || |
| 100 | // Un-prefixed app-level routes (e.g. `/v1/app/npm/dist-tags`, which the |
| 101 | // shell's update check fetches). Served by the Effect router in prod; |
| 102 | // without this the SPA index.html fallback answers 200-with-HTML and |
| 103 | // the JSON parse fails, so the UpdateCard never appears. |
| 104 | path === "/v1" || |
| 105 | path.startsWith("/v1/") || |