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';
|
role: 'idle' | 'broadcaster' | 'viewer';
|
||||||
name: string;
|
name: string;
|
||||||
streamId?: string; // ID of stream this client broadcasts or views
|
streamId?: string; // ID of stream this client broadcasts or views
|
||||||
|
isAlive: boolean; // heartbeat tracking
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── State ──
|
// ── State ──
|
||||||
|
|
||||||
/** Active streams keyed by stream ID */
|
/** Active streams keyed by stream ID (password stored server-side, never sent to clients) */
|
||||||
const streams = new Map<string, StreamInfo & { broadcasterId: string }>();
|
const streams = new Map<string, StreamInfo & { broadcasterId: string; password: string }>();
|
||||||
/** All connected WS clients */
|
/** All connected WS clients */
|
||||||
const wsClients = new Map<string, WsClient>();
|
const wsClients = new Map<string, WsClient>();
|
||||||
|
|
||||||
let wss: WebSocketServer | null = null;
|
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 ──
|
// ── Helpers ──
|
||||||
|
|
||||||
function broadcastStreamStatus(): void {
|
function broadcastStreamStatus(): void {
|
||||||
const list = [...streams.values()].map(({ broadcasterId: _, ...s }) => s);
|
sseBroadcast({ type: 'streaming_status', plugin: 'streaming', streams: getStreamList() });
|
||||||
sseBroadcast({ type: 'streaming_status', plugin: 'streaming', streams: list });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendTo(client: WsClient, data: Record<string, any>): void {
|
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[] {
|
function getStreamList(): (StreamInfo & { hasPassword: boolean })[] {
|
||||||
return [...streams.values()].map(({ broadcasterId: _, ...s }) => s);
|
return [...streams.values()].map(({ broadcasterId: _, password: pw, ...s }) => ({
|
||||||
|
...s,
|
||||||
|
hasPassword: pw.length > 0,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function endStream(streamId: string, reason: string): void {
|
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.' });
|
sendTo(client, { type: 'error', code: 'ALREADY_BROADCASTING', message: 'Du streamst bereits.' });
|
||||||
return;
|
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 streamId = crypto.randomUUID();
|
||||||
const name = String(msg.name || 'Anon').slice(0, 32);
|
const name = String(msg.name || 'Anon').slice(0, 32);
|
||||||
const title = String(msg.title || 'Screen Share').slice(0, 64);
|
const title = String(msg.title || 'Screen Share').slice(0, 64);
|
||||||
|
|
@ -98,6 +109,7 @@ function handleSignalingMessage(client: WsClient, msg: any): void {
|
||||||
broadcasterId: client.id,
|
broadcasterId: client.id,
|
||||||
broadcasterName: name,
|
broadcasterName: name,
|
||||||
title,
|
title,
|
||||||
|
password,
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
viewerCount: 0,
|
viewerCount: 0,
|
||||||
});
|
});
|
||||||
|
|
@ -130,6 +142,12 @@ function handleSignalingMessage(client: WsClient, msg: any): void {
|
||||||
sendTo(client, { type: 'error', code: 'NO_STREAM', message: 'Stream nicht gefunden.' });
|
sendTo(client, { type: 'error', code: 'NO_STREAM', message: 'Stream nicht gefunden.' });
|
||||||
return;
|
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.role = 'viewer';
|
||||||
client.name = String(msg.name || 'Viewer').slice(0, 32);
|
client.name = String(msg.name || 'Viewer').slice(0, 32);
|
||||||
|
|
@ -215,6 +233,7 @@ const streamingPlugin: Plugin = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async destroy() {
|
async destroy() {
|
||||||
|
if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; }
|
||||||
if (wss) {
|
if (wss) {
|
||||||
for (const client of wsClients.values()) {
|
for (const client of wsClients.values()) {
|
||||||
client.ws.close(1001, 'Server shutting down');
|
client.ws.close(1001, 'Server shutting down');
|
||||||
|
|
@ -234,12 +253,16 @@ export function attachWebSocket(server: http.Server): void {
|
||||||
|
|
||||||
wss.on('connection', (ws) => {
|
wss.on('connection', (ws) => {
|
||||||
const clientId = crypto.randomUUID();
|
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);
|
wsClients.set(clientId, client);
|
||||||
|
|
||||||
sendTo(client, { type: 'welcome', clientId, streams: getStreamList() });
|
sendTo(client, { type: 'welcome', clientId, streams: getStreamList() });
|
||||||
|
|
||||||
|
// Pong response marks client as alive
|
||||||
|
ws.on('pong', () => { client.isAlive = true; });
|
||||||
|
|
||||||
ws.on('message', (raw) => {
|
ws.on('message', (raw) => {
|
||||||
|
client.isAlive = true; // any message = alive
|
||||||
let msg: any;
|
let msg: any;
|
||||||
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
||||||
handleSignalingMessage(client, msg);
|
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');
|
console.log('[Streaming] WebSocket signaling attached at /ws/streaming');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,15 @@ interface StreamInfo {
|
||||||
title: string;
|
title: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
viewerCount: number;
|
viewerCount: number;
|
||||||
|
hasPassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JoinModal {
|
||||||
|
streamId: string;
|
||||||
|
streamTitle: string;
|
||||||
|
broadcasterName: string;
|
||||||
|
password: string;
|
||||||
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ViewState {
|
interface ViewState {
|
||||||
|
|
@ -41,7 +50,9 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
const [streams, setStreams] = useState<StreamInfo[]>([]);
|
const [streams, setStreams] = useState<StreamInfo[]>([]);
|
||||||
const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || '');
|
const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || '');
|
||||||
const [streamTitle, setStreamTitle] = useState('Screen Share');
|
const [streamTitle, setStreamTitle] = useState('Screen Share');
|
||||||
|
const [streamPassword, setStreamPassword] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [joinModal, setJoinModal] = useState<JoinModal | null>(null);
|
||||||
const [myStreamId, setMyStreamId] = useState<string | null>(null);
|
const [myStreamId, setMyStreamId] = useState<string | null>(null);
|
||||||
const [isBroadcasting, setIsBroadcasting] = useState(false);
|
const [isBroadcasting, setIsBroadcasting] = useState(false);
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
|
|
@ -234,7 +245,12 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
setError(msg.message);
|
if (msg.code === 'WRONG_PASSWORD') {
|
||||||
|
// Show error inside join modal
|
||||||
|
setJoinModal(prev => prev ? { ...prev, error: msg.message } : prev);
|
||||||
|
} else {
|
||||||
|
setError(msg.message);
|
||||||
|
}
|
||||||
setStarting(false);
|
setStarting(false);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -278,6 +294,7 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
// ── Start broadcasting ──
|
// ── Start broadcasting ──
|
||||||
const startBroadcast = useCallback(async () => {
|
const startBroadcast = useCallback(async () => {
|
||||||
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
|
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
|
||||||
|
if (!streamPassword.trim()) { setError('Passwort ist Pflicht.'); return; }
|
||||||
|
|
||||||
if (!navigator.mediaDevices?.getDisplayMedia) {
|
if (!navigator.mediaDevices?.getDisplayMedia) {
|
||||||
setError('Dein Browser unterstützt keine Bildschirmfreigabe.');
|
setError('Dein Browser unterstützt keine Bildschirmfreigabe.');
|
||||||
|
|
@ -309,7 +326,7 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
|
|
||||||
const waitForWs = () => {
|
const waitForWs = () => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
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 {
|
} else {
|
||||||
setTimeout(waitForWs, 100);
|
setTimeout(waitForWs, 100);
|
||||||
}
|
}
|
||||||
|
|
@ -323,7 +340,7 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
setError(`Fehler: ${e.message}`);
|
setError(`Fehler: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [userName, streamTitle, connectWs, wsSend]);
|
}, [userName, streamTitle, streamPassword, connectWs, wsSend]);
|
||||||
|
|
||||||
// ── Stop broadcasting ──
|
// ── Stop broadcasting ──
|
||||||
const stopBroadcast = useCallback(() => {
|
const stopBroadcast = useCallback(() => {
|
||||||
|
|
@ -339,23 +356,41 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
setIsBroadcasting(false);
|
setIsBroadcasting(false);
|
||||||
isBroadcastingRef.current = false;
|
isBroadcastingRef.current = false;
|
||||||
setMyStreamId(null);
|
setMyStreamId(null);
|
||||||
|
setStreamPassword('');
|
||||||
}, [wsSend]);
|
}, [wsSend]);
|
||||||
|
|
||||||
// ── Join as viewer ──
|
// ── Join as viewer (opens password modal first) ──
|
||||||
const joinStream = useCallback((streamId: string) => {
|
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);
|
setError(null);
|
||||||
setViewing({ streamId, phase: 'connecting' });
|
setViewing({ streamId, phase: 'connecting' });
|
||||||
connectWs();
|
connectWs();
|
||||||
|
|
||||||
const waitForWs = () => {
|
const waitForWs = () => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
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 {
|
} else {
|
||||||
setTimeout(waitForWs, 100);
|
setTimeout(waitForWs, 100);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
waitForWs();
|
waitForWs();
|
||||||
}, [userName, connectWs, wsSend]);
|
}, [joinModal, userName, connectWs, wsSend]);
|
||||||
|
|
||||||
// ── Leave viewer ──
|
// ── Leave viewer ──
|
||||||
const leaveViewing = useCallback(() => {
|
const leaveViewing = useCallback(() => {
|
||||||
|
|
@ -438,6 +473,14 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
onChange={e => setStreamTitle(e.target.value)}
|
onChange={e => setStreamTitle(e.target.value)}
|
||||||
disabled={isBroadcasting}
|
disabled={isBroadcasting}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
className="stream-input stream-input-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Passwort"
|
||||||
|
value={streamPassword}
|
||||||
|
onChange={e => setStreamPassword(e.target.value)}
|
||||||
|
disabled={isBroadcasting}
|
||||||
|
/>
|
||||||
{isBroadcasting ? (
|
{isBroadcasting ? (
|
||||||
<button className="stream-btn stream-btn-stop" onClick={stopBroadcast}>
|
<button className="stream-btn stream-btn-stop" onClick={stopBroadcast}>
|
||||||
{'\u23F9'} Stream beenden
|
{'\u23F9'} Stream beenden
|
||||||
|
|
@ -486,11 +529,12 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
{streams
|
{streams
|
||||||
.filter(s => s.id !== myStreamId)
|
.filter(s => s.id !== myStreamId)
|
||||||
.map(s => (
|
.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">
|
<div className="stream-tile-preview">
|
||||||
<span className="stream-tile-icon">{'\u{1F5A5}\uFE0F'}</span>
|
<span className="stream-tile-icon">{'\u{1F5A5}\uFE0F'}</span>
|
||||||
<span className="stream-live-badge"><span className="stream-live-dot" /> LIVE</span>
|
<span className="stream-live-badge"><span className="stream-live-dot" /> LIVE</span>
|
||||||
<span className="stream-tile-viewers">{'\u{1F465}'} {s.viewerCount}</span>
|
<span className="stream-tile-viewers">{'\u{1F465}'} {s.viewerCount}</span>
|
||||||
|
{s.hasPassword && <span className="stream-tile-lock">{'\u{1F512}'}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="stream-tile-info">
|
<div className="stream-tile-info">
|
||||||
<div className="stream-tile-meta">
|
<div className="stream-tile-meta">
|
||||||
|
|
@ -504,6 +548,30 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -324,3 +324,72 @@
|
||||||
border: 2px solid var(--danger);
|
border: 2px solid var(--danger);
|
||||||
border-bottom: none;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue