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:
parent
dacfde4328
commit
4aed4e70ab
3 changed files with 191 additions and 15 deletions
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue