import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { Readable } from "node:stream"; import { fileURLToPath } from "node:url"; import { defineConfig, type Plugin } from "vite"; import appPlugin from "@executor-js/app/vite"; import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server"; import { loadOrMintLocalAuthToken } from "./src/auth"; import { consumeOAuthResult } from "./src/oauth-result-store"; import { isUnauthenticatedOAuthClientMetadataPath, isUnauthenticatedOAuthPath, makeIsAuthorized, } from "./src/serve-shared"; // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json const rootPackage = JSON.parse( readFileSync(new URL("../../package.json", import.meta.url), "utf8"), ) as { version: string; homepage?: string; repository?: string | { url?: string }; }; // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json const cliPackage = JSON.parse( readFileSync(new URL("../cli/package.json", import.meta.url), "utf8"), ) as { version?: string }; const repositoryUrl = typeof rootPackage.repository === "string" ? rootPackage.repository : rootPackage.repository?.url; const EXECUTOR_VERSION = cliPackage.version ?? rootPackage.version; const EXECUTOR_GITHUB_URL = ( rootPackage.homepage ?? repositoryUrl ?? "https://github.com/UsefulSoftwareCo/executor" ) .replace(/^git\+/, "") .replace(/\.git$/, ""); const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url)); const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response => new Response( JSON.stringify( oauthClientIdMetadataDocumentFromRequest({ requestUrl, webRequest, mountPrefix: "/api", }), ), { headers: { "content-type": "application/json", "cache-control": "public, max-age=300", }, }, ); /** * Vite plugin that forwards /api and /mcp requests to the Effect handlers * during development, so you don't need a separate server process. */ function executorApiPlugin(): Plugin { let handlers: import("./src/main").ServerHandlers | null = null; // The dev server's in-process /api + /mcp require the bearer (same gate as the // production Bun shell). The browser gets the token the SAME way it does in // production — via the one-time `?_token=` URL printed below — so there is no // dev-only token-injection path to drift. let devToken: string | null = null; return { name: "executor-api", configureServer(server) { devToken ??= loadOrMintLocalAuthToken(); // Print the bootstrap URL when vite is the front (plain `bun run dev`). // When the CLI daemon spawns vite as a child (EXECUTOR_DEV_VITE_PORT set), // the daemon prints its own `?_token=` URL and the app is loaded from the // daemon port, so we stay quiet to avoid two conflicting URLs. if (!process.env.EXECUTOR_DEV_VITE_PORT) { server.httpServer?.once("listening", () => { const address = server.httpServer?.address(); const port = typeof address === "object" && address ? address.port : server.config.server.port; server.config.logger.info( `\n Open with auth: http://127.0.0.1:${port}/?_token=${devToken}\n`, ); }); } server.watcher.on("change", (path) => { if (path.includes("/apps/local/src/") || path.endsWith("/executor.config.ts")) { handlers = null; } }); server.middlewares.use(async (req, res, next) => { const rawUrl = req.url ?? "/"; const pathOnly = rawUrl.split("?")[0] ?? "/"; const isApi = pathOnly.startsWith("/api/") || pathOnly === "/api"; const isMcp = pathOnly === "/mcp" || pathOnly.startsWith("/mcp/"); // App-level routes the Effect app serves at root, outside the `/api` // prefix (e.g. `/v1/app/npm/dist-tags`, the update-check the web shell // fetches). Public, like `/api/health` below. const isV1 = pathOnly === "/v1" || pathOnly.startsWith("/v1/"); if (!isApi && !isMcp && !isV1) return next(); // Gate parity with the production Bun shell (serve.ts): the vite server // is reachable by any local process, so /api and /mcp require the bearer // here too — otherwise /mcp would be unauthenticated arbitrary code // execution in dev. Exempt the health probe and the state-gated OAuth // callback. The SPA carries the token from its `?_token`/localStorage // bootstrap, so the UI is unaffected; external MCP clients use the // daemon port. const authExempt = pathOnly === "/api/health" || isV1 || isUnauthenticatedOAuthPath(pathOnly); if (!authExempt) { const presented = req.headers.authorization; const authValue = Array.isArray(presented) ? presented[0] : presented; const probe = new Request( "http://localhost/", authValue ? { headers: { authorization: authValue } } : undefined, ); if (!makeIsAuthorized(devToken ?? loadOrMintLocalAuthToken())(probe)) { res.statusCode = 401; res.setHeader("www-authenticate", 'Bearer realm="executor"'); res.end("Unauthorized"); return; } } // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Vite middleware must convert handler failures into HTTP 500 responses try { if (!handlers) { const { getServerHandlers } = await import("./src/main"); handlers = await getServerHandlers(devToken ?? loadOrMintLocalAuthToken()); } const origin = `http://${req.headers.host ?? "localhost"}`; const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value); } const hasBody = req.method !== "GET" && req.method !== "HEAD"; const webRequest = (url: string): Request => new Request(new URL(url, origin), { method: req.method, headers, body: hasBody ? Readable.toWeb(req) : undefined, duplex: hasBody ? "half" : undefined, } as RequestInit); let response: Response; if (isUnauthenticatedOAuthClientMetadataPath(pathOnly) && req.method === "GET") { response = oauthClientMetadataResponse(rawUrl, webRequest(rawUrl)); } else if (isMcp) { response = await handlers.mcp.handleRequest(webRequest(rawUrl)); } else if (isV1) { // Served at root by the Effect app; forward verbatim (no /api strip). response = await handlers.api.handler(webRequest(rawUrl)); } else if (pathOnly === "/api/health" && req.method === "GET") { response = new Response("ok", { headers: { "content-type": "text/plain" }, }); } else if (pathOnly.startsWith("/api/mcp-sessions/")) { const handler = req.method === "GET" ? handlers.mcp.handlePausedRequest : handlers.mcp.handleApprovalRequest; response = await handler(webRequest(rawUrl)); } else { const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(pathOnly); if (awaitMatch && req.method === "GET") { response = new Response(JSON.stringify(consumeOAuthResult(awaitMatch[1]!)), { headers: { "content-type": "application/json" }, }); } else { // Strip /api prefix for Effect handlers. response = await handlers.api.handler(webRequest(rawUrl.slice("/api".length) || "/")); } } res.statusCode = response.status; response.headers.forEach((v, k) => res.setHeader(k, v)); if (response.body) { const reader = response.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; res.write(value); } } res.end(); } catch (err) { console.error("[executor-api]", err); if (!res.headersSent) { res.statusCode = 500; res.end("Internal Server Error"); } } }); }, }; } export default defineConfig({ root: APP_ROOT, publicDir: resolve(APP_ROOT, "public"), build: { outDir: resolve(import.meta.dirname, "dist"), emptyOutDir: true, }, define: { "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION), // The local app IS the npm-installed CLI, so its update card shows the npm // upgrade command (the desktop bridge overrides this with a native action). "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("npm"), "import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL), "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT), "import.meta.env.VITE_EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL": JSON.stringify( process.env.EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL ?? "https://executor.sh", ), "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), }, resolve: { tsconfigPaths: true, }, server: { port: parseInt(process.env.PORT ?? "5173", 10), host: "127.0.0.1", // When the CLI daemon spawns this vite as a child and proxies HTTP // (EXECUTOR_DEV=1), the page is loaded from the daemon's port, but // the daemon does not proxy WebSockets. Point the HMR client at // vite's own port so the browser opens a WS directly to vite, side- // stepping the daemon proxy. Without this, the client tries the // daemon port and floods the console with reconnect errors. hmr: process.env.EXECUTOR_DEV_VITE_PORT ? { host: "127.0.0.1", clientPort: parseInt(process.env.EXECUTOR_DEV_VITE_PORT, 10), protocol: "ws", } : undefined, watch: { // Workspace packages live under packages/ and are symlinked into // node_modules. Without this, chokidar treats them as ordinary // node_modules and skips watching, so edits to e.g. // packages/react/src/pages/integrations.tsx don't trigger HMR. ignored: ["!**/node_modules/@executor-js/**"], // WSL2 + symlinked workspace packages can drop inotify events; // polling is slower but reliable. usePolling: true, interval: 200, }, fs: { allow: [resolve(import.meta.dirname, "../..")], }, }, plugins: [ appPlugin({ executorConfigPath: resolve(import.meta.dirname, "executor.config.ts"), executorJsoncPath: resolve(import.meta.dirname, "executor.jsonc"), }), executorApiPlugin(), ], });