Files
hms-mediaengine/_entwicklung/apps/web/src/hooks/useWebSocket.ts
T

118 lines
3.6 KiB
TypeScript
Raw Normal View History

/**
* 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 };
}