openvibers/openvibe.live Commits

Delete the P2P viewer fallback (ALLOW_P2P_FALLBACK, C-77): viewer media is SFU-only. (#37)

OP OpenVibers committed 7 Oct 2026 · 5c6a5cc4ab · 11 files, +104 −685

## Changes (one line each)

**Server**
- `server/config.js:89,93,186` — removed the `ALLOW_P2P_FALLBACK` parse, its startup `console.warn`, and the `allowP2pFallback` export. (Default was `false`: absent → `normalizeValue` → `null` → `=== true` is false, so the fallback was already off unless explicitly enabled.)
- `server/streaming/broadcast-server.js:10,104,329` — header now says SFU-only; dropped `allowP2pFallback` from the `welcome` and `watch-queued` payloads.
- `server/streaming/broadcast-server.js:362` — deleted the `offer`/`answer`/`ice-candidate` relay case (legacy messages now hit `default` and are ignored).
- `server/streaming/broadcast-server.js:390` — deleted the SFU-error `viewer-joined` fallback branch.
- `server/streaming/broadcast-server.js:494` — deleted the `relaySignaling()` method.
- `server/streaming/broadcast-server.js:546` — deleted the `viewer-left` notify on viewer disconnect.
- `server/streaming/broadcast-server.js:808` — reworded the "falling back to P2P" log to "viewer will be queued".

**Client**
- `public/js/broadcast-state.js:21` — dropped `viewerConnections`, `viewerReconnectTimers`, `_allowP2pFallback` from stream state.
- `public/js/broadcast.js:330` — removed `clearViewerReconnectTimer()` / `scheduleViewerReconnect()`.
- `public/js/broadcast.js:2288` — removed viewer-PC teardown loops.
- `public/js/broadcast.js:2405` — removed the P2P stats fallback.
- `public/js/broadcast.js:3985` — deleted `createViewerConnection()` / `closeViewerConnection()`; `updateBroadcastStatusFromConnections()` now keys only off the SFU produce transport.
- `public/js/broadcast.js:4371` — dropped `viewer-joined`/`viewer-left`/`answer`/`ice-candidate` cases and the `welcome._allowP2pFallback` read.
- `public/js/broadcast.js:4808,4833,4925,5170` — removed the P2P `viewerConnections` track-replace loops (camera switch, `_replaceAllViewerTracks`, `_republishAudioTrack`); SFU/RS/VOD paths untouched.
- `public/js/stream-player.js:1153,1236` — dropped `player.pc` init and the welcome `_allowP2pFallback` read.
- `public/js/stream-player.js:1280,1305` — deleted the `offer` and `ice-candidate` viewer cases.
- `public/js/stream-player.js:~1457–1746` — deleted the `handleViewerOffer()` SDP client fallback and its `_startWatchOfferTimeout` exposure.
- `public/js/stream-player.js:1224,1264,1326,1448` — health/cleanup checks switched from `player.pc` to `player._sfuRecvTransport`.
- `public/js/stream-player.js:256,281,3434,3578` — dropped `legacyPc` diagnostics, the PC close in `destroyPlayer`, and the PC branch in `_getStatsSource()`.

**Docs / config examples**
- `public/fragments/documentation.html:324,345` — removed `allowP2pFallback` from the `welcome` row; the note now says viewer media is SFU-only, no P2P relay.
- `.env.example:133` — removed `ALLOW_P2P_FALLBACK=false`.
- `SETUP.md:133` — replaced the flag bullet with "Viewer media is SFU-only (mediasoup); there is no peer-to-peer fallback."

Changes

ddiff --git a/.env.example b/.env.example
iindex d2667202..5a4a41fa 100644
---- a/.env.example
++++ b/.env.example
@@@ -130,7 +130,6 @@ TURN_CREDENTIAL=
# Preferred: coturn `use-auth-secret` + `static-auth-secret=<this>` — every ICE list then carries a
# credential that expires after an hour instead of the static pair above.
TURN_AUTH_SECRET=
--ALLOW_P2P_FALLBACK=false
# PayPal (for Vibes cashout — Vibes stay Live-local)
PAYPAL_CLIENT_ID=
ddiff --git a/SETUP.md b/SETUP.md
iindex eea246b8..2bec8666 100644
---- a/SETUP.md
++++ b/SETUP.md
@@@ -130,7 +130,7 @@ Completion arrives via the `vod.ready` / `clip.ready` webhooks.
- `MEDIASOUP_LISTEN_IP`, `MEDIASOUP_ANNOUNCED_IP`, `MEDIASOUP_MIN_PORT` / `MEDIASOUP_MAX_PORT`, `WEBRTC_PORT`.
- `TURN_URL` / `TURN_USERNAME` / `TURN_CREDENTIAL` — required in production for reliable NAT traversal (default `turn:turn.openvibe.live`).
--- `ALLOW_P2P_FALLBACK` — emergency rollback flag; leave `false` (legacy P2P relays can expose viewer IPs).
++- Viewer media is SFU-only (mediasoup); there is no peer-to-peer fallback.
### WHIP
ddiff --git a/public/fragments/documentation.html b/public/fragments/documentation.html
iindex 2b362054..24a8c6a2 100644
---- a/public/fragments/documentation.html
++++ b/public/fragments/documentation.html
@@@ -321,7 +321,7 @@ console.log(endpoint.whipUrlBase); // POST your SDP offer here (see WebRTC tab)
<table class="doc-table">
<thead><tr><th>Type</th><th>Dir</th><th>Key Fields</th><th>Description</th></tr></thead>
<tbody>
--<tr><td>welcome</td><td>S→C</td><td>peerId, role, streamId, viewerCount, iceServers, allowP2pFallback</td><td>Sent immediately on connect</td></tr>
++<tr><td>welcome</td><td>S→C</td><td>peerId, role, streamId, viewerCount, iceServers</td><td>Sent immediately on connect</td></tr>
<tr><td>watch</td><td>C→S</td><td>—</td><td>Viewer requests to start watching (triggers SFU consumer)</td></tr>
<tr><td>broadcaster-ready</td><td>S→Viewer</td><td>peerId</td><td>Broadcaster or SFU source is live, safe to send/refresh <code>watch</code></td></tr>
<tr><td>watch-queued</td><td>S→Viewer</td><td>reason, detail</td><td>Viewer is queued while the live source warms up or reconnects</td></tr>
@@@ -342,7 +342,7 @@ console.log(endpoint.whipUrlBase); // POST your SDP offer here (see WebRTC tab)
<tr><td>stream-ended</td><td>S→Viewers</td><td>—</td><td>Stream has ended</td></tr>
</tbody>
</table>
--<p class="muted" style="margin-top:10px">Legacy <code>offer</code> / <code>answer</code> / <code>ice-candidate</code> relay messages are disabled by default. They are only honored when <code>ALLOW_P2P_FALLBACK=true</code> is explicitly set for emergency rollback.</p>
++<p class="muted" style="margin-top:10px">Viewer media is delivered exclusively through the Mediasoup SFU. There is no direct peer-to-peer relay: legacy <code>offer</code> / <code>answer</code> / <code>ice-candidate</code> messages are not supported.</p>
<h3>RTMP Ingest (OBS)</h3>
<pre><code>Server: rtmp://openvibe.live/live
ddiff --git a/public/js/broadcast-state.js b/public/js/broadcast-state.js
iindex 7803db92..a9b84d48 100644
---- a/public/js/broadcast-state.js
++++ b/public/js/broadcast-state.js
@@@ -18,10 +18,6 @@ function createStreamState(streamData) {
return {
streamData,
localStream: null,
-- /** @type {Map<string, RTCPeerConnection>} peerId → PC */
-- viewerConnections: new Map(),
-- viewerReconnectTimers: new Map(),
-- _allowP2pFallback: false,
signalingWs: null,
heartbeatInterval: null,
startedAt: streamData?.started_at || null,
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 9965b652..26871990 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -327,27 +327,6 @@ function _ensureSfuBroadcastReady(streamId, reason = 'startup') {
});
}
--function clearViewerReconnectTimer(ss, viewerPeerId) {
-- const timer = ss?.viewerReconnectTimers?.get(viewerPeerId);
-- if (timer) clearTimeout(timer);
-- ss?.viewerReconnectTimers?.delete(viewerPeerId);
--}
--
--function scheduleViewerReconnect(streamId, viewerPeerId, delay = 2000) {
-- const ss = getStreamState(streamId);
-- if (!ss || !viewerPeerId || !ss._allowP2pFallback) return;
-- clearViewerReconnectTimer(ss, viewerPeerId);
-- ss.viewerReconnectTimers.set(viewerPeerId, setTimeout(() => {
-- clearViewerReconnectTimer(ss, viewerPeerId);
-- if (!broadcastState.streams.has(streamId)) return;
-- if (!ss.localStream) return;
-- if (!ss.signalingWs || ss.signalingWs.readyState !== WebSocket.OPEN) return;
-- createViewerConnection(streamId, viewerPeerId).catch((err) => {
-- console.warn(`[Broadcast] Viewer reconnect failed for ${viewerPeerId}:`, err.message);
-- });
-- }, delay));
--}
--
/**
* Attach diagnostic logging to track/stream events.
*
@@@ -2307,12 +2286,6 @@ function cleanupStream(streamId) {
// Clear RS restream slot if this stream had it
if (getRsRestreamSlotStreamId() === streamId) setRsRestreamSlot(null);
-- // Close viewer connections
-- for (const [, pc] of ss.viewerConnections) { try { pc.close(); } catch {} }
-- ss.viewerConnections.clear();
-- for (const [, timer] of ss.viewerReconnectTimers) clearTimeout(timer);
-- ss.viewerReconnectTimers.clear();
--
// Close signaling WS
ss.signalingIntentionalClose = true;
ss.signalingReconnectDelay = 3000;
@@@ -2416,7 +2389,7 @@ function startGlobalDisplayTimers() {
// Primary source: the SFU produce transport — this IS the broadcaster's
// real ingest uplink, so its byte counter and connection state are the
-- // authoritative numbers to show. (Viewer PCs are only P2P-fallback downlinks.)
++ // authoritative numbers to show.
const sfuState = _sfuProduceStates.get(ss.streamData?.id);
if (sfuState?.transport) {
try {
@@@ -2426,20 +2399,6 @@ function startGlobalDisplayTimers() {
} catch {}
}
-- // Fallback: direct P2P viewer connections. Pick the healthiest one
-- // (most bytes sent) rather than blindly sampling the first — the first
-- // entry may be a still-negotiating or failed peer.
-- if (!hasStats && ss.viewerConnections.size > 0) {
-- for (const [, pc] of ss.viewerConnections) {
-- try {
-- const s = pc.iceConnectionState;
-- const bytes = extractStats(await pc.getStats());
-- if (bytes > totalBytesSent) { totalBytesSent = bytes; connState = s; }
-- } catch {}
-- }
-- hasStats = totalBytesSent > 0;
-- }
--
// Fallback: local stream settings (when no WebRTC stats available yet).
// Don't claim "connected" here — report the true transport/signaling state
// so the overlay never shows a fake healthy status.
@@@ -4023,96 +3982,6 @@ function connectSignaling(streamId) {
};
}
--async function createViewerConnection(streamId, viewerPeerId) {
-- const ss = getStreamState(streamId);
-- if (!ss) return;
-- if (!ss._allowP2pFallback) {
-- console.warn(`[Broadcast] Ignoring createViewerConnection(${streamId}, ${viewerPeerId}) — SFU-only mode is active`);
-- return;
-- }
-- if (ss.viewerConnections.has(viewerPeerId)) closeViewerConnection(streamId, viewerPeerId);
-- const s = broadcastState.settings;
-- const maxBitrate = getTargetVideoBitrate();
-- const maxFrameRate = getBroadcastFrameRate();
-- const scaleDownBy = getSuggestedScaleDown(s);
-- // Use server-provided ICE servers (with TURN support) if available
-- const iceServers = (ss._serverIceServers && ss._serverIceServers.length > 0)
-- ? ss._serverIceServers
-- : [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
-- const pc = new RTCPeerConnection({ iceServers });
-- ss.viewerConnections.set(viewerPeerId, pc);
-- ss.localStream.getTracks().forEach(track => {
-- try {
-- track.contentHint = track.kind === 'audio' ? 'speech' : (s.screenShare ? 'detail' : 'motion');
-- } catch {}
-- pc.addTrack(track, ss.localStream);
-- });
--
-- pc.getTransceivers().forEach(t => {
-- if (t.sender?.track?.kind === 'video') {
-- try { t.direction = 'sendonly'; } catch {}
-- try { t.setCodecPreferences?.((RTCRtpReceiver.getCapabilities('video')?.codecs || []).filter(Boolean)); } catch {}
-- }
-- });
--
-- const codec = s.broadcastCodec || 'auto';
-- if (codec !== 'auto' && pc.getTransceivers) {
-- const vt = pc.getTransceivers().find(t => t.sender?.track?.kind === 'video');
-- if (vt && typeof vt.setCodecPreferences === 'function') {
-- try {
-- const codecs = RTCRtpReceiver.getCapabilities('video')?.codecs || [];
-- const mimeMap = { vp8: 'video/VP8', vp9: 'video/VP9', h264: 'video/H264' };
-- const pref = codecs.filter(c => c.mimeType === mimeMap[codec]);
-- const rest = codecs.filter(c => c.mimeType !== mimeMap[codec]);
-- if (pref.length) vt.setCodecPreferences([...pref, ...rest]);
-- } catch {}
-- }
-- }
--
-- pc.getSenders().forEach(sender => {
-- if (sender.track?.kind === 'video') {
-- const params = sender.getParameters(); if (!params.encodings) params.encodings = [{}];
-- params.encodings[0].maxBitrate = maxBitrate;
-- params.encodings[0].maxFramerate = maxFrameRate;
-- params.encodings[0].scaleResolutionDownBy = scaleDownBy;
-- params.encodings[0].priority = 'high';
-- const minBps = parseInt(s.broadcastBpsMin); if (minBps > 50) params.encodings[0].minBitrate = minBps * 1000;
-- params.degradationPreference = s.screenShare ? 'maintain-resolution' : 'balanced';
-- sender.setParameters(params).catch(() => {});
-- } else if (sender.track?.kind === 'audio') {
-- const params = sender.getParameters();
-- if (!params.encodings) params.encodings = [{}];
-- params.encodings[0].priority = 'high';
-- sender.setParameters(params).catch(() => {});
-- }
-- });
--
-- pc.onicecandidate = (e) => { if (e.candidate) sendBroadcastSignal(ss, { type: 'ice-candidate', candidate: e.candidate, targetPeerId: viewerPeerId }); };
-- pc.oniceconnectionstatechange = () => {
-- const iceState = pc.iceConnectionState;
-- console.log(`[Broadcast] Stream ${streamId} viewer ${viewerPeerId} ICE state: ${iceState}`);
-- if (iceState === 'connected' || iceState === 'completed') clearViewerReconnectTimer(ss, viewerPeerId);
-- if (iceState === 'failed' || iceState === 'disconnected') {
-- scheduleViewerReconnect(streamId, viewerPeerId, iceState === 'failed' ? 1500 : 4000);
-- }
-- if (iceState === 'closed') {
-- closeViewerConnection(streamId, viewerPeerId);
-- }
-- if (broadcastState.activeStreamId === streamId) updateBroadcastStatusFromConnections(streamId);
-- };
--
-- const offer = await pc.createOffer(); await pc.setLocalDescription(offer);
-- sendBroadcastSignal(ss, { type: 'offer', sdp: pc.localDescription, targetPeerId: viewerPeerId });
--}
--
--function closeViewerConnection(streamId, viewerPeerId) {
-- const ss = getStreamState(streamId);
-- if (!ss) return;
-- clearViewerReconnectTimer(ss, viewerPeerId);
-- const pc = ss.viewerConnections.get(viewerPeerId);
-- if (pc) { try { pc.close(); } catch {} ss.viewerConnections.delete(viewerPeerId); }
--}
--
function updateBroadcastStatusFromConnections(streamId) {
const ss = getStreamState(streamId);
if (!ss) return;
@@@ -4127,19 +3996,6 @@ function updateBroadcastStatusFromConnections(streamId) {
return;
}
-- if (ss._allowP2pFallback && ss.viewerConnections.size > 0) {
-- let hasConnectedP2p = false;
-- for (const [, pc] of ss.viewerConnections) {
-- const state = pc.iceConnectionState;
-- if (state === 'connected' || state === 'completed') {
-- hasConnectedP2p = true;
-- break;
-- }
-- }
-- updateBroadcastStatus(hasConnectedP2p ? 'connected' : (signalingOpen ? 'checking' : 'disconnected'));
-- return;
-- }
--
updateBroadcastStatus(signalingOpen ? 'checking' : 'disconnected');
}
@@@ -4512,43 +4368,6 @@ async function handleSignalingMessage(streamId, msg) {
const ss = getStreamState(streamId);
if (!ss) return;
switch (msg.type) {
-- case 'viewer-joined':
-- if (!ss._allowP2pFallback) {
-- console.warn(`[Broadcast] Ignoring legacy viewer-joined for stream ${streamId} — SFU-only mode is active`);
-- break;
-- }
-- if (msg.peerId && ss.localStream) {
-- // Skip re-negotiation if the existing peer connection is still healthy
-- const existingPc = ss.viewerConnections.get(msg.peerId);
-- if (existingPc) {
-- const state = existingPc.iceConnectionState;
-- if (state === 'connected' || state === 'completed') {
-- console.log(`[Broadcast] Stream ${streamId} viewer ${msg.peerId} already connected (ICE: ${state}), skipping re-negotiate`);
-- break;
-- }
-- }
-- await createViewerConnection(streamId, msg.peerId);
-- }
-- break;
-- case 'viewer-left':
-- if (ss._allowP2pFallback && msg.peerId) closeViewerConnection(streamId, msg.peerId);
-- break;
-- case 'answer':
-- if (ss._allowP2pFallback) {
-- const pc = ss.viewerConnections.get(msg.fromPeerId);
-- if (pc && msg.sdp) await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
-- } else {
-- console.warn(`[Broadcast] Ignoring legacy answer for stream ${streamId} — SFU-only mode is active`);
-- }
-- break;
-- case 'ice-candidate':
-- if (ss._allowP2pFallback) {
-- const pc = ss.viewerConnections.get(msg.fromPeerId);
-- if (pc && msg.candidate) await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
-- } else {
-- console.warn(`[Broadcast] Ignoring legacy ice-candidate for stream ${streamId} — SFU-only mode is active`);
-- }
-- break;
case 'viewer-count':
_perStreamViewerCounts.set(streamId, msg.count || 0);
_updateTabViewerCount(streamId, msg.count || 0);
@@@ -4560,7 +4379,6 @@ async function handleSignalingMessage(streamId, msg) {
if (msg.iceServers && Array.isArray(msg.iceServers)) {
ss._serverIceServers = msg.iceServers;
}
-- ss._allowP2pFallback = !!msg.allowP2pFallback;
break;
case 'error': toast(msg.message || 'Broadcast error', 'error'); break;
@@@ -4989,10 +4807,6 @@ async function _switchActiveCamera(cameraId) {
const newTrack = newStream.getVideoTracks()[0];
ss.localStream.addTrack(newTrack);
-- for (const [, pc] of ss.viewerConnections) {
-- const sender = pc.getSenders().find(s => s.track === null || s.track?.kind === 'video');
-- if (sender) sender.replaceTrack(newTrack);
-- }
syncRobotStreamerTracks(broadcastState.activeStreamId).catch(() => {});
_syncSfuProducerTracks(broadcastState.activeStreamId).catch(() => {});
const preview = document.getElementById('bc-video-preview'); if (preview) preview.srcObject = ss.localStream;
@@@ -5014,10 +4828,6 @@ async function _switchActiveCamera(cameraId) {
const recovery = await _getUserMediaWithTimeout({ video: { deviceId: oldDeviceId ? { ideal: oldDeviceId } : undefined, facingMode: { ideal: oldSettings.facingMode || 'user' }, width: { ideal: trackWidth }, height: { ideal: trackHeight }, frameRate: { ideal: trackFrameRate } } });
const recoveryTrack = recovery.getVideoTracks()[0];
ss.localStream.addTrack(recoveryTrack);
-- for (const [, pc] of ss.viewerConnections) {
-- const sender = pc.getSenders().find(s => s.track === null || s.track?.kind === 'video');
-- if (sender) sender.replaceTrack(recoveryTrack);
-- }
syncRobotStreamerTracks(broadcastState.activeStreamId).catch(() => {});
_syncSfuProducerTracks(broadcastState.activeStreamId).catch(() => {});
const preview = document.getElementById('bc-video-preview'); if (preview) preview.srcObject = ss.localStream;
@@@ -5111,20 +4921,10 @@ async function toggleCameraOverlay() {
}
}
--/** Replace live tracks across all active outputs (legacy P2P only when enabled, RobotStreamer, and SFU producers). */
++/** Replace live tracks across all active outputs (RobotStreamer and SFU producers). */
function _replaceAllViewerTracks(streamId) {
const ss = getStreamState(streamId);
if (!ss || !ss.localStream) return;
-- const nvt = ss.localStream.getVideoTracks()[0];
-- const nat = ss.localStream.getAudioTracks()[0];
-- if (ss._allowP2pFallback) {
-- for (const [, pc] of ss.viewerConnections) {
-- const vs = pc.getSenders().find(s => s.track?.kind === 'video' || s.track === null);
-- if (vs && nvt) vs.replaceTrack(nvt);
-- const as = pc.getSenders().find(s => s.track?.kind === 'audio');
-- if (as && nat) as.replaceTrack(nat);
-- }
-- }
syncRobotStreamerTracks(streamId).catch(() => {});
// Also update the local SFU mediasoup producers so server-side consumers get the new tracks
_syncSfuProducerTracks(streamId).catch(() => {});
@@@ -5367,14 +5167,6 @@ function _republishAudioTrack(streamId, track) {
ss.robotStreamer?.audioProducer?.replaceTrack({ track }).catch((e) => console.warn('[RS Restream] audio replaceTrack failed:', e.message));
} catch { /* */ }
-- // Direct viewer peer connections
-- try {
-- ss.viewerConnections?.forEach((pc) => {
-- const sender = pc.getSenders?.().find(x => x.track && x.track.kind === 'audio');
-- sender?.replaceTrack(track).catch(() => {});
-- });
-- } catch { /* */ }
--
// VOD: MediaRecorder is bound to the tracks it was constructed with, so roll the
// segment. uploadVodRecording({finalizeStream:false}) flushes what has been captured
// so far without ending the recording server-side; startVodRecording() then opens a
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex 7aacb5e7..f49c70a2 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -253,7 +253,6 @@ function _collectPlayerRuntimeSnapshot() {
videoSize: video ? `${video.videoWidth || 0}x${video.videoHeight || 0}` : 'n/a',
signalingWs: player?.ws ? player.ws.readyState : 'n/a',
sfuTransport: player?._sfuRecvTransport?.connectionState || 'n/a',
-- legacyPc: player?.pc?.iceConnectionState || 'n/a',
trackSummary: tracks.length
? tracks.map((track) => `${track.kind}:${track.readyState}:muted=${track.muted}:enabled=${track.enabled}`).join(', ')
: 'none',
@@@ -283,7 +282,6 @@ function _buildPlayerDiagnosticsText() {
`video_size=${snapshot.videoSize}`,
`signaling_ws=${snapshot.signalingWs}`,
`sfu_transport=${snapshot.sfuTransport}`,
-- `legacy_pc=${snapshot.legacyPc}`,
`tracks=${snapshot.trackSummary}`,
`network_hint=${snapshot.networkHint}`,
`user_agent=${navigator.userAgent}`,
@@@ -1115,7 +1113,7 @@ function startJSMPEG(wsUrl, canvas, placeholder, bufferProfile = getJsmpegBuffer
async function initWebRTC(stream) {
const video = document.getElementById('video-element');
const placeholder = document.querySelector('.video-placeholder');
-- streamRef = stream; // set module-level ref for clip recording in handleViewerOffer
++ streamRef = stream; // set module-level ref for clip recording
try {
video.playsInline = true;
@@@ -1152,7 +1150,7 @@ async function initWebRTC(stream) {
const wsUrl = `${protocol}://${host}${portSuffix}/ws/broadcast?streamId=${streamRef.id}&role=viewer&token=${token}`;
const ws = new WebSocket(wsUrl);
-- player = { ws, video, pc: null, myPeerId: null, watchSent: false, _wsUrl: wsUrl, _serverIceServers: null };
++ player = { ws, video, myPeerId: null, watchSent: false, _wsUrl: wsUrl, _serverIceServers: null };
const _myGen = _playerGen; // this session's generation; stale once destroyPlayer bumps it
let _broadcasterDisconnectTimer = null;
let _viewerReconnectTimer = null;
@@@ -1212,9 +1210,6 @@ async function initWebRTC(stream) {
}, delay);
};
-- // Expose startWatchOfferTimeout on player so handleViewerOffer's triggerRewatch can use it
-- player._startWatchOfferTimeout = startWatchOfferTimeout;
--
ws.onopen = () => {
console.log('[Player] Broadcast signaling connected');
_updateStatus('Looking for the live feed', {
@@@ -1224,8 +1219,8 @@ async function initWebRTC(stream) {
showDiagnostics: true,
});
_viewerReconnectDelay = 3000; // reset backoff on successful connect
-- const pcState = player?.pc?.iceConnectionState;
-- if (!player.watchSent || pcState === 'failed' || pcState === 'disconnected' || pcState === 'closed') {
++ const sfuState = player?._sfuRecvTransport?.connectionState;
++ if (!player.watchSent || sfuState === 'failed' || sfuState === 'disconnected' || sfuState === 'closed') {
scheduleViewerRewatch(250);
}
};
@@@ -1240,7 +1235,6 @@ async function initWebRTC(stream) {
if (msg.iceServers && Array.isArray(msg.iceServers)) {
player._serverIceServers = msg.iceServers;
}
-- player._allowP2pFallback = !!msg.allowP2pFallback;
console.log('[Player] Welcome, peerId:', msg.peerId, 'iceServers:', (player._serverIceServers || []).length);
_updateStatus('Waiting for the live feed', {
phase: 'queue',
@@@ -1270,13 +1264,6 @@ async function initWebRTC(stream) {
// Hide reconnecting indicator if shown
_hideReconnectingIndicator();
// Skip re-negotiation if the existing media path is still healthy.
-- if (player.pc) {
-- const state = player.pc.iceConnectionState;
-- if (state === 'connected' || state === 'completed') {
-- console.log(`[Player] Peer connection still healthy (ICE: ${state}), skipping re-negotiate`);
-- break;
-- }
-- }
if (player._sfuRecvTransport) {
const state = player._sfuRecvTransport.connectionState;
if (state === 'connected') {
@@@ -1290,28 +1277,6 @@ async function initWebRTC(stream) {
player.watchSent = true;
startWatchOfferTimeout();
break;
-- case 'offer':
-- if (!player._allowP2pFallback) {
-- console.warn('[Player] Ignoring legacy P2P offer — SFU-only mode is active');
-- _pushPlayerDiagnostic('p2p.offer.ignored', 'Received unexpected offer while SFU-only mode was active');
-- break;
-- }
-- // Broadcaster sent us an offer (legacy P2P rollback path) — create answer
-- console.log('[Player] Received offer from broadcaster');
-- _updateStatus('Switching to the fallback path', {
-- phase: 'transport',
-- detail: 'Emergency P2P fallback is enabled for this session. Negotiating direct media.',
-- hint: 'This path should normally stay disabled. Copy the log if you were not expecting it.',
-- showDiagnostics: true,
-- });
-- // Clear the watch-to-offer timeout — offer received successfully
-- if (_watchOfferTimer) { clearTimeout(_watchOfferTimer); _watchOfferTimer = null; }
-- _rewatchCount = 0; // reset retry count on successful offer
-- _hideReconnectingIndicator();
-- // Clear any stale error overlay — we got a valid offer
-- _clearStreamError();
-- await handleViewerOffer(msg, player.ws, video);
-- break;
case 'sfu-viewer-ready':
// Server has SFU producers — use mediasoup-client RecvTransport
console.log('[Player] SFU viewer ready — starting mediasoup-client flow');
@@@ -1337,16 +1302,6 @@ async function initWebRTC(stream) {
_sfuViewerSetupInProgress = false;
}
break;
-- case 'ice-candidate':
-- if (!player._allowP2pFallback) {
-- console.warn('[Player] Ignoring legacy P2P ICE candidate — SFU-only mode is active');
-- break;
-- }
-- // ICE candidate from broadcaster
-- if (player.pc && msg.candidate) {
-- await player.pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
-- }
-- break;
case 'broadcaster-disconnected':
console.log('[Player] Broadcaster signaling disconnected — media may still be active');
_pushPlayerDiagnostic('signal.disconnect', 'Broadcaster signaling disconnected while viewer session remained open');
@@@ -1358,14 +1313,14 @@ async function initWebRTC(stream) {
if (_broadcasterDisconnectTimer) clearTimeout(_broadcasterDisconnectTimer);
_broadcasterDisconnectTimer = setTimeout(() => {
_broadcasterDisconnectTimer = null;
-- // Check if PeerConnection is still delivering media
-- const pcState = player?.pc?.iceConnectionState;
-- if (pcState === 'connected' || pcState === 'completed') {
-- // PC still working — don't show "ended", just log
-- console.log('[Player] Broadcaster signaling gone but PC still connected, waiting...');
++ // Check if the SFU receive transport is still delivering media
++ const sfuState = player?._sfuRecvTransport?.connectionState;
++ if (sfuState === 'connected') {
++ // Transport still working — don't show "ended", just log
++ console.log('[Player] Broadcaster signaling gone but SFU transport still connected, waiting...');
return;
}
-- console.log('[Player] Broadcaster did not reconnect and PC is dead, stream ended');
++ console.log('[Player] Broadcaster did not reconnect and SFU transport is dead, stream ended');
_hideReconnectingIndicator();
showStreamEnded();
}, 60000);
@@@ -1402,7 +1357,7 @@ async function initWebRTC(stream) {
_showReconnectingIndicator();
// Clear the SFU frozen checker — a new one will start after recovery
if (player._sfuFrozenInterval) { clearInterval(player._sfuFrozenInterval); player._sfuFrozenInterval = null; }
-- // Gentle poll: send watch every 10 s WITHOUT starting the P2P offer timeout.
++ // Gentle poll: send watch every 10 s WITHOUT starting the watch-response timeout.
// The server will respond with sfu-viewer-ready, sfu-source-unavailable, or watch-queued.
_viewerRewatchTimer = setTimeout(() => {
_viewerRewatchTimer = null;
@@@ -1490,308 +1445,9 @@ async function initWebRTC(stream) {
}
}
--async function handleViewerOffer(msg, ws, video) {
-- // Safety: player must be set by initWebRTC before we get here
-- if (!player) {
-- console.warn('[Player] handleViewerOffer called but player is null');
-- return;
-- }
-- // Close existing PC if re-negotiating — detach handlers first to prevent
-- // the old PC's 'closed' state from triggering a cascading re-watch loop
-- if (player.pc) {
-- const oldPc = player.pc;
-- oldPc.oniceconnectionstatechange = null;
-- oldPc.ontrack = null;
-- oldPc.onicecandidate = null;
-- try { oldPc.close(); } catch (ignored) {}
-- }
-- // Clear any pending stall/ICE timers from the previous connection
-- if (player._iceTimeout) { clearTimeout(player._iceTimeout); player._iceTimeout = null; }
-- if (player._stallTimer) { clearTimeout(player._stallTimer); player._stallTimer = null; }
-- if (player._playRetryTimer) { clearTimeout(player._playRetryTimer); player._playRetryTimer = null; }
--
-- // Use server-provided ICE servers (with TURN support) if available, else fallback to STUN-only
-- const iceServers = (player._serverIceServers && player._serverIceServers.length > 0)
-- ? player._serverIceServers
-- : [
-- { urls: 'stun:stun.l.google.com:19302' },
-- { urls: 'stun:stun1.l.google.com:19302' },
-- { urls: 'stun:stun2.l.google.com:19302' },
-- { urls: 'stun:stun3.l.google.com:19302' },
-- { urls: 'stun:stun4.l.google.com:19302' },
-- ];
--
-- const pc = new RTCPeerConnection({ iceServers });
-- player.pc = pc;
-- let _iceConnected = false;
-- let _hasVideoFrames = false;
-- let _playPending = false; // debounce play() across multiple ontrack events
--
-- // Schedules a re-watch if the current PC is still ours
-- const triggerRewatch = (reason) => {
-- if (!player || player.pc !== pc) return;
-- // Track ICE failures separately — these never reset (prevents infinite loop)
-- if (reason.includes('ICE') || reason.includes('stall') || reason.includes('SDP')) {
-- _totalIceFailures++;
-- console.log(`[Player] Re-watching: ${reason} (ICE failures: ${_totalIceFailures}/${MAX_ICE_FAILURES})`);
-- if (_totalIceFailures >= MAX_ICE_FAILURES) {
-- console.error('[Player] Too many ICE/connection failures — giving up');
-- showStreamError('Could not establish media connection. Your network may be blocking WebRTC traffic. Try a different network or refresh the page.');
-- return;
-- }
-- } else {
-- console.log(`[Player] Re-watching: ${reason}`);
-- }
-- player.watchSent = false;
-- sendPlayerSignal({ type: 'watch' });
-- player.watchSent = true;
-- // Start watch-to-offer timeout for this re-watch too
-- if (player._startWatchOfferTimeout) player._startWatchOfferTimeout();
-- };
--
-- // Debounced play() — always starts muted (guaranteed autoplay), then unmutes if allowed.
-- // Previous approach tried unmuted first, hit NotAllowedError, then retried muted — but the
-- // muted retry could also fail with AbortError if the first play() was still settling,
-- // leading to "Playback failed even muted" and no frames ever rendering.
-- const tryPlay = () => {
-- if (_playPending || !player || player.pc !== pc) return;
-- // Viewer deliberately paused — don't force playback on a reconnect.
-- if (viewerWantsPaused()) { try { video.pause(); } catch {} return; }
-- _playPending = true;
-- if (player._playRetryTimer) { clearTimeout(player._playRetryTimer); player._playRetryTimer = null; }
--
-- // Always start muted — this is the only way to guarantee autoplay across all browsers.
-- // We unmute after playback starts if the user hasn't muted.
-- video.muted = true;
-- video.volume = 1;
--
-- video.play().then(() => {
-- _playPending = false;
-- console.log('[Player] Playback started (muted autoplay)');
--
-- // Now try to unmute based on user prefs
-- const audioPrefs = getSavedPlayerAudioState();
-- if (!audioPrefs.muted) {
-- video.volume = Math.max(0.01, audioPrefs.volume);
-- video.muted = false;
-- // If unmuting fails (autoplay policy), show overlay
-- // Some browsers will pause on unmute — re-play muted if needed
-- const playPromise = video.play();
-- if (playPromise) {
-- playPromise.catch(() => {
-- video.muted = true;
-- video.play().catch(() => {});
-- showUnmuteOverlay(video);
-- });
-- }
-- } else {
-- video.muted = true;
-- video.volume = 0;
-- // Already muted — remove stale overlay if present
-- document.getElementById('unmute-overlay')?.remove();
-- }
-- }).catch((err) => {
-- _playPending = false;
-- if (err.name === 'AbortError') {
-- // play() interrupted — retry after a tick (second ontrack can cause this)
-- console.log('[Player] play() interrupted, retrying in 300ms');
-- player._playRetryTimer = setTimeout(() => {
-- player._playRetryTimer = null;
-- _playPending = false;
-- tryPlay();
-- }, 300);
-- } else {
-- console.warn('[Player] Muted play() failed:', err.name, err.message);
-- // Retry once after short delay — video element may not have enough data yet
-- player._playRetryTimer = setTimeout(() => {
-- player._playRetryTimer = null;
-- _playPending = false;
-- tryPlay();
-- }, 1000);
-- }
-- });
-- };
--
-- let _trackCount = 0; // count received tracks — only tryPlay after both video+audio arrive
--
-- pc.ontrack = (e) => {
-- _trackCount++;
-- console.log('[Player] Got remote track:', e.track.kind, `(${_trackCount} total)`);
-- if (e.streams && e.streams[0]) {
-- video.srcObject = e.streams[0];
-- startClipRecordingIfNeeded(e.streams[0], streamRef?.id);
-- } else {
-- let mediaStream = video.srcObject;
-- if (!mediaStream) {
-- mediaStream = new MediaStream();
-- video.srcObject = mediaStream;
-- }
-- mediaStream.addTrack(e.track);
-- startClipRecordingIfNeeded(mediaStream, streamRef?.id);
-- }
--
-- // Monitor remote track health — if it ends or mutes, trigger re-watch
-- const track = e.track;
-- track.addEventListener('ended', () => {
-- if (player?.pc !== pc) return;
-- console.warn(`[Player] Remote ${track.kind} track ended`);
-- triggerRewatch(`remote ${track.kind} track ended`);
-- }, { once: true });
-- track.addEventListener('mute', () => {
-- if (player?.pc !== pc) return;
-- console.warn(`[Player] Remote ${track.kind} track muted`);
-- // Give muted tracks a grace period — they may unmute on their own (e.g. track replacement)
-- setTimeout(() => {
-- if (player?.pc !== pc || !track.muted) return;
-- triggerRewatch(`remote ${track.kind} track stayed muted`);
-- }, 5000);
-- }, { once: true });
--
-- // Show the video element now that we have tracks
-- video.style.display = 'block';
--
-- // Register the playing event listener for EACH new PC (fresh _hasVideoFrames each time)
-- if (_trackCount === 1) {
-- const onPlaying = () => {
-- video.removeEventListener('playing', onPlaying);
-- _hasVideoFrames = true;
-- if (player._stallTimer) { clearTimeout(player._stallTimer); player._stallTimer = null; }
-- _hidePlayerPlaceholder();
-- // Only remove unmute overlay if video is actually playing with audio
-- if (!video.muted) {
-- document.getElementById('unmute-overlay')?.remove();
-- }
-- };
-- video.addEventListener('playing', onPlaying);
-- }
--
-- // Video stall detection — if no frames render within 8s of getting tracks, re-watch
-- if (!player._stallTimer && !_hasVideoFrames) {
-- player._stallTimer = setTimeout(() => {
-- player._stallTimer = null;
-- if (player?.pc !== pc || _hasVideoFrames) return;
-- // Check if video element is actually rendering
-- if (video.videoWidth === 0 || video.paused || video.readyState < 2) {
-- console.warn('[Player] Video stall detected — no frames after 8s');
-- triggerRewatch('video stall — no frames rendered');
-- }
-- }, 8000);
-- }
--
-- // Delay tryPlay until we have at least 2 tracks (video + audio) or 500ms
-- // after first track (handles audio-only/video-only streams).
-- // This prevents the first tryPlay() from racing with the second ontrack
-- // which causes AbortError on the first play() call.
-- if (_trackCount >= 2) {
-- tryPlay();
-- } else if (_trackCount === 1) {
-- player._playRetryTimer = setTimeout(() => {
-- player._playRetryTimer = null;
-- _playPending = false;
-- tryPlay();
-- }, 500);
-- }
-- };
--
-- pc.onicecandidate = (e) => {
-- // Use player.ws (not the passed ws param) so this works after WS reconnection
-- if (e.candidate) {
-- sendPlayerSignal({
-- type: 'ice-candidate',
-- candidate: e.candidate,
-- });
-- }
-- };
--
-- pc.oniceconnectionstatechange = () => {
-- // Ignore state changes from a stale (replaced) PC
-- if (!player || player.pc !== pc) return;
-- console.log('[Player] ICE state:', pc.iceConnectionState);
-- if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
-- _iceConnected = true;
-- if (player._iceTimeout) { clearTimeout(player._iceTimeout); player._iceTimeout = null; }
-- // If the stall timer is already running and no frames yet, restart it from ICE-connected
-- // time — ICE negotiation itself can take several seconds, so the 8s clock should start
-- // from when media can actually flow, not from when ontrack fired.
-- if (player._stallTimer && !_hasVideoFrames) {
-- clearTimeout(player._stallTimer);
-- player._stallTimer = setTimeout(() => {
-- player._stallTimer = null;
-- if (player?.pc !== pc || _hasVideoFrames) return;
-- if (video.videoWidth === 0 || video.paused || video.readyState < 2) {
-- console.warn('[Player] Video stall detected — no frames after ICE connected + 8s');
-- triggerRewatch('video stall — no frames after ICE connect');
-- }
-- }, 8000);
-- }
-- return;
-- }
-- if (pc.iceConnectionState === 'failed') {
-- if (player._iceTimeout) { clearTimeout(player._iceTimeout); player._iceTimeout = null; }
-- triggerRewatch('ICE failed');
-- return;
-- }
-- if (pc.iceConnectionState === 'disconnected') {
-- setTimeout(() => {
-- if (!player?.pc || player.pc !== pc) return;
-- const state = pc.iceConnectionState;
-- if (state === 'disconnected' || state === 'failed') {
-- triggerRewatch('ICE disconnected/failed after grace period');
-- }
-- }, 2500);
-- }
-- // 'disconnected' is transient and often recovers on its own — don't show error
-- // 'closed' is handled by detaching handlers before close — no cascading re-watch
-- };
--
-- // Wrap SDP operations in try/catch — retry on failure instead of silent black screen
-- try {
-- await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
-- const answer = await pc.createAnswer();
-- await pc.setLocalDescription(answer);
--
-- // Use player.ws (not the passed ws param) so this works after WS reconnection
-- sendPlayerSignal({
-- type: 'answer',
-- sdp: answer,
-- });
-- console.log('[Player] Sent answer to broadcaster');
-- } catch (sdpErr) {
-- console.error('[Player] SDP negotiation failed:', sdpErr);
-- // Close the failed PC and retry after a short delay
-- pc.oniceconnectionstatechange = null;
-- pc.ontrack = null;
-- try { pc.close(); } catch {}
-- if (player.pc === pc) player.pc = null;
-- setTimeout(() => triggerRewatch('SDP negotiation failed'), 2000);
-- return;
-- }
--
-- // ICE connection timeout — if not connected within 10s, something is stuck
-- player._iceTimeout = setTimeout(() => {
-- player._iceTimeout = null;
-- if (!player || player.pc !== pc || _iceConnected) return;
-- const state = pc.iceConnectionState;
-- if (state !== 'connected' && state !== 'completed') {
-- console.warn(`[Player] ICE timeout after 10s (state: ${state})`);
-- triggerRewatch('ICE connection timeout');
-- }
-- }, 10000);
--}
--
--/* ── SFU Viewer via mediasoup-client (replaces hand-built SDP) ── */
async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatch) {
if (!player) return;
-- // Clean up old PC if any (from P2P fallback or previous SFU attempt)
-- if (player.pc) {
-- const oldPc = player.pc;
-- oldPc.oniceconnectionstatechange = null;
-- oldPc.ontrack = null;
-- oldPc.onicecandidate = null;
-- try { oldPc.close(); } catch {}
-- player.pc = null;
-- }
if (player._iceTimeout) { clearTimeout(player._iceTimeout); player._iceTimeout = null; }
if (player._stallTimer) { clearTimeout(player._stallTimer); player._stallTimer = null; }
if (player._playRetryTimer) { clearTimeout(player._playRetryTimer); player._playRetryTimer = null; }
@@@ -2989,7 +2645,7 @@ function setupVideoControls() {
const audioPrefs = getSavedPlayerAudioState();
const savedVol = Math.round(audioPrefs.volume * 100);
volSlider.value = savedVol;
-- // For WebRTC, tryPlay() in handleViewerOffer sets volume + handles autoplay policy.
++ // For WebRTC (SFU), the player start path sets volume + handles autoplay policy.
// For JSMPEG, onSourceEstablished handles it. For HLS, set it here.
if (playerType === 'hls') {
setVolume(savedVol / 100, { muted: audioPrefs.muted });
@@@ -3757,7 +3413,6 @@ function destroyPlayer() {
if (player) {
if (playerType === 'jsmpeg' && player.destroy) player.destroy();
if (playerType === 'webrtc') {
-- if (player.pc) player.pc.close();
if (player.ws) player.ws.close();
}
if (playerType === 'hls') {
@@@ -3921,9 +3576,8 @@ function toggleStreamStats(force) {
}
function _getStatsSource() {
-- // Both RTCPeerConnection (legacy) and the mediasoup recv Transport expose getStats()
++ // The mediasoup recv Transport exposes getStats()
if (typeof player !== 'undefined' && player) {
-- if (player.pc && typeof player.pc.getStats === 'function') return player.pc;
if (player._sfuRecvTransport && typeof player._sfuRecvTransport.getStats === 'function') return player._sfuRecvTransport;
}
return null;
ddiff --git a/server/config.js b/server/config.js
iindex c013b902..ed5a1729 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -86,14 +86,10 @@ function buildConfig(registryValues) {
const rtmpEntry = getRegistryEntry('RTMP_HOST');
const turnEntry = getRegistryEntry('TURN_URL');
const turnUrl = normalizeTurnUrl(turnEntry.value || process.env.TURN_URL || DEFAULTS.TURN_URL);
-- const allowP2pFallback = normalizeValue(process.env.ALLOW_P2P_FALLBACK, 'boolean') === true;
if (turnEntry.source !== 'default' && !turnUrl) {
console.warn('[Config] Invalid TURN_URL configured; ICE metadata will be skipped. Expected a turn: or turns: URL with hostname and optional port/path.');
}
-- if (allowP2pFallback) {
-- console.warn('[Config] ALLOW_P2P_FALLBACK is enabled. Legacy peer-to-peer viewer relays may expose client IP addresses and should be used only for emergency rollback.');
-- }
const baseUrl = baseEntry.source !== 'default' ? baseEntry.value : DEFAULTS.BASE_URL;
// Derive the public URL for openvibe.network (the SSO/auth provider).
@@@ -185,7 +181,6 @@ function buildConfig(registryValues) {
username: process.env.TURN_USERNAME || '',
credential: process.env.TURN_CREDENTIAL || '',
},
-- allowP2pFallback,
mediasoup: {
listenIp: process.env.MEDIASOUP_LISTEN_IP || '0.0.0.0',
announcedIp: mediasoupAnnouncedIp,
ddiff --git a/server/streaming/broadcast-server.js b/server/streaming/broadcast-server.js
iindex e2344144..54f15ee5 100644
---- a/server/streaming/broadcast-server.js
++++ b/server/streaming/broadcast-server.js
@@@ -7,9 +7,9 @@
* 1. Broadcaster connects with JWT token + streamId
* 2. Broadcaster auto-publishes audio/video into the Mediasoup SFU
* 3. Viewer connects, sends 'watch', and is either queued or attached to the SFU
-- * 4. Legacy P2P relay messages are ignored unless ALLOW_P2P_FALLBACK is enabled
*
-- * Each stream has ONE broadcaster and MANY viewers.
++ * Each stream has ONE broadcaster and MANY viewers. The SFU (mediasoup) is the
++ * only media path — there is no direct peer-to-peer viewer relay.
* The server primarily orchestrates SFU transports and viewer queueing.
*/
const { EventEmitter } = require('events');
@@@ -101,7 +101,6 @@ class BroadcastServer extends EventEmitter {
type: 'watch-queued',
reason,
detail,
-- allowP2pFallback: !!config.allowP2pFallback,
});
this._logMetric('viewer.queued', {
streamId: client.streamId,
@@@ -327,7 +326,6 @@ class BroadcastServer extends EventEmitter {
streamId,
viewerCount: room.viewers.size,
iceServers: this._getIceServers(),
-- allowP2pFallback: !!config.allowP2pFallback,
});
// Broadcast viewer count
@@@ -361,24 +359,6 @@ class BroadcastServer extends EventEmitter {
if (!room) return;
switch (msg.type) {
-- case 'offer':
-- case 'answer':
-- case 'ice-candidate':
-- if (!config.allowP2pFallback) {
-- this._logMetric('p2p.relay.attempt', {
-- streamId: client.streamId,
-- peerId: client.peerId,
-- type: msg.type,
-- outcome: 'ignored',
-- });
-- console.warn(`[Broadcast] Ignoring legacy ${msg.type} for stream ${client.streamId} — SFU-only mode is active`);
-- break;
-- }
-- // For SFU viewers, ignore direct P2P signaling — mediasoup handles everything.
-- if (client._sfuViewerTransportId) break;
-- this.relaySignaling(ws, client, room, msg);
-- break;
--
case 'watch':
// Viewer requests to watch
if (client.role === 'viewer') {
@@@ -407,20 +387,6 @@ class BroadcastServer extends EventEmitter {
}
}).catch(err => {
console.error(`[Broadcast] SFU viewer error for ${client.peerId}:`, err.message);
-- if (config.allowP2pFallback && room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {
-- this._logMetric('p2p.relay.attempt', {
-- streamId: client.streamId,
-- peerId: client.peerId,
-- type: 'viewer-joined',
-- outcome: 'fallback',
-- });
-- this.safeSend(room.broadcaster, {
-- type: 'viewer-joined',
-- peerId: client.peerId,
-- });
-- return;
-- }
--
if (room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {
this._requestSfuProduceWarmup(room, client.streamId);
}
@@@ -525,46 +491,6 @@ class BroadcastServer extends EventEmitter {
}
}
-- relaySignaling(ws, client, room, msg) {
-- this._logMetric('p2p.relay.attempt', {
-- streamId: client.streamId,
-- peerId: client.peerId,
-- type: msg.type,
-- outcome: 'relayed',
-- });
-- if (client.role === 'broadcaster') {
-- // Broadcaster sending to a specific viewer
-- const targetPeerId = msg.targetPeerId;
-- if (targetPeerId && room.viewers.has(targetPeerId)) {
-- const viewerWs = room.viewers.get(targetPeerId);
-- if (viewerWs.readyState === WebSocket.OPEN) {
-- this.safeSend(viewerWs, {
-- type: msg.type,
-- sdp: msg.sdp,
-- candidate: msg.candidate,
-- fromPeerId: 'broadcaster',
-- });
-- } else {
-- console.warn(`[Broadcast] Cannot relay ${msg.type} to ${targetPeerId} — viewer WS not open (state: ${viewerWs.readyState})`);
-- }
-- } else if (targetPeerId) {
-- console.warn(`[Broadcast] Cannot relay ${msg.type} — viewer ${targetPeerId} not found in room (stream ${client.streamId})`);
-- }
-- } else {
-- // Viewer sending to broadcaster
-- if (room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {
-- this.safeSend(room.broadcaster, {
-- type: msg.type,
-- sdp: msg.sdp,
-- candidate: msg.candidate,
-- fromPeerId: client.peerId,
-- });
-- } else {
-- console.warn(`[Broadcast] Cannot relay ${msg.type} from viewer ${client.peerId} — broadcaster not connected (stream ${client.streamId})`);
-- }
-- }
-- }
--
handleDisconnect(ws) {
const client = this.clients.get(ws);
if (!client) return;
@@@ -617,14 +543,6 @@ class BroadcastServer extends EventEmitter {
this._cleanupSfuViewerTransport(client);
console.log(`[Broadcast] Viewer disconnected: stream ${client.streamId} (${client.peerId})`);
--
-- // Notify broadcaster only when legacy P2P fallback is explicitly enabled.
-- if (config.allowP2pFallback && room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {
-- this.safeSend(room.broadcaster, {
-- type: 'viewer-left',
-- peerId: client.peerId,
-- });
-- }
}
this.broadcastViewerCount(client.streamId);
@@@ -885,9 +803,9 @@ class BroadcastServer extends EventEmitter {
// True stale: ICE never connected or has definitively failed.
console.log(`[Broadcast] All ${allProducers.length} producer(s) are stale for stream ${client.streamId} — sending source-unavailable to ${client.peerId}`);
this.safeSend(ws, { type: 'sfu-source-unavailable', reason: 'ingest_stale' });
-- return true; // handled — do not fall through to P2P offer path
++ return true; // handled — source-unavailable already sent
}
-- console.log(`[Broadcast] No live producers for stream ${client.streamId} (${allProducers.length} total, all dead/disconnected) — falling back to P2P`);
++ console.log(`[Broadcast] No live SFU producers for stream ${client.streamId} (${allProducers.length} total, all dead/disconnected) — viewer will be queued`);
return false;
}
ddiff --git a/test/broadcast-audio-harmony.test.js b/test/broadcast-audio-harmony.test.js
iindex 92688ed3..df05dd5b 100644
---- a/test/broadcast-audio-harmony.test.js
++++ b/test/broadcast-audio-harmony.test.js
@@@ -4,7 +4,7 @@
*
* Every consumer of the broadcast audio binds to a track ONCE and keeps its own
* reference — MediaRecorder snapshots its tracks at construction, mediasoup Producers
-- * wrap a specific track, viewer RTCPeerConnection senders hold theirs. So the old
++ * wrap a specific track, the RobotStreamer relay holds its own. So the old
* removeTrack/addTrack dance on ss.localStream changed nothing for anyone: toggling the
* mic mid-stream left viewers hearing the previous track and the VOD recording it,
* silently, for the rest of the session.
@@@ -51,12 +51,11 @@ const republish = bc.slice(bc.indexOf('function _republishAudioTrack('));
for (const [what, re] of [
['the local SFU producer', /audioProducer\?\.replaceTrack\(\{ track \}\)/],
['the RobotStreamer producer', /robotStreamer\?\.audioProducer\?\.replaceTrack/],
-- ['viewer peer connections', /getSenders\?\.\(\)[\s\S]{0,120}replaceTrack\(track\)/],
['the VOD recorder', /uploadVodRecording\(streamId, \{ finalizeStream: false \}\)[\s\S]{0,120}startVodRecording\(streamId\)/],
]) {
assert.ok(re.test(republish), `_republishAudioTrack must update ${what}`);
}
--console.log('OK D: SFU, RobotStreamer, viewer PCs and the VOD segment are all updated on a real track change');
++console.log('OK D: the SFU, RobotStreamer and the VOD segment are all updated on a real track change');
// ── E: the mixer is torn down with the capture ───────────────────────────────────────
assert.ok(/_teardownAudioMixer\(ss\)/.test(bc), 'the mixer must be closed when capture stops');
ddiff --git a/test/p2p-fallback-ignored.test.js b/test/p2p-fallback-ignored.test.js
nnew file mode 100644
iindex 00000000..0d8fcc19
---- /dev/null
++++ b/test/p2p-fallback-ignored.test.js
@@@ -0,0 +1,70 @@
++'use strict';
++
++/**
++ * C-77 — ALLOW_P2P_FALLBACK is gone; the env name is inert.
++ *
++ * A broadcast must behave exactly the same whether the legacy variable is set
++ * or not. We prove that at two levels:
++ * 1. Building the server config with ALLOW_P2P_FALLBACK set to various values
++ * yields a config byte-identical to the default build, and no
++ * `allowP2pFallback` field exists to read.
++ * 2. No broadcast code path (server or client) names the variable at all, so
++ * every downstream decision is taken without it.
++ */
++
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const CONFIG_PATH = require.resolve('../server/config');
++
++function buildConfig(envValue) {
++ delete require.cache[CONFIG_PATH];
++ if (envValue === undefined) {
++ delete process.env.ALLOW_P2P_FALLBACK;
++ } else {
++ process.env.ALLOW_P2P_FALLBACK = envValue;
++ }
++ // JSON round-trip drops functions (refreshRegistry, _normalizeTurnUrl) so the
++ // snapshot is a pure comparison of the config values a broadcast would read.
++ return JSON.stringify(require('../server/config'));
++}
++
++const withoutFlag = buildConfig(undefined);
++delete process.env.ALLOW_P2P_FALLBACK; // leave the process clean for any later test
++
++assert.ok(
++ !Object.prototype.hasOwnProperty.call(JSON.parse(withoutFlag), 'allowP2pFallback'),
++ 'server config must not expose an allowP2pFallback field'
++);
++assert.strictEqual(
++ buildConfig('true'), withoutFlag,
++ 'config with ALLOW_P2P_FALLBACK=true must be identical to config without it'
++);
++assert.strictEqual(
++ buildConfig('1'), withoutFlag,
++ 'config with ALLOW_P2P_FALLBACK=1 must be identical to config without it'
++);
++assert.strictEqual(
++ buildConfig('false'), withoutFlag,
++ 'config with ALLOW_P2P_FALLBACK=false must be identical to config without it'
++);
++delete process.env.ALLOW_P2P_FALLBACK;
++
++// No broadcast code reads or emits the name — the variable cannot influence a broadcast.
++const SOURCES = [
++ '../server/config.js',
++ '../server/streaming/broadcast-server.js',
++ '../public/js/broadcast.js',
++ '../public/js/broadcast-state.js',
++ '../public/js/stream-player.js',
++];
++for (const rel of SOURCES) {
++ const src = fs.readFileSync(path.join(__dirname, rel), 'utf8');
++ assert.ok(
++ !src.includes('ALLOW_P2P_FALLBACK') && !src.includes('allowP2pFallback'),
++ `${rel} must not reference ALLOW_P2P_FALLBACK (it is ignored)`
++ );
++}
++
++console.log('OK: ALLOW_P2P_FALLBACK is ignored — a broadcast behaves the same with or without it');
ddiff --git a/test/sfu-viewer-stability.test.js b/test/sfu-viewer-stability.test.js
iindex e0fc3c3b..0e343adb 100644
---- a/test/sfu-viewer-stability.test.js
++++ b/test/sfu-viewer-stability.test.js
@@@ -11,9 +11,9 @@
* E. Client-side: 15s transport connect timeout + null-before-close cascade guard
* F. WHIP ICE disconnect: grace timer + explicit producer-removed emit in cleanupSession
* G. Broadcast-server: ICE-state filter + stale-source path + watch-queued message
-- * H. Client: sfu-source-unavailable and watch-queued handled without P2P offer timeout
++ * H. Client: sfu-source-unavailable and watch-queued handled without the watch-response timeout
* I. Client: frozen-video detector starts after play, escalates keyframe -> rebuild
-- * J. Broadcaster auto-publishes into SFU and legacy P2P is explicitly gated
++ * J. Broadcaster auto-publishes into SFU (the SFU is the only media path)
* K. Server startup logs TURN / announced-IP diagnostics for SFU viewers
*/
@@@ -159,8 +159,8 @@ assert.ok(
'broadcast-server.js must send watch-queued when viewer is added to pending queue'
);
assert.ok(
-- bcastSrc.includes('config.allowP2pFallback') && bcastSrc.includes('p2p.relay.attempt'),
-- 'broadcast-server.js must gate legacy P2P relay behind config.allowP2pFallback and log relay attempts'
++ !bcastSrc.includes('allowP2pFallback') && !bcastSrc.includes('p2p.relay.attempt') && !bcastSrc.includes('relaySignaling'),
++ 'broadcast-server.js must carry no legacy P2P relay path — the SFU is the only media path'
);
assert.ok(
bcastSrc.includes('viewer.queued') && bcastSrc.includes('viewer.notified'),
@@@ -234,24 +234,20 @@ assert.ok(
);
console.log('OK I: frozen-video detector nudges with a keyframe, then rebuilds the transport');
--// ── J: broadcaster auto-publishes into SFU, P2P rollback gated ──
++// ── J: broadcaster auto-publishes into SFU (SFU is the only media path) ──
assert.ok(
broadcastClientSrc.includes("_ensureSfuBroadcastReady(streamId, 'signaling-open')"),
'broadcast.js must auto-start SFU publishing when broadcaster signaling opens'
);
assert.ok(
-- broadcastClientSrc.includes('ss._allowP2pFallback = !!msg.allowP2pFallback'),
-- 'broadcast.js must track whether legacy P2P rollback is enabled'
++ !broadcastClientSrc.includes('_allowP2pFallback') && !broadcastClientSrc.includes('createViewerConnection'),
++ 'broadcast.js must carry no legacy P2P viewer-connection path — SFU-only'
);
assert.ok(
-- broadcastClientSrc.includes('SFU-only mode is active'),
-- 'broadcast.js must ignore legacy P2P viewer signaling while SFU-only mode is active'
++ !configSrc.includes('ALLOW_P2P_FALLBACK') && !configSrc.includes('allowP2pFallback'),
++ 'server/config.js must not expose the removed ALLOW_P2P_FALLBACK flag'
);
--assert.ok(
-- configSrc.includes('ALLOW_P2P_FALLBACK') && configSrc.includes('allowP2pFallback'),
-- 'server/config.js must expose the ALLOW_P2P_FALLBACK feature flag'
--);
--console.log('OK J: broadcaster auto-publishes into SFU and P2P rollback is explicitly gated');
++console.log('OK J: broadcaster auto-publishes into SFU — the SFU is the only media path');
// ── K: server startup TURN / announced-IP diagnostics ───────────
assert.ok(