305 lines · 12.4 KB · TypeScript
RawDownload
import { readFileSync } from "node:fs";import { createRequire } from "node:module";import { dirname, 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.jsonconst 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.jsonconst 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)); // js-yaml reaches the browser through @executor-js/plugin-openapi's UI, so it// belongs in the pre-bundle below. Vite resolves `optimizeDeps.include` from// `root`, which here is packages/app, and bun's isolated install keeps js-yaml// in the openapi plugin's own node_modules — out of reach of both a plain// "js-yaml" specifier and the "<pkg> > <dep>" form from that directory. Point// the bare specifier at the package the plugin already resolves, so `root` can// name it. It is the same copy either way, so nothing about resolution changes.const JS_YAML_DIR = dirname(  createRequire(fileURLToPath(new URL("../../packages/plugins/openapi/", import.meta.url))).resolve(    "js-yaml",  ),); 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,  },  // Deps vite only discovers once a lazy-loaded React chunk actually renders  // (e.g. opening the integration Edit sheet, or the MCP/OpenAPI add-source  // flow). Discovering one mid-run forces a re-optimize and a full page  // reload, which throws away whatever the person was doing — an open sheet  // and its form state included. Pre-bundle them at boot so vite never  // discovers them mid-run. Keep in sync with apps/host-selfhost and  // apps/cloud.  optimizeDeps: {    include: [      "effect/Match",      "effect/Predicate",      "effect/Exit",      "effect/Option",      "effect/Cause",      "effect/Data",      "effect/Schema",      "js-yaml",    ],  },  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,    alias: { "js-yaml": JS_YAML_DIR },  },  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(),  ],});