- 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>
240 lines
8.5 KiB
TypeScript
240 lines
8.5 KiB
TypeScript
/**
|
||
* 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\<name>\
|
||
* 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<typeof layout>;
|
||
|
||
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<InstanceConfig> {
|
||
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<InstanceConfig[]> {
|
||
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<T>(path: string): Promise<T | null> {
|
||
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<string, string> {
|
||
return config.access
|
||
? {
|
||
'CF-Access-Client-Id': config.access.clientId,
|
||
'CF-Access-Client-Secret': config.access.clientSecret
|
||
}
|
||
: {};
|
||
}
|