Files
hms-mediaengine/apps/web/src/hooks/useWebSocket.ts
T
HMS MediaEngine Agent c6a3cf7751 Phase 5: Web-Bedienoberfläche nach §17 (React+Vite, ADR-0006)
- ADR-0006: React 18/19 mit Vite im statischen SPA-Modus
- Workspace §17.2: Statusleiste 32px, einklappbare Werkzeugleiste,
  Layer-Tabelle, Inspector ~30% mit Divider-Resize, umschaltbare
  rechte Seitenleiste, Fußleiste mit Setup/Live-Lock
- Layer-Tabelle §17.3: alle 10 Pflichtspalten, Zeilen 30px,
  7 unterscheidbare Zustände (Glyph+Farbe+Text, nie nur Farbe),
  Pfeiltasten-Navigation, Opacity-Fader mit echtem parameter.set
- Inspector §17.4: 7 Tabs, Zahlenfelder mit Tippen/Ziehen/Pfeiltasten,
  Release-Befehl, Live-Lock sperrt Struktur
- Design-Tokens §17.6: alle als CSS-Variablen (Flächen, Text,
  Akzent-Blau, Zustandsfarben, 4px-Raster, 2-4px Radien,
  Monospace tabular-nums, 100-180ms)
- WebSocket: Snapshot bei Connect, Deltas danach, Reconnect
  1s/2s/4s max 30s; Browser-Neustart stoppt Output nicht (§3.2)
- REST: health/identity/cluster-nodes/parameters/diagnostics,
  Commands mit expected_revision + Revert bei 409
- Kein GO-Button, keine Standby-Anzeige, keine Cue-Carts (§17.1)
- Build: tsc strict + vite, 27 Module, 262KB JS gzip 80KB
- Python-Suite 549 grün unberührt
2026-09-11 03:02:38 +02:00

118 lines
3.6 KiB
TypeScript

/**
* WebSocket zum Control Core (§17.9, §3.2).
*
* - Snapshot beim Connect, danach Deltas (parameter.update) plus Heartbeat.
* - Reconnect mit Exponential-Backoff: 1 s, 2 s, 4 s … maximum 30 s.
* - Browser-Neustart/-Reload stoppt den Output nicht (§3.2): der Client
* sendet keinerlei Commands beim Connect und synchronisiert nur Zustand.
* - URL: Same-Origin '/ws' (Dev über den Vite-Proxy → ws://localhost:8000/ws);
* alternativ direkt über VITE_WS_URL konfigurierbar.
*/
import { useEffect, useState } from 'react';
import type { WsServerEvent } from '../types';
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30_000;
function resolveWsUrl(): string {
const override: unknown = import.meta.env.VITE_WS_URL;
if (typeof override === 'string' && override.length > 0) return override;
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
return `${protocol}://${window.location.host}/ws`;
}
function isWsServerEvent(data: unknown): data is WsServerEvent {
if (typeof data !== 'object' || data === null) return false;
const type = (data as { type?: unknown }).type;
return type === 'snapshot' || type === 'parameter.update' || type === 'heartbeat';
}
export interface WsState {
readonly connected: boolean;
/** Engine-Revision aus Snapshot/Updates; null vor der ersten Serverantwort. */
readonly revision: number | null;
readonly values: Readonly<Record<string, number>>;
readonly lastEventMs: number | null;
}
export function useWebSocket(): WsState {
const [connected, setConnected] = useState(false);
const [revision, setRevision] = useState<number | null>(null);
const [values, setValues] = useState<Record<string, number>>({});
const [lastEventMs, setLastEventMs] = useState<number | null>(null);
useEffect(() => {
let disposed = false;
let socket: WebSocket | null = null;
let timer: number | null = null;
let attempt = 0;
const clearTimer = (): void => {
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
};
const scheduleReconnect = (): void => {
const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS);
attempt += 1;
timer = window.setTimeout(connect, delay);
};
const connect = (): void => {
if (disposed) return;
socket = new WebSocket(resolveWsUrl());
socket.onopen = () => {
if (disposed) return;
attempt = 0;
setConnected(true);
};
socket.onmessage = (ev: MessageEvent<string>) => {
if (disposed) return;
setLastEventMs(Date.now());
const parsed: unknown = (() => {
try {
return JSON.parse(ev.data) as unknown;
} catch {
return null;
}
})();
if (!isWsServerEvent(parsed)) return;
switch (parsed.type) {
case 'snapshot':
setValues(parsed.values);
setRevision(parsed.revision);
break;
case 'parameter.update':
setValues(prev => ({ ...prev, [parsed.parameter_path]: parsed.value }));
setRevision(parsed.revision);
break;
case 'heartbeat':
break;
}
};
socket.onclose = () => {
if (disposed) return;
setConnected(false);
scheduleReconnect();
};
socket.onerror = () => {
socket?.close();
};
};
connect();
return () => {
disposed = true;
clearTimer();
socket?.close(1000, 'client-unmount');
socket = null;
};
}, []);
return { connected, revision, values, lastEventMs };
}