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
97 // auth. Served by the Effect router in prod; without this the SPA97 // auth. Served by the Effect router in prod; without this the SPA
98 // index.html fallback answers 200-with-HTML and breaks discovery.98 // index.html fallback answers 200-with-HTML and breaks discovery.
99 path.startsWith("/.well-known/");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 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Vite dev middleware must convert handler failures into HTTP 500 responses127 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Vite dev middleware must convert handler failures into HTTP 500 responses
103 try {128 try {