The Vite dev server resolves an extensionless request like /login to a colliding web-root module (web/login.tsx shadows /login, web/setup.tsx shadows /setup) and serves it as text/javascript, so a browser navigating to those routes (e.g. Better Auth's MCP-OAuth loginPage 302) renders raw JS instead of the app. In the dev API middleware (which runs before Vite's transform middleware), rewrite genuine document navigations to the index so the html fallback serves index.html and the SPA renders the route. Gated on Sec-Fetch-Dest: document / Accept: text/html and no file extension, so modules, assets, and /@vite internals pass through untouched. Dev-only: the production static server already serves index.html for unknown routes.
Unified · Split
| @@ -97,7 +97,32 @@ function executorApiPlugin(): Plugin { · 96 unmodified lines | ||
| 97 | 97 | // auth. Served by the Effect router in prod; without this the SPA |
| 98 | 98 | // index.html fallback answers 200-with-HTML and breaks discovery. |
| 99 | 99 | path.startsWith("/.well-known/"); |
| 100 | − if (!handled) return next(); | |
| 100 | + if (!handled) { | |
| 101 | + // SPA document navigations must receive the app shell, not a module. | |
| 102 | + // A browser navigating to a route like `/login` (Better Auth's | |
| 103 | + // MCP-OAuth `loginPage` 302s here as a real document GET) sends an | |
| 104 | + // extensionless request that Vite's transform middleware would resolve | |
| 105 | + // to a colliding web-root module (`web/login.tsx` shadows `/login`, | |
| 106 | + // `web/setup.tsx` shadows `/setup`) and serve as text/javascript, so | |
| 107 | + // the page renders as raw JS and the OAuth login never appears. Rewrite | |
| 108 | + // genuine page navigations to the index so Vite's html fallback serves | |
| 109 | + // index.html; the SPA reads window.location and renders the right | |
| 110 | + // route. Module/asset/HMR requests carry a file extension or a | |
| 111 | + // non-document fetch destination, so they fall through untouched. This | |
| 112 | + // is dev-only: the production static server already serves index.html | |
| 113 | + // for unknown routes, with login/setup bundled into the SPA. | |
| 114 | + const isGet = req.method === "GET" || req.method === "HEAD"; | |
| 115 | + const dest = req.headers["sec-fetch-dest"]; | |
| 116 | + const accept = req.headers.accept; | |
| 117 | + const wantsDocument = | |
| 118 | + dest === "document" || | |
| 119 | + (dest === undefined && typeof accept === "string" && accept.includes("text/html")); | |
| 120 | + const hasExtension = /\.[^/]+$/.test(path); | |
| 121 | + if (isGet && wantsDocument && !hasExtension && !path.startsWith("/@")) { | |
| 122 | + req.url = "/"; | |
| 123 | + } | |
| 124 | + return next(); | |
| 125 | + } | |
| 101 | 126 | |
| 102 | 127 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Vite dev middleware must convert handler failures into HTTP 500 responses |
| 103 | 128 | try { |