/** * Serve THIS worktree's frontend against the RUNNING platform's backend. * * The visual comparison needs the new bundle rendering real data. The obvious way — start a second full * platform on another port — is the one thing not to do tonight: it shares the database, the install root * and the sidecar registry with the live server the owner is currently talking to me through, and a second * instance racing it is exactly the SO_REUSEPORT class of problem that has already cost us an evening. * * So this serves static assets from the worktree's build and forwards everything else to the live server. * The frontend cannot tell the difference: same origin, same cookies, same API. Only the stylesheet and the * theme provider are new, which is precisely the variable under test. * * Read-only in practice — the snapshot script only navigates and screenshots — but it is a proxy, not a * sandbox: a POST would reach the real backend. Do not point anything destructive at it. * * bun scripts/theme-preview-server.ts --port 9010 --upstream http://127.0.0.1:9000 */ const arg = (name: string, fallback: string): string => { const i = process.argv.indexOf(`--${name}`); return i === -1 ? fallback : (process.argv[i + 1] ?? fallback); }; const port = Number(arg('port', '9010')); const upstream = arg('upstream', 'http://127.0.0.1:9000'); const dist = arg('dist', 'dist'); // The build emits `index.gen.html` (the generated entry), not `index.html`. Both are accepted so this // keeps working if that ever changes. const indexHtml = (await Bun.file(`${dist}/index.gen.html`) .text() .catch(() => null)) ?? (await Bun.file(`${dist}/index.html`) .text() .catch(() => null)); if (!indexHtml) { console.error(` no build found in ${dist}/ — run the frontend build first`); process.exit(1); } const server = Bun.serve({ port, idleTimeout: 60, async fetch(req) { const url = new URL(req.url); // Anything the backend owns goes upstream, headers and body intact. `/api` covers REST; the rest are // the paths the SPA fetches that are not assets. if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/uploads/')) { const target = `${upstream}${url.pathname}${url.search}`; try { return await fetch(target, { method: req.method, headers: req.headers, body: req.method === 'GET' || req.method === 'HEAD' ? undefined : await req.arrayBuffer(), redirect: 'manual', }); } catch (err) { return new Response(`upstream unreachable: ${String(err)}`, { status: 502 }); } } // A built asset, if we have one. const asset = Bun.file(`${dist}${url.pathname}`); if (url.pathname !== '/' && (await asset.exists())) { return new Response(asset); } /** * A file we do NOT have goes upstream rather than falling through to the shell. * * `public/` — the logo, wallpapers, icons — is served by the platform from outside the build, so the * first version of this returned index.html for those and the page rendered with a broken logo and no * wallpaper. Both showed up in the comparison as if the extraction had removed them, which is exactly * the kind of false positive that gets a real regression waved through next to it. * * "Looks like a file" is the extension test: a client-side route never has one. */ if (/\.[a-z0-9]{2,5}$/i.test(url.pathname)) { try { const res = await fetch(`${upstream}${url.pathname}${url.search}`, { headers: req.headers }); if (res.ok) return res; } catch { // Fall through to the shell — a missing asset should not take the page down. } } // Everything else is a client-side route: hand back the shell and let the router decide. return new Response(indexHtml, { headers: { 'content-type': 'text/html; charset=utf-8' } }); }, }); console.log(` preview on http://127.0.0.1:${server.port} (assets: ${dist}, api -> ${upstream})`);