fix(streaming): reliable disconnect + mandatory stream password

Disconnect:
- Server-side heartbeat ping/pong every 10s with 25s timeout
- Detects and cleans up dead connections (browser closed, network lost)
- ws.terminate() on heartbeat timeout triggers handleDisconnect

Password:
- Stream password is mandatory (server rejects start_broadcast without)
- Password stored server-side, never sent to clients
- Viewers must enter password via modal before joining
- Lock icon on tiles, WRONG_PASSWORD error shown in modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-07 01:00:48 +01:00
parent dacfde4328
commit 4aed4e70ab
3 changed files with 191 additions and 15 deletions

View file

@ -21,22 +21,25 @@ interface WsClient {
role: 'idle' | 'broadcaster' | 'viewer';
name: string;
streamId?: string; // ID of stream this client broadcasts or views
isAlive: boolean; // heartbeat tracking
}
// ── State ──
/** Active streams keyed by stream ID */
const streams = new Map<string, StreamInfo & { broadcasterId: string }>();
/** Active streams keyed by stream ID (password stored server-side, never sent to clients) */
const streams = new Map<string, StreamInfo & { broadcasterId: string; password: string }>();
/** All connected WS clients */
const wsClients = new Map<string, WsClient>();
let wss: WebSocketServer | null = null;
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
const HEARTBEAT_MS = 10_000; // ping every 10s
const HEARTBEAT_TIMEOUT = 25_000; // dead after missing ~2 pings
// ── Helpers ──
function broadcastStreamStatus(): void {
const list = [...streams.values()].map(({ broadcasterId: _, ...s }) => s);
sseBroadcast({ type: 'streaming_status', plugin: 'streaming', streams: list });
sseBroadcast({ type: 'streaming_status', plugin: 'streaming', streams: getStreamList() });
}
function sendTo(client: WsClient, data: Record<string, any>): void {
@ -45,8 +48,11 @@ function sendTo(client: WsClient, data: Record<string, any>): void {
}
}
function getStreamList(): StreamInfo[] {
return [...streams.values()].map(({ broadcasterId: _, ...s }) => s);
function getStreamList(): (StreamInfo & { hasPassword: boolean })[] {
return [...streams.values()].map(({ broadcasterId: _, password: pw, ...s }) => ({
...s,
hasPassword: pw.length > 0,
}));
}
function endStream(streamId: string, reason: string): void {
@ -85,6 +91,11 @@ function handleSignalingMessage(client: WsClient, msg: any): void {
sendTo(client, { type: 'error', code: 'ALREADY_BROADCASTING', message: 'Du streamst bereits.' });
return;
}
const password = String(msg.password || '').trim();
if (!password) {
sendTo(client, { type: 'error', code: 'PASSWORD_REQUIRED', message: 'Passwort ist Pflicht.' });
return;
}
const streamId = crypto.randomUUID();
const name = String(msg.name || 'Anon').slice(0, 32);
const title = String(msg.title || 'Screen Share').slice(0, 64);
@ -98,6 +109,7 @@ function handleSignalingMessage(client: WsClient, msg: any): void {
broadcasterId: client.id,
broadcasterName: name,
title,
password,
startedAt: new Date().toISOString(),
viewerCount: 0,
});
@ -130,6 +142,12 @@ function handleSignalingMessage(client: WsClient, msg: any): void {
sendTo(client, { type: 'error', code: 'NO_STREAM', message: 'Stream nicht gefunden.' });
return;
}
// Validate password
const joinPw = String(msg.password || '').trim();
if (stream.password && joinPw !== stream.password) {
sendTo(client, { type: 'error', code: 'WRONG_PASSWORD', message: 'Falsches Passwort.' });
return;
}
client.role = 'viewer';
client.name = String(msg.name || 'Viewer').slice(0, 32);
@ -215,6 +233,7 @@ const streamingPlugin: Plugin = {
},
async destroy() {
if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; }
if (wss) {
for (const client of wsClients.values()) {
client.ws.close(1001, 'Server shutting down');
@ -234,12 +253,16 @@ export function attachWebSocket(server: http.Server): void {
wss.on('connection', (ws) => {
const clientId = crypto.randomUUID();
const client: WsClient = { id: clientId, ws, role: 'idle', name: '' };
const client: WsClient = { id: clientId, ws, role: 'idle', name: '', isAlive: true };
wsClients.set(clientId, client);
sendTo(client, { type: 'welcome', clientId, streams: getStreamList() });
// Pong response marks client as alive
ws.on('pong', () => { client.isAlive = true; });
ws.on('message', (raw) => {
client.isAlive = true; // any message = alive
let msg: any;
try { msg = JSON.parse(raw.toString()); } catch { return; }
handleSignalingMessage(client, msg);
@ -256,6 +279,22 @@ export function attachWebSocket(server: http.Server): void {
});
});
// ── Heartbeat: detect dead connections ──
heartbeatInterval = setInterval(() => {
for (const [id, client] of wsClients) {
if (!client.isAlive) {
// No pong received since last check → dead
console.log(`[Streaming] Heartbeat timeout for ${client.name || id.slice(0, 8)} (role=${client.role})`);
handleDisconnect(client);
wsClients.delete(id);
client.ws.terminate();
continue;
}
client.isAlive = false;
try { client.ws.ping(); } catch { /* ignore */ }
}
}, HEARTBEAT_MS);
console.log('[Streaming] WebSocket signaling attached at /ws/streaming');
}