Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
- /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>
171 lines
6.1 KiB
TypeScript
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,
|
|
};
|
|
}
|