/** * Where an instance lives and what it is configured with. * * %LOCALAPPDATA%\Vash\Broadcast\ (VASH_BROADCAST_HOME overrides) * bin\vash-broadcast.exe the CLI, copied here by setup * bin\vash-broadcast-worker.exe the same program without a console window * bin\vash-desktop.ps1 desktop helper (windows, capture, OCR, input) * downloads\ verified archives, reused by every instance * tourney.lock which instance runs osu!tourney + tosu now * instances\\ * config.json server, channel, token, ports, options * obs\ osu\ tosu\ ffmpeg\ this instance's own copies of each program * hls\ logs\ snapshots\ recording scratch, logs, diagnostics * state.json runtime.json what the worker is doing; how to reach it * * Every program an instance runs lives under its own folder, so stopping one * instance can find and stop exactly its processes and nothing else. */ import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; export const VERSION = '0.1.0'; export function rootDir() { if (process.env.VASH_BROADCAST_HOME) return process.env.VASH_BROADCAST_HOME; if (process.platform === 'win32') return join( process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'Vash', 'Broadcast' ); return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'vash-broadcast'); } export const INSTANCE_NAME = /^[a-z][a-z0-9-]{0,19}$/; export function instanceDir(name: string) { if (!INSTANCE_NAME.test(name)) throw new Error( `Instance names are 1–20 lowercase letters, digits or dashes, starting with a letter (got "${name}")` ); return join(rootDir(), 'instances', name); } /** Paths inside an instance folder. */ export function layout(name: string) { const dir = instanceDir(name); const root = rootDir(); return { root, dir, config: join(dir, 'config.json'), state: join(dir, 'state.json'), runtime: join(dir, 'runtime.json'), request: join(dir, 'request.json'), login: join(dir, 'login.json'), doctor: join(dir, 'doctor.json'), logs: join(dir, 'logs'), log: join(dir, 'logs', 'worker.log'), snapshots: join(dir, 'snapshots'), hls: join(dir, 'hls'), obs: join(dir, 'obs'), obsExe: join(dir, 'obs', 'bin', '64bit', 'obs64.exe'), obsConfig: join(dir, 'obs', 'config', 'obs-studio'), osu: join(dir, 'osu'), osuExe: join(dir, 'osu', 'osu!.exe'), tosu: join(dir, 'tosu'), tosuExe: join(dir, 'tosu', 'tosu.exe'), ffmpeg: join(dir, 'ffmpeg', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'), bin: join(root, 'bin'), cliExe: join(root, 'bin', 'vash-broadcast.exe'), workerExe: join(root, 'bin', 'vash-broadcast-worker.exe'), helperScript: join(root, 'bin', 'vash-desktop.ps1'), downloads: join(root, 'downloads'), lock: join(root, 'tourney.lock') }; } export type Layout = ReturnType; export interface Ports { /** The worker's local control API (status, stop, snapshots). */ control: number; /** obs-websocket of this instance's OBS. */ obs: number; /** tosu's HTTP/websocket server. */ tosu: number; /** RTMP ingest OBS streams into; the relay records and restreams it. */ ingest: number; /** The one-time sign-in screen viewer. */ viewer: number; /** Local overlay proxy for sites behind Cloudflare Access (OBS cannot send its headers). */ proxy: number; } export interface InstanceConfig { version: 1; name: string; /** The Vash site, e.g. https://esports.vash.software */ server: string; channel: string; /** The channel's agent token (Broadcast page → Agent token). */ token: string; /** Cloudflare Access service token for sites behind Access (dev, staging). */ access: { clientId: string; clientSecret: string } | null; ports: Ports; obsPassword: string; ingestKey: string; /** Where osu!.exe was copied from for this instance's separate tourney installation. */ osuSource: string | null; /** osu!tourney's streamable height (tournament.cfg Height); the window is ~200 px taller. */ tourneyHeight: number; /** 'amd' asks OBS for AMD AMF hardware encoding; 'x264' chooses software encoding on purpose. */ encoder: 'amd' | 'x264'; /** Start the worker when this Windows user signs in. */ autostart: boolean; } const PORT_BASE = 47100; const PORT_STEP = 10; export function portsForSlot(slot: number): Ports { const base = PORT_BASE + slot * PORT_STEP; return { control: base, obs: base + 1, tosu: base + 2, ingest: base + 3, viewer: base + 4, proxy: base + 5 }; } /** The first port block no other instance uses. */ export function allocatePorts(taken: Ports[]): Ports { const used = new Set(taken.map((ports) => ports.control)); for (let slot = 0; slot < 50; slot++) { const ports = portsForSlot(slot); if (!used.has(ports.control)) return ports; } throw new Error('No free port block for another instance'); } export const secret = (bytes = 18) => randomBytes(bytes).toString('base64url'); export function validateServer(server: string) { let url: URL; try { url = new URL(server); } catch { throw new Error(`--server must be a URL like https://esports.vash.software (got "${server}")`); } if (url.protocol !== 'https:' && !['127.0.0.1', 'localhost'].includes(url.hostname)) throw new Error('--server must use https:// (plain http only for localhost)'); return url.origin; } const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; export function validateConfig(value: unknown): InstanceConfig { const config = value as InstanceConfig; const problems: string[] = []; if (!config || typeof config !== 'object') throw new Error('config.json is not an object'); if (config.version !== 1) problems.push('unknown config version'); if (!INSTANCE_NAME.test(config.name ?? '')) problems.push('bad instance name'); try { validateServer(config.server); } catch (err) { problems.push((err as Error).message); } if (!UUID.test(config.channel ?? '')) problems.push('channel must be the channel id (a UUID)'); if (typeof config.token !== 'string' || !config.token) problems.push('missing agent token'); for (const key of ['control', 'obs', 'tosu', 'ingest', 'viewer', 'proxy'] as const) if (!Number.isInteger(config.ports?.[key])) problems.push(`missing ${key} port`); if (!config.obsPassword || !config.ingestKey) problems.push('missing generated secrets'); if (!(config.tourneyHeight >= 568 && config.tourneyHeight <= 2160)) problems.push('tourneyHeight must be 568–2160'); if (config.encoder !== 'amd' && config.encoder !== 'x264') problems.push('encoder must be amd or x264'); if (problems.length) throw new Error(`Invalid instance config: ${problems.join('; ')}`); return config; } export async function loadConfig(name: string): Promise { const path = layout(name).config; if (!existsSync(path)) throw new Error( `Instance "${name}" is not set up. Run: vash-broadcast setup --instance ${name} …` ); return validateConfig(JSON.parse(await readFile(path, 'utf8'))); } export async function listInstances(): Promise { const dir = join(rootDir(), 'instances'); const names = await readdir(dir).catch(() => [] as string[]); const configs: InstanceConfig[] = []; for (const name of names) { if (!INSTANCE_NAME.test(name)) continue; try { configs.push(await loadConfig(name)); } catch { /* half-set-up instance; ignore */ } } return configs; } /** Writes JSON through a temporary file so readers never see half a file. */ export async function writeJson(path: string, value: unknown) { await mkdir(join(path, '..'), { recursive: true }); const temp = `${path}.${process.pid}.tmp`; await writeFile(temp, JSON.stringify(value, null, '\t')); await rename(temp, path); } export async function readJson(path: string): Promise { try { return JSON.parse(await readFile(path, 'utf8')) as T; } catch { return null; } } export async function saveConfig(config: InstanceConfig) { await writeJson(layout(config.name).config, validateConfig(config)); } /** Headers for a site behind Cloudflare Access. */ export function accessHeaders(config: InstanceConfig): Record { return config.access ? { 'CF-Access-Client-Id': config.access.clientId, 'CF-Access-Client-Secret': config.access.clientSecret } : {}; }