254 lines · 12.2 KB · TypeScript
RawDownload
import { readFileSync } from "node:fs";import { Readable } from "node:stream";import { fileURLToPath } from "node:url"; import { defineConfig, type Plugin } from "vite";import react from "@vitejs/plugin-react";import tailwindcss from "@tailwindcss/vite";import { tanstackRouter } from "@tanstack/router-plugin/vite";import executorVitePlugin from "@executor-js/vite-plugin";import { innerRendererPlugin, mcpAppsShellAsset } from "@executor-js/mcp-apps-shell/vite"; import { routes } from "./tsr.routes";import { MCP_ORIGINAL_PATH_HEADER, stripMcpOrgSegment } from "./src/mcp/org-path"; // The real release version (matches the published `executor` dist-tags the// update card compares against), read from the CLI package the same way// apps/local does. A placeholder here made the card read as permanently// "behind", see the update-check comparison in @executor-js/react.// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads the version from package.json at build timeconst cliPackage = JSON.parse(  readFileSync(new URL("../cli/package.json", import.meta.url), "utf8"),) as { version?: string };const EXECUTOR_VERSION = cliPackage.version; // Self-host web SPA. Mirrors @executor-js/app's vite plugin bundle, but points// the TanStack router codegen at THIS app's routes (web/routes) so we get the// multiplayer shell + Better-Auth gate (routes/__root.tsx) instead of the// personal-mode local shell. executorVitePlugin feeds plugin client bundles// from our executor.config.ts into `virtual:executor/plugins-client`.const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url));const DEV_PORT = 5173; // Dev defaults so `bun run dev` boots the full stack with zero manual env.// Set at module load (before any plugin/executor.config reads them). Override// via real env for anything you care about (esp. BETTER_AUTH_SECRET in prod).process.env.EXECUTOR_DATA_DIR ??= fileURLToPath(new URL("./.executor-dev/", import.meta.url));process.env.BETTER_AUTH_SECRET ??= "executor-selfhost-dev-secret-change-me-0123456789";if (process.env.EXECUTOR_DEV_SEED_ADMIN === "1") {  process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL ??= "admin@example.com";  process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD ??= "executor-dev-admin";}process.env.EXECUTOR_WEB_BASE_URL ??= `http://localhost:${DEV_PORT}`; // Dev-only: forward /api, /mcp, /docs to the self-host Effect handler in-process// (the same web handler serve.ts binds). Requires vite to run under Bun// (`bunx --bun vite dev`) because the handler opens a bun:sqlite DB. No path// stripping — the self-host API is served under /api by the prefixed router, so// the handler expects the full path. Handler rebuilds when src/ changes.function executorApiPlugin(): Plugin {  let handlerPromise: Promise<{ handler: (request: Request) => Promise<Response> }> | null = null;  const getHandler = async () => {    if (!handlerPromise) {      // Computed specifier so Vite's Node-based config loader does NOT statically      // follow this into ./src/api/api (which imports @executor-js/host-mcp, whose      // extensionless re-exports resolve under Bun but not Node ESM). It only runs      // at dev-server request time, under `bunx --bun vite dev`.      const apiModule = new URL("./src/api/api.ts", import.meta.url).href;      handlerPromise = import(apiModule).then((m) => m.makeSelfHostApiHandler());    }    return handlerPromise;  };   return {    name: "executor-selfhost-api",    apply: "serve",    configureServer(server) {      server.watcher.on("change", (path) => {        if (path.includes("/src/") || path.endsWith("/executor.config.ts")) handlerPromise = null;      });      server.middlewares.use(async (req, res, next) => {        let rawUrl = req.url ?? "/";        // The "Connect an agent" card prints `/<organizationId>/mcp`; self-host        // serves the bare `/mcp`, so rewrite it here (prod does the same in        // serve.ts) — otherwise this org-pinned path isn't recognized as an MCP        // path and falls through to the SPA as a 404. Mirrors ./src/mcp/org-path.        const devOrigin = `http://${req.headers.host ?? `localhost:${DEV_PORT}`}`;        const originalPathname = new URL(rawUrl, devOrigin).pathname;        const pathname = stripMcpOrgSegment(originalPathname) ?? "";        // Carries the ORIGINAL org-scoped pathname through to the handler (see        // ./src/mcp/auth.ts) so the protected-resource metadata can echo it        // back to a client that dialed org-scoped — mirrors serve.ts's prod        // middleware. Set only when we ourselves rewrote this request; any        // client-supplied value is dropped below so it can't be spoofed.        let originalPathHeader: string | null = null;        if (pathname !== "") {          const original = new URL(rawUrl, devOrigin);          rawUrl = `${pathname}${original.search}`;          originalPathHeader = originalPathname;        }        // Match on PATHNAME, not a raw-URL prefix: `/mcp` must NOT swallow the        // SPA route `/mcp-consent`, or the dev server misroutes it to the API        // handler and returns a 404.        const path = new URL(rawUrl, devOrigin).pathname;        const handled =          path === "/api" ||          path.startsWith("/api/") ||          path === "/mcp" ||          path.startsWith("/mcp/") ||          path === "/docs" ||          path.startsWith("/docs/") ||          // Un-prefixed app-level routes (e.g. `/v1/app/npm/dist-tags`, which the          // shell's update check fetches). Served by the Effect router in prod;          // without this the SPA index.html fallback answers 200-with-HTML and          // the JSON parse fails, so the UpdateCard never appears.          path === "/v1" ||          path.startsWith("/v1/") ||          // RFC 9728 / RFC 8414 OAuth discovery the MCP client fetches before          // auth. Served by the Effect router in prod; without this the SPA          // index.html fallback answers 200-with-HTML and breaks discovery.          path.startsWith("/.well-known/");        if (!handled) {          // SPA document navigations must receive the app shell, not a module.          // A browser navigating to a route like `/login` (Better Auth's          // MCP-OAuth `loginPage` 302s here as a real document GET) sends an          // extensionless request that Vite's transform middleware would resolve          // to a colliding web-root module (`web/login.tsx` shadows `/login`,          // `web/setup.tsx` shadows `/setup`) and serve as text/javascript, so          // the page renders as raw JS and the OAuth login never appears. Rewrite          // genuine page navigations to the index so Vite's html fallback serves          // index.html; the SPA reads window.location and renders the right          // route. Module/asset/HMR requests carry a file extension or a          // non-document fetch destination, so they fall through untouched. This          // is dev-only: the production static server already serves index.html          // for unknown routes, with login/setup bundled into the SPA.          const isGet = req.method === "GET" || req.method === "HEAD";          const dest = req.headers["sec-fetch-dest"];          const accept = req.headers.accept;          const wantsDocument =            dest === "document" ||            (dest === undefined && typeof accept === "string" && accept.includes("text/html"));          const hasExtension = /\.[^/]+$/.test(path);          if (isGet && wantsDocument && !hasExtension && !path.startsWith("/@")) {            req.url = "/";          }          return next();        }         // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Vite dev middleware must convert handler failures into HTTP 500 responses        try {          const { handler } = await getHandler();          const origin = `http://${req.headers.host ?? `localhost:${DEV_PORT}`}`;          const headers = new Headers();          for (const [key, value] of Object.entries(req.headers)) {            if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value);          }          if (originalPathHeader) {            headers.set(MCP_ORIGINAL_PATH_HEADER, originalPathHeader);          } else {            headers.delete(MCP_ORIGINAL_PATH_HEADER);          }          const hasBody = req.method !== "GET" && req.method !== "HEAD";          const webRequest = new Request(new URL(rawUrl, origin), {            method: req.method,            headers,            body: hasBody ? Readable.toWeb(req) : undefined,            duplex: hasBody ? "half" : undefined,          } as RequestInit);           const response = await handler(webRequest);          res.statusCode = response.status;          response.headers.forEach((value, key) => res.setHeader(key, value));          if (response.body) {            const reader = response.body.getReader();            for (;;) {              const { done, value } = await reader.read();              if (done) break;              res.write(value);            }          }          res.end();        } catch (err) {          console.error("[executor-selfhost-api]", err);          if (!res.headersSent) {            res.statusCode = 500;            res.end("Internal Server Error");          }        }      });    },  };} export default defineConfig({  root: fileURLToPath(new URL("./web/", import.meta.url)),  publicDir: fileURLToPath(new URL("../../packages/app/public/", import.meta.url)),  build: {    outDir: fileURLToPath(new URL("./dist/", import.meta.url)),    emptyOutDir: true,  },  resolve: {    alias: { "@executor-app": APP_ROOT },    dedupe: ["react", "react-dom"],  },  // This SPA shares packages/app's shell and packages/react's lazy-loaded  // "add source" UI with apps/cloud, so the same deps only surface once a  // lazy chunk actually renders (e.g. opening the MCP/OpenAPI add-source  // flow). Pre-bundle them at boot so vite never discovers them mid-run and  // never has to re-optimize + reload. No SSR/worker environment here (this  // app has no cloudflare plugin, it's a plain SPA build), so only the  // client optimizeDeps applies. Keep in sync with apps/cloud/vite.config.ts.  // js-yaml is a transitive dep (via @executor-js/plugin-openapi) that isn't  // hoisted into this app's node_modules under bun's isolated install, so a  // plain "js-yaml" specifier fails to resolve; "<pkg> > <dep>" resolves it  // from that package's own node_modules instead.  optimizeDeps: {    include: [      "effect/Match",      "effect/Predicate",      "effect/Exit",      "effect/Option",      "effect/Cause",      "effect/Data",      "effect/Schema",      "@executor-js/plugin-openapi > js-yaml",    ],  },  define: {    "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION ?? "0.0.0"),    // Self-host upgrades by pulling/rebuilding the image (or git + rebuild), not    // npm, so the update card links to the upgrade guide instead of a command.    "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("selfhost"),    "import.meta.env.VITE_GITHUB_URL": JSON.stringify(      "https://github.com/UsefulSoftwareCo/executor",    ),    "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),  },  server: {    port: DEV_PORT,    fs: { allow: [fileURLToPath(new URL("../../", import.meta.url))] },  },  plugins: [    executorApiPlugin(),    tailwindcss(),    // The artifact page hosts the MCP-Apps shell as a sandboxed iframe over the    // MCP-Apps protocol: `mcpAppsShellAsset` emits the built shell document and    // hands the page its URL, and `innerRendererPlugin` inlines the shell's own    // sandboxed inner frame from `virtual:executor-inner-renderer`.    mcpAppsShellAsset() as Plugin,    innerRendererPlugin(),    executorVitePlugin({      configPath: fileURLToPath(new URL("./executor.config.ts", import.meta.url)),    }),    tanstackRouter({      target: "react",      autoCodeSplitting: true,      routesDirectory: fileURLToPath(new URL("./web/routes", import.meta.url)),      generatedRouteTree: fileURLToPath(new URL("./web/routeTree.gen.ts", import.meta.url)),      // The route tree definition lives in tsr.routes.ts (shared with      // packages/react's routes:gen so a CLI regen matches dev/build).      virtualRouteConfig: routes,    }),    ...react(),  ],});