From bd5b8de3c54baeac99af2c34b8678c8bd88c38d0 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:42:00 -0700 Subject: [PATCH] Self-host dev: serve the SPA shell for /login and /setup 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. --- apps/host-selfhost/vite.config.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/host-selfhost/vite.config.ts b/apps/host-selfhost/vite.config.ts index 636f92817..b5d75ec75 100644 --- a/apps/host-selfhost/vite.config.ts +++ b/apps/host-selfhost/vite.config.ts @@ -97,7 +97,32 @@ function executorApiPlugin(): Plugin { // 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) return next(); + 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 {