- 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>
90 lines
3.1 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
});
|
|
}
|