/** * Dev and staging sit behind Cloudflare Access. The worker can send an * Access service token with its own requests, but OBS's browser source * cannot, so it loads the overlay through this local proxy, which adds the * token to every request and websocket. Production needs none of this. */ import type { Server, ServerWebSocket } from 'bun'; interface Tunnel { target: string; upstream: WebSocket | null; queue: (string | Uint8Array)[]; } /** No Referer, so the CDN's hotlink protection accepts images loaded from 127.0.0.1. */ export function withoutReferrer(html: string) { const tag = ''; return /]*>/i.test(html) ? html.replace(/]*>/i, (head) => `${head}${tag}`) : tag + html; } export function startAccessProxy(options: { port: number; upstream: string; headers: Record; log: (...parts: unknown[]) => void; }): Server { const upstream = options.upstream.replace(/\/$/, ''); return Bun.serve({ hostname: '127.0.0.1', port: options.port, async fetch(request, server) { const url = new URL(request.url); const target = `${upstream}${url.pathname}${url.search}`; if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') { const data: Tunnel = { target: target.replace(/^http/, 'ws'), upstream: null, queue: [] }; return server.upgrade(request, { data }) ? undefined : new Response('Upgrade failed', { status: 400 }); } const headers = new Headers(request.headers); headers.delete('host'); headers.delete('accept-encoding'); for (const [key, value] of Object.entries(options.headers)) headers.set(key, value); const response = await fetch(target, { method: request.method, headers, body: ['GET', 'HEAD'].includes(request.method) ? undefined : await request.arrayBuffer(), redirect: 'manual' }).catch((err) => new Response(`Proxy: ${(err as Error).message}`, { status: 502 })); const out = new Headers(response.headers); out.delete('content-encoding'); out.delete('content-length'); if (out.get('content-type')?.includes('text/html')) return new Response(withoutReferrer(await response.text()), { status: response.status, headers: out }); return new Response(response.body, { status: response.status, headers: out }); }, websocket: { open(ws: ServerWebSocket) { const socket = new WebSocket(ws.data.target, { headers: options.headers } as unknown as string[]); socket.binaryType = 'arraybuffer'; ws.data.upstream = socket; socket.onopen = () => { for (const message of ws.data.queue.splice(0)) socket.send(message); }; socket.onmessage = (event) => ws.send( typeof event.data === 'string' ? event.data : new Uint8Array(event.data as ArrayBuffer) ); socket.onclose = () => ws.close(); socket.onerror = () => options.log('Overlay proxy: upstream websocket failed'); }, message(ws, message) { const socket = ws.data.upstream; if (socket?.readyState === WebSocket.OPEN) socket.send(message); else ws.data.queue.push(message); }, close(ws) { ws.data.upstream?.close(); } } }); }