/** * The agent connection to a Vash broadcast channel (/ws/agent): Vash says * which match, lobby and scene to follow; we send live tosu scores and, * from an unattended worker, what the broadcast PC is doing. */ import type { TosuFrame } from './tosu'; export interface Follow { scene: string; matchId: string | null; lobbyId: number | null; roomName: string | null; teamSize: number | null; title: string | null; } /** What an unattended worker reports; shown on the channel's pages. */ export interface WorkerReport { state: string; detail: string | null; onAir: boolean; encoder: string | null; instance: string | null; version: string | null; } export interface LinkOptions { server: string; channel: string; token: string; /** Extra headers, e.g. Cloudflare Access service-token headers. */ headers?: Record; log?: (...parts: unknown[]) => void; onFollow?: (follow: Follow) => void; /** The channel or token was refused; retrying will not help until it changes. */ onRejected?: () => void; } export class VashLink { connected = false; rejected = false; channelName: string | null = null; follow: Follow | null = null; private socket: WebSocket | null = null; private attempts = 0; private stopped = false; private timer: ReturnType | null = null; private lastSent = 0; private lastKey = ''; private pending: TosuFrame | null = null; private flushTimer: ReturnType | null = null; private lastReport = ''; private lastReportAt = 0; /** Server acknowledgement: a connected socket alone does not prove scores reached the match. */ scores = { sentAt: 0, acceptedAt: 0, players: 0 }; private log: (...parts: unknown[]) => void; constructor(private options: LinkOptions) { this.log = options.log ?? (() => {}); } get url() { return `${this.options.server.replace(/\/$/, '').replace(/^http/, 'ws')}/ws/agent`; } start() { this.stopped = false; this.rejected = false; this.connect(); } private connect() { if (this.stopped) return; const headers = this.options.headers ?? {}; // Bun's WebSocket accepts headers; they carry Cloudflare Access service tokens. const socket = Object.keys(headers).length ? new WebSocket(this.url, { headers } as unknown as string[]) : new WebSocket(this.url); this.socket = socket; socket.onopen = () => socket.send( JSON.stringify({ type: 'hello', channelId: this.options.channel, token: this.options.token }) ); socket.onmessage = (event) => { let message: { type?: string; channel?: string; accepted?: number } & Partial; try { message = JSON.parse(String(event.data)); } catch { return; } if (message.type === 'welcome') { this.attempts = 0; this.connected = true; this.channelName = message.channel ?? null; this.lastReport = ''; this.lastKey = ''; this.log(`Connected to ${this.options.server} as channel "${message.channel}"`); } else if (message.type === 'follow') { const follow: Follow = { scene: String(message.scene ?? 'idle'), matchId: message.matchId ?? null, lobbyId: typeof message.lobbyId === 'number' ? message.lobbyId : null, roomName: message.roomName ?? null, teamSize: typeof message.teamSize === 'number' ? message.teamSize : null, title: message.title ?? null }; if (follow.matchId !== this.follow?.matchId || follow.lobbyId !== this.follow?.lobbyId) { this.pending = null; this.lastKey = ''; this.scores = { sentAt: 0, acceptedAt: 0, players: 0 }; } this.follow = follow; this.options.onFollow?.(follow); } else if (message.type === 'scores' && message.matchId === this.follow?.matchId) { this.scores.players = Number(message.accepted) || 0; if (this.scores.players > 0) this.scores.acceptedAt = Date.now(); } }; socket.onclose = (event) => { if (this.socket !== socket) return; this.connected = false; this.socket = null; this.pending = null; this.follow = null; if (this.stopped) return; if (event.code === 4001) { this.rejected = true; this.log('Vash refused the channel or token. Check them on the Broadcast page.'); this.options.onRejected?.(); // A rotated token is fixed by re-running setup; check again now and then anyway. this.timer = setTimeout(() => this.connect(), 5 * 60_000); return; } const wait = Math.min(30_000, 1000 * 2 ** this.attempts++); this.log(`Lost Vash (${event.code}); retrying in ${Math.round(wait / 1000)}s`); this.timer = setTimeout(() => this.connect(), wait); }; } /** At most four updates a second; unchanged fresh frames heartbeat once a second. */ sendScores(frame: TosuFrame) { if (!frame.clients.length || !this.connected || !this.follow?.matchId) return; this.pending = frame; this.flush(); } private flush() { if (!this.pending || !this.connected || this.socket?.readyState !== WebSocket.OPEN) return; const wait = 250 - (Date.now() - this.lastSent); if (wait > 0) { this.flushTimer ??= setTimeout(() => { this.flushTimer = null; this.flush(); }, wait); return; } const frame = this.pending; this.pending = null; const message = JSON.stringify({ type: 'tosu', matchId: this.follow?.matchId, beatmapId: frame.beatmapId && frame.beatmapId > 0 ? frame.beatmapId : null, progress: frame.progress, clients: frame.clients }); if (message === this.lastKey && Date.now() - this.lastSent < 1000) return; this.lastKey = message; this.lastSent = Date.now(); this.scores.sentAt = this.lastSent; this.socket.send(message); } /** Sends the worker's state when it changes, and every 30 seconds as a heartbeat. */ report(status: WorkerReport) { if (!this.connected || this.socket?.readyState !== WebSocket.OPEN) return; const key = JSON.stringify(status); if (key === this.lastReport && Date.now() - this.lastReportAt < 30_000) return; this.lastReport = key; this.lastReportAt = Date.now(); this.socket.send(JSON.stringify({ type: 'status', ...status })); } stop() { this.stopped = true; if (this.timer) clearTimeout(this.timer); if (this.flushTimer) clearTimeout(this.flushTimer); const socket = this.socket; this.socket = null; this.connected = false; socket?.close(); } }