Files
esports/streamer/windows/proxy.ts
Stan Runge 54444a77f0 feat: add native Windows broadcaster and live score modules
- streamer/windows: vash-broadcast.exe (setup/login/start/stop/status/doctor)
  with per-instance OBS, tosu and FFmpeg, a supervised worker, OCR room
  selection and OBS scene reconciliation; built and tested on Linux only
- streamer/relay.ts shared with Vash TV; Cloudflare Access proxy for dev/staging
- worker status validation and live score helpers for the site
- STREAMING_HANDOFF.md and streamer/ALPHA_TEST.md record what is unverified

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:02:25 +00:00

90 lines
3.1 KiB
TypeScript

/**
* 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 = '<meta name="referrer" content="no-referrer">';
return /<head[^>]*>/i.test(html)
? html.replace(/<head[^>]*>/i, (head) => `${head}${tag}`)
: tag + html;
}
export function startAccessProxy(options: {
port: number;
upstream: string;
headers: Record<string, string>;
log: (...parts: unknown[]) => void;
}): Server<Tunnel> {
const upstream = options.upstream.replace(/\/$/, '');
return Bun.serve<Tunnel>({
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<Tunnel>) {
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();
}
}
});
}