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>
This commit is contained in:
89
streamer/windows/proxy.ts
Normal file
89
streamer/windows/proxy.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user