/** * ObsClient — pure Bun WebSocket v5 client for obs-websocket (no external deps). * * Production complete: full hello/identify/auth dance, request/response correlation * with timeouts, event subscription, typed high-level helpers for the actions we need. * * Reconnects automatically on close. All public methods throw on failure with clear messages. */ import type { ObsScene } from "../shared/types"; interface ObsHelloAuth { challenge: string; salt: string; } interface ObsHelloData { obsWebSocketVersion: string; rpcVersion: number; authentication?: ObsHelloAuth; } interface ObsRequest { requestType: string; requestData?: Record; } interface ObsRequestStatus { result: boolean; code: number; comment?: string; } interface ObsResponse { requestType: string; requestStatus: ObsRequestStatus; responseData?: Record; } interface ObsEvent { eventType: string; eventIntent: number; eventData?: Record; } type PendingRequest = { resolve: (value: Record) => void; reject: (reason: Error) => void; timer: Timer; }; export class ObsError extends Error { constructor( message: string, public readonly code?: number, public readonly requestType?: string, ) { super(message); this.name = "ObsError"; } } export class ObsClient { private ws: WebSocket | null = null; private readonly rpcVersion = 1; private readonly eventListeners = new Map) => void>>(); private readonly requestMap = new Map(); private connected = false; private authenticated = false; private reconnectTimer: Timer | null = null; private connecting = false; constructor( private readonly url: string, private readonly password: string, ) {} get isConnected(): boolean { return this.connected && this.authenticated; } async connect(): Promise { if (this.connecting) { throw new ObsError("Connection already in progress"); } if (this.isConnected) return; this.connecting = true; return new Promise((resolve, reject) => { this.ws = new WebSocket(this.url); this.ws.onopen = () => { console.log("[obs] WS connected, awaiting Hello"); }; this.ws.onmessage = (ev) => { try { const msg = JSON.parse(String(ev.data)); this.handleMessage(msg, resolve, reject); } catch (e) { console.error("[obs] failed to parse message", e); } }; this.ws.onclose = () => { console.log("[obs] WS closed"); this.connected = false; this.authenticated = false; this.ws = null; this.clearPendingRequests(new ObsError("OBS WebSocket closed")); this.scheduleReconnect(); }; this.ws.onerror = (ev) => { const err = new ObsError("OBS WebSocket error"); console.error("[obs] WS error", ev); if (this.connecting) { this.connecting = false; reject(err); } }; // overall connect timeout setTimeout(() => { if (this.connecting) { this.connecting = false; const t = new ObsError("OBS WS connect timeout"); reject(t); } }, 15000); }); } private scheduleReconnect(): void { if (this.reconnectTimer) return; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; if (!this.isConnected) { console.log("[obs] attempting reconnect..."); this.connect().catch((e) => { console.warn("[obs] reconnect failed:", e instanceof Error ? e.message : e); }); } }, 3000); } private clearPendingRequests(reason: Error): void { for (const [id, entry] of this.requestMap) { clearTimeout(entry.timer as any); entry.reject(reason); this.requestMap.delete(id); } } private handleMessage( msg: any, connectResolve?: (v: void) => void, connectReject?: (e: Error) => void, ): void { const { op, d } = msg; if (op === 0) { // Hello console.log("[obs] Hello received, rpcVersion", d?.rpcVersion); const identify: any = { op: 1, d: { rpcVersion: this.rpcVersion, }, }; if (d?.authentication) { const { challenge, salt } = d.authentication as ObsHelloAuth; const secret = Bun.SHA256(this.password + salt).toString("base64"); const auth = Bun.SHA256(secret + challenge).toString("base64"); identify.d.authentication = auth; } this.ws?.send(JSON.stringify(identify)); return; } if (op === 2) { // Identified this.connected = true; this.authenticated = true; this.connecting = false; console.log("[obs] Identified successfully"); if (connectResolve) connectResolve(); return; } if (op === 7) { // RequestResponse const rid: string = d?.requestId; const entry = this.requestMap.get(rid); if (entry) { this.requestMap.delete(rid); clearTimeout(entry.timer as any); const status: ObsRequestStatus = d?.requestStatus; if (status?.result) { entry.resolve((d?.responseData as Record) ?? {}); } else { entry.reject( new ObsError( status?.comment || `OBS error code ${status?.code}`, status?.code, ), ); } } return; } if (op === 5) { // Event const ev: ObsEvent = d; const listeners = this.eventListeners.get(ev.eventType) ?? []; for (const l of listeners) { try { l(ev.eventData ?? {}); } catch (e) { console.error("[obs] event handler error", e); } } const all = this.eventListeners.get("*") ?? []; for (const l of all) { try { l({ type: ev.eventType, data: ev.eventData ?? {} }); } catch (e) { console.error("[obs] wildcard event handler error", e); } } } } async sendRequest(req: ObsRequest): Promise> { if (!this.ws || !this.authenticated) { throw new ObsError("OBS not connected or not authenticated"); } const requestId = crypto.randomUUID(); const payload = { op: 6, d: { requestType: req.requestType, requestId, requestData: req.requestData ?? {}, }, }; return new Promise((resolve, reject) => { const timer = setTimeout(() => { if (this.requestMap.has(requestId)) { this.requestMap.delete(requestId); reject(new ObsError(`OBS request ${req.requestType} timed out`, undefined, req.requestType)); } }, 15000); this.requestMap.set(requestId, { resolve, reject, timer }); try { this.ws!.send(JSON.stringify(payload)); } catch (e) { this.requestMap.delete(requestId); clearTimeout(timer as any); reject(new ObsError(`Failed to send OBS request: ${(e as Error).message}`)); } }); } onEvent(eventType: string, handler: (data: Record) => void): void { if (!this.eventListeners.has(eventType)) { this.eventListeners.set(eventType, []); } this.eventListeners.get(eventType)!.push(handler); } removeEventListener(eventType: string, handler: (data: Record) => void): void { const list = this.eventListeners.get(eventType); if (!list) return; const idx = list.indexOf(handler); if (idx >= 0) list.splice(idx, 1); } // ---------------- high level typed helpers ---------------- async getScenes(): Promise { const res = await this.sendRequest({ requestType: "GetSceneList" }); const scenes = (res.scenes as any[] | undefined) ?? []; return scenes.map((s: any, idx: number) => ({ sceneName: String(s.sceneName ?? s.name ?? `Scene${idx}`), sceneIndex: typeof s.sceneIndex === "number" ? s.sceneIndex : idx, })); } async getCurrentProgramScene(): Promise { try { const res = await this.sendRequest({ requestType: "GetCurrentProgramScene" }); return (res.currentProgramSceneName as string) ?? null; } catch { return null; } } async setCurrentScene(sceneName: string): Promise { await this.sendRequest({ requestType: "SetCurrentProgramScene", requestData: { sceneName }, }); } async getStreamStatus(): Promise> { return this.sendRequest({ requestType: "GetStreamStatus" }); } async startStream(): Promise { await this.sendRequest({ requestType: "StartStream" }); } async stopStream(): Promise { await this.sendRequest({ requestType: "StopStream" }); } async getInputList(): Promise> { return this.sendRequest({ requestType: "GetInputList" }); } /** * Set or create a text source. Uses "text_ft2_source_v2" by default (cross platform). * Falls back gracefully if the named input already exists but is wrong kind. */ async setTextSource(inputName: string, text: string, sceneName?: string): Promise { const settings = { text, font: { face: "Arial", size: 48 } }; try { await this.sendRequest({ requestType: "SetInputSettings", requestData: { inputName, inputSettings: settings, }, }); return; } catch { // try create } const targetScene = sceneName || "Hotel Cam"; try { await this.sendRequest({ requestType: "CreateInput", requestData: { inputName, inputKind: "text_ft2_source_v2", inputSettings: settings, sceneName: targetScene, }, }); } catch (e) { // last resort: try gdiplus variant (windows) await this.sendRequest({ requestType: "CreateInput", requestData: { inputName, inputKind: "text_gdiplus_v2", inputSettings: settings, sceneName: targetScene, }, }); } } async getStats(): Promise> { return this.sendRequest({ requestType: "GetStats" }); } }