// Minimal reverse proxy mirroring the production Caddyfile's routing rules, used only for // Playwright e2e runs — no `caddy` binary is available in this dev environment (see // global-setup.ts). Routing matches Caddyfile exactly: /ws* and /api/* -> api, everything else // -> frontend. This matters because the frontend's browser JS (map.js) makes same-origin // relative calls (`fetch("/api/...")`, `new WebSocket(".../ws/chat/...")`) that only resolve // correctly behind a single proxied origin like this one — see the README's e2e section for why // that's the whole point of testing through this proxy instead of hitting api/frontend directly. const PORT = Number(process.env.E2E_PROXY_PORT); const API_ORIGIN = process.env.E2E_API_ORIGIN!; // e.g. http://localhost:13010 const FRONTEND_ORIGIN = process.env.E2E_FRONTEND_ORIGIN!; // e.g. http://localhost:13011 function targetOrigin(pathname: string): string { if (pathname.startsWith("/ws") || pathname.startsWith("/api/")) return API_ORIGIN; return FRONTEND_ORIGIN; } type WsData = { targetUrl: string; upstream?: WebSocket; queue: (string | ArrayBuffer)[]; }; Bun.serve({ port: PORT, async fetch(req, server) { const url = new URL(req.url); // Synthetic, non-forwarded endpoint so global-setup can wait for *this* process specifically // (the one tests actually talk to) instead of inferring proxy readiness from api/frontend's // own health checks, which would leave a chicken-and-egg gap. if (url.pathname === "/__proxy_health") { return new Response("ok"); } const origin = targetOrigin(url.pathname); if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { const targetUrl = origin.replace(/^http/, "ws") + url.pathname + url.search; const upgraded = server.upgrade(req, { data: { targetUrl, queue: [] } }); return upgraded ? undefined : new Response("upgrade failed", { status: 400 }); } const headers = new Headers(req.headers); headers.delete("host"); headers.delete("content-length"); const res = await fetch(origin + url.pathname + url.search, { method: req.method, headers, body: ["GET", "HEAD"].includes(req.method) ? undefined : await req.arrayBuffer(), }); const resHeaders = new Headers(res.headers); resHeaders.delete("content-encoding"); // fetch() already decoded the body return new Response(res.body, { status: res.status, headers: resHeaders }); }, websocket: { open(ws) { const upstream = new WebSocket(ws.data.targetUrl); ws.data.upstream = upstream; upstream.onopen = () => { for (const msg of ws.data.queue) upstream.send(msg as any); ws.data.queue.length = 0; }; upstream.onmessage = (ev) => ws.send(ev.data); upstream.onclose = () => ws.close(); }, message(ws, message) { const upstream = ws.data.upstream; if (upstream && upstream.readyState === WebSocket.OPEN) upstream.send(message as any); else ws.data.queue.push(message); }, close(ws) { ws.data.upstream?.close(); }, }, }); console.log(`[e2e-proxy] listening on :${PORT} -> api=${API_ORIGIN} frontend=${FRONTEND_ORIGIN}`);