History

Self-host dev: serve the SPA shell for /login and /setup (#1232)

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.

Rhys Sullivan committed 4 months ago

Commit 08166281 parent 1c1051c1 file · +26 −1Browse files

Unified · Split

apps/host-selfhost/vite.config.ts+26−1
@@ -97,7 +97,32 @@ function executorApiPlugin(): Plugin { · 96 unmodified lines
9797 // auth. Served by the Effect router in prod; without this the SPA
9898 // index.html fallback answers 200-with-HTML and breaks discovery.
9999 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+ }
101126
102127 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Vite dev middleware must convert handler failures into HTTP 500 responses
103128 try {