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');
}

View file

@ -9,6 +9,15 @@ interface StreamInfo {
title: string;
startedAt: string;
viewerCount: number;
hasPassword: boolean;
}
interface JoinModal {
streamId: string;
streamTitle: string;
broadcasterName: string;
password: string;
error: string | null;
}
interface ViewState {
@ -41,7 +50,9 @@ export default function StreamingTab({ data }: { data: any }) {
const [streams, setStreams] = useState<StreamInfo[]>([]);
const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || '');
const [streamTitle, setStreamTitle] = useState('Screen Share');
const [streamPassword, setStreamPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [joinModal, setJoinModal] = useState<JoinModal | null>(null);
const [myStreamId, setMyStreamId] = useState<string | null>(null);
const [isBroadcasting, setIsBroadcasting] = useState(false);
const [starting, setStarting] = useState(false);
@ -234,7 +245,12 @@ export default function StreamingTab({ data }: { data: any }) {
}
case 'error':
if (msg.code === 'WRONG_PASSWORD') {
// Show error inside join modal
setJoinModal(prev => prev ? { ...prev, error: msg.message } : prev);
} else {
setError(msg.message);
}
setStarting(false);
break;
}
@ -278,6 +294,7 @@ export default function StreamingTab({ data }: { data: any }) {
// ── Start broadcasting ──
const startBroadcast = useCallback(async () => {
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
if (!streamPassword.trim()) { setError('Passwort ist Pflicht.'); return; }
if (!navigator.mediaDevices?.getDisplayMedia) {
setError('Dein Browser unterstützt keine Bildschirmfreigabe.');
@ -309,7 +326,7 @@ export default function StreamingTab({ data }: { data: any }) {
const waitForWs = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share' });
wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share', password: streamPassword.trim() });
} else {
setTimeout(waitForWs, 100);
}
@ -323,7 +340,7 @@ export default function StreamingTab({ data }: { data: any }) {
setError(`Fehler: ${e.message}`);
}
}
}, [userName, streamTitle, connectWs, wsSend]);
}, [userName, streamTitle, streamPassword, connectWs, wsSend]);
// ── Stop broadcasting ──
const stopBroadcast = useCallback(() => {
@ -339,23 +356,41 @@ export default function StreamingTab({ data }: { data: any }) {
setIsBroadcasting(false);
isBroadcastingRef.current = false;
setMyStreamId(null);
setStreamPassword('');
}, [wsSend]);
// ── Join as viewer ──
const joinStream = useCallback((streamId: string) => {
// ── Join as viewer (opens password modal first) ──
const openJoinModal = useCallback((s: StreamInfo) => {
setJoinModal({
streamId: s.id,
streamTitle: s.title,
broadcasterName: s.broadcasterName,
password: '',
error: null,
});
}, []);
const submitJoinModal = useCallback(() => {
if (!joinModal) return;
if (!joinModal.password.trim()) {
setJoinModal(prev => prev ? { ...prev, error: 'Passwort eingeben.' } : prev);
return;
}
const { streamId, password } = joinModal;
setJoinModal(null);
setError(null);
setViewing({ streamId, phase: 'connecting' });
connectWs();
const waitForWs = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsSend({ type: 'join_viewer', name: userName.trim() || 'Viewer', streamId });
wsSend({ type: 'join_viewer', name: userName.trim() || 'Viewer', streamId, password: password.trim() });
} else {
setTimeout(waitForWs, 100);
}
};
waitForWs();
}, [userName, connectWs, wsSend]);
}, [joinModal, userName, connectWs, wsSend]);
// ── Leave viewer ──
const leaveViewing = useCallback(() => {
@ -438,6 +473,14 @@ export default function StreamingTab({ data }: { data: any }) {
onChange={e => setStreamTitle(e.target.value)}
disabled={isBroadcasting}
/>
<input
className="stream-input stream-input-password"
type="password"
placeholder="Passwort"
value={streamPassword}
onChange={e => setStreamPassword(e.target.value)}
disabled={isBroadcasting}
/>
{isBroadcasting ? (
<button className="stream-btn stream-btn-stop" onClick={stopBroadcast}>
{'\u23F9'} Stream beenden
@ -486,11 +529,12 @@ export default function StreamingTab({ data }: { data: any }) {
{streams
.filter(s => s.id !== myStreamId)
.map(s => (
<div key={s.id} className="stream-tile" onClick={() => joinStream(s.id)}>
<div key={s.id} className="stream-tile" onClick={() => openJoinModal(s)}>
<div className="stream-tile-preview">
<span className="stream-tile-icon">{'\u{1F5A5}\uFE0F'}</span>
<span className="stream-live-badge"><span className="stream-live-dot" /> LIVE</span>
<span className="stream-tile-viewers">{'\u{1F465}'} {s.viewerCount}</span>
{s.hasPassword && <span className="stream-tile-lock">{'\u{1F512}'}</span>}
</div>
<div className="stream-tile-info">
<div className="stream-tile-meta">
@ -504,6 +548,30 @@ export default function StreamingTab({ data }: { data: any }) {
))}
</div>
)}
{/* Password join modal */}
{joinModal && (
<div className="stream-pw-overlay" onClick={() => setJoinModal(null)}>
<div className="stream-pw-modal" onClick={e => e.stopPropagation()}>
<h3>{joinModal.broadcasterName}</h3>
<p>{joinModal.streamTitle}</p>
{joinModal.error && <div className="stream-pw-modal-error">{joinModal.error}</div>}
<input
className="stream-input"
type="password"
placeholder="Stream-Passwort"
value={joinModal.password}
onChange={e => setJoinModal(prev => prev ? { ...prev, password: e.target.value, error: null } : prev)}
onKeyDown={e => { if (e.key === 'Enter') submitJoinModal(); }}
autoFocus
/>
<div className="stream-pw-actions">
<button className="stream-pw-cancel" onClick={() => setJoinModal(null)}>Abbrechen</button>
<button className="stream-btn" onClick={submitJoinModal}>Beitreten</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -324,3 +324,72 @@
border: 2px solid var(--danger);
border-bottom: none;
}
/* ── Password input in topbar ── */
.stream-input-password {
width: 140px;
}
/* ── Lock icon on tile ── */
.stream-tile-lock {
position: absolute;
bottom: 8px;
right: 8px;
font-size: 16px;
opacity: 0.6;
}
/* ── Password modal ── */
.stream-pw-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
}
.stream-pw-modal {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 24px;
width: 340px;
max-width: 90vw;
}
.stream-pw-modal h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
}
.stream-pw-modal p {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 16px;
}
.stream-pw-modal .stream-input {
width: 100%;
margin-bottom: 12px;
}
.stream-pw-modal-error {
color: var(--danger);
font-size: 13px;
margin-bottom: 8px;
}
.stream-pw-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.stream-pw-cancel {
padding: 8px 16px;
border: 1px solid var(--bg-tertiary);
border-radius: var(--radius);
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
}
.stream-pw-cancel:hover {
color: var(--text-normal);
border-color: var(--text-faint);
}