Files
abdulive/packages/obs/src/client.ts
T
ZeasyAlexandClaude Opus 5.5 8be195d938
Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
Pult: Handy als Stream Deck mit Soundboard und Szenenwechsel
- /pult als installierbare Web-App (Manifest, Service Worker, Symbole)
- Soundboard: Sounds vom Handy hochladen, bearbeiten, im Stream spielen
  ueber die neue Browser-Source /sounds
- Szenen, Clip und Stummschalter: der Stream-PC waehlt sich mit eigenem
  Schluessel beim Hub ein (fern:verbinden), Befehle warten auf OBS
- Browser-Sources melden, welche Seite sie sind

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:51:48 +01:00

171 lines
6.1 KiB
TypeScript

// obs-websocket v5. Eigene Umsetzung statt npm-Paket - das Protokoll ist klein
// und das Projekt kommt ohne Abhaengigkeiten aus (ADR-0001, Nachtrag 2).
//
// Ablauf: Server schickt Hello (op 0), Client antwortet mit Identify (op 1),
// Server bestaetigt mit Identified (op 2). Danach Requests (op 6) / Responses
// (op 7) und Events (op 5).
import crypto from 'node:crypto';
import { logger } from '#core/logger.ts';
import { forever } from '#core/retry.ts';
const log = logger('obs');
/**
* Ereignisgruppen als Bitmaske. Wir brauchen General, Scenes und Outputs,
* dazu Inputs fuer die Stummschalter auf dem Pult.
*/
export const SUBSCRIPTIONS = { general: 1, config: 2, scenes: 4, inputs: 8, outputs: 64 } as const;
const DEFAULT_SUBS = SUBSCRIPTIONS.general | SUBSCRIPTIONS.scenes |
SUBSCRIPTIONS.inputs | SUBSCRIPTIONS.outputs;
/**
* Antwort auf die Authentifizierungsaufforderung.
* base64(sha256( base64(sha256(passwort + salt)) + challenge ))
*/
export function authString(password: string, salt: string, challenge: string): string {
const secret = crypto.createHash('sha256').update(password + salt).digest('base64');
return crypto.createHash('sha256').update(secret + challenge).digest('base64');
}
type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: NodeJS.Timeout };
export type ObsEventHandlers = {
onReplaySaved?: (path: string) => void;
onSceneChanged?: (scene: string) => void;
onIdentified?: () => void;
onClosed?: () => void;
/** Jedes Ereignis, auch die oben schon einzeln gemeldeten. */
onEvent?: (type: string, data: Record<string, unknown>) => void;
};
export type ObsClient = {
run: (signal: AbortSignal) => Promise<void>;
request: <T = unknown>(type: string, data?: unknown) => Promise<T>;
isConnected: () => boolean;
};
export function createObs(
url: string,
password: () => string | undefined,
handlers: ObsEventHandlers
): ObsClient {
let ws: WebSocket | null = null;
let identified = false;
let seq = 0;
const pending = new Map<string, Pending>();
function send(op: number, d: unknown): void {
if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error('OBS ist nicht verbunden');
ws.send(JSON.stringify({ op, d }));
}
function request<T>(type: string, data?: unknown): Promise<T> {
if (!identified) return Promise.reject(new Error('OBS ist nicht verbunden'));
const requestId = `r${++seq}`;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(requestId);
reject(new Error(`OBS antwortet nicht auf ${type}`));
}, 10_000);
pending.set(requestId, { resolve: resolve as (v: unknown) => void, reject, timer });
try {
send(6, { requestType: type, requestId, ...(data ? { requestData: data } : {}) });
} catch (err) {
clearTimeout(timer);
pending.delete(requestId);
reject(err as Error);
}
});
}
function handleMessage(raw: string, fail: (why: string) => void): void {
let msg: { op: number; d: Record<string, unknown> };
try { msg = JSON.parse(raw); } catch { return; }
switch (msg.op) {
case 0: { // Hello
const auth = msg.d['authentication'] as { challenge: string; salt: string } | undefined;
const identify: Record<string, unknown> = {
rpcVersion: 1, eventSubscriptions: DEFAULT_SUBS,
};
if (auth) {
const pw = password();
if (!pw) return fail('OBS verlangt ein Passwort, es ist keines hinterlegt (/setup)');
identify['authentication'] = authString(pw, auth.salt, auth.challenge);
}
send(1, identify);
break;
}
case 2: { // Identified
identified = true;
log.info(`Mit OBS verbunden (RPC ${String(msg.d['negotiatedRpcVersion'])})`);
handlers.onIdentified?.();
break;
}
case 5: { // Event
const type = msg.d['eventType'] as string;
const data = (msg.d['eventData'] ?? {}) as Record<string, unknown>;
if (type === 'ReplayBufferSaved') {
handlers.onReplaySaved?.(String(data['savedReplayPath'] ?? ''));
} else if (type === 'CurrentProgramSceneChanged') {
handlers.onSceneChanged?.(String(data['sceneName'] ?? ''));
}
handlers.onEvent?.(type, data);
break;
}
case 7: { // RequestResponse
const id = msg.d['requestId'] as string;
const entry = pending.get(id);
if (!entry) break;
pending.delete(id);
clearTimeout(entry.timer);
const status = msg.d['requestStatus'] as { result: boolean; code: number; comment?: string };
if (status?.result) entry.resolve(msg.d['responseData'] ?? {});
else entry.reject(new Error(
`OBS lehnt ab (${status?.code}): ${status?.comment ?? 'ohne Begruendung'}`));
break;
}
default: break;
}
}
function connectOnce(signal: AbortSignal): Promise<void> {
return new Promise<void>((_resolve, reject) => {
const socket = new WebSocket(url);
ws = socket;
identified = false;
const fail = (why: string) => {
identified = false;
for (const [, p] of pending) { clearTimeout(p.timer); p.reject(new Error(why)); }
pending.clear();
try { socket.close(); } catch { /* schon zu */ }
handlers.onClosed?.();
reject(new Error(why));
};
signal.addEventListener('abort', () => fail('abgebrochen'), { once: true });
socket.addEventListener('message', (ev) => handleMessage(String(ev.data), fail));
socket.addEventListener('error', () => fail('WebSocket-Fehler'));
socket.addEventListener('close', (ev) => fail(`Verbindung geschlossen (${ev.code})`));
});
}
return {
run: (signal) => forever(connectOnce, {
signal,
baseMs: 2000,
maxMs: 60_000,
onWait: (ms, n) => {
// Erst ab dem dritten Versuch laut werden: OBS laeuft oft einfach
// noch nicht, wenn das Toolkit startet.
const line = `OBS nicht erreichbar, neuer Versuch in ${Math.round(ms / 1000)} s`;
if (n >= 3) log.warn(line); else log.debug(line);
},
}),
request,
isConnected: () => identified,
};
}