From 32f926c911ce673357b6218cc9b6648d378eb77e Mon Sep 17 00:00:00 2001 From: Balazs Kreith Date: Fri, 7 Aug 2026 03:35:01 +0000 Subject: [PATCH] fix: read TURN server url from local candidate and register TURN servers - usingTURN / selectedIceCandiadtePairForTurn read the local candidate's url (remote candidates never carry one per W3C webrtc-stats), and usingTURN no longer requires the url at all - candidateType 'relay' suffices; the url only gates TURN-server attribution. 'turns:' (TLS) urls now match too. - ObservedTURN.addPeerConnection: derive the server key by stripping the url query instead of new URL() (WHATWG URL parses turn: schemes as opaque paths with empty hostname), register the created server in this.servers, and register the peer connection on the server so update()/removePeerConnection actually work. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RPdM2dcBs16j2uhz2aLenY --- src/ObservedPeerConnection.ts | 26 +++++++++++++++++++------- src/ObservedTURN.ts | 19 +++++++++++++------ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/ObservedPeerConnection.ts b/src/ObservedPeerConnection.ts index b999fc8..a262ab6 100644 --- a/src/ObservedPeerConnection.ts +++ b/src/ObservedPeerConnection.ts @@ -225,11 +225,15 @@ export class ObservedPeerConnection extends EventEmitter { } public get selectedIceCandiadtePairForTurn() { + // The ICE server `url` is only exposed on *local* candidates (it identifies the server + // the candidate was obtained from); remote candidates never carry it. `turn` also + // matches `turns:` (TURN over TLS). return this.selectedIceCandidatePairs - .filter((pair) => - pair.getLocalCandidate()?.candidateType === 'relay' && - pair.getRemoteCandidate()?.url?.startsWith('turn:') - ); + .filter((pair) => { + const localCandidate = pair.getLocalCandidate(); + + return localCandidate?.candidateType === 'relay' && localCandidate?.url?.startsWith('turn') === true; + }); } public close() { @@ -539,12 +543,20 @@ export class ObservedPeerConnection extends EventEmitter { this.usingTURN = false; for (const selectedCandidatePair of selectedIceCandidatePairs) { - if (selectedCandidatePair.getLocalCandidate()?.protocol === 'tcp') { + const localCandidate = selectedCandidatePair.getLocalCandidate(); + + if (localCandidate?.protocol === 'tcp') { this.usingTCP = true; } - if (selectedCandidatePair.getLocalCandidate()?.candidateType === 'relay' && selectedCandidatePair.getRemoteCandidate()?.url?.startsWith('turn:')) { - selectedCandidatePairForTurn.push(selectedCandidatePair); + // relay candidates are only obtained from TURN servers, so the local candidate's + // type alone establishes TURN usage. The server `url` is only exposed on *local* + // candidates (and not by every browser) — it is required only to attribute the + // traffic to a concrete TURN server. + if (localCandidate?.candidateType === 'relay') { this.usingTURN = true; + if (localCandidate.url?.startsWith('turn')) { + selectedCandidatePairForTurn.push(selectedCandidatePair); + } } this.deltaTransportReceivedBytes += selectedCandidatePair.deltaBytesReceived; this.deltaTransportSentBytes += selectedCandidatePair.deltaBytesSent; diff --git a/src/ObservedTURN.ts b/src/ObservedTURN.ts index a8b6bd4..ce7a004 100644 --- a/src/ObservedTURN.ts +++ b/src/ObservedTURN.ts @@ -53,27 +53,34 @@ export class ObservedTURN extends EventEmitter { } public addPeerConnection(peerConnection: ObservedPeerConnection) { - const turnPairs = peerConnection.selectedIceCandidatePairs.filter((pair) => pair.getLocalCandidate()?.candidateType === 'relay' && pair.getRemoteCandidate()?.url?.startsWith('turn:')); + // selected pairs whose *local* candidate is a relay candidate with a `turn(s):` url + // (the ICE server url is only exposed on local candidates). + const turnPairs = peerConnection.selectedIceCandiadtePairForTurn; if (turnPairs.length !== 1) { return (logger.warn(`Expected exactly one TURN pair, but found for peerconnection ${peerConnection.peerConnectionId}`, turnPairs.length), undefined); } const candidatePair = turnPairs[0]; - const rawUrl = candidatePair.getRemoteCandidate()?.url; + const rawUrl = candidatePair.getLocalCandidate()?.url; if (!rawUrl) { - return (logger.warn(`No remote candidate URL found for peerconnection ${peerConnection.peerConnectionId}`), undefined); + return (logger.warn(`No local candidate URL found for peerconnection ${peerConnection.peerConnectionId}`), undefined); } - const turnUrl = new URL(rawUrl); - const turnServerUrl = `${turnUrl.protocol}//${turnUrl.hostname}:${turnUrl.port}`; + // A TURN url looks like `turn(s):host[:port][?transport=udp|tcp]` (RFC 7065). The WHATWG + // `URL` class parses these non-special schemes as opaque paths (empty hostname/port), so + // derive the server key by stripping the query instead of using `new URL()`. + const turnServerUrl = rawUrl.split('?')[0]; let turnServer = this.servers.get(turnServerUrl); - + if (!turnServer) { turnServer = new ObservedTurnServer(turnServerUrl, this); + this.servers.set(turnServerUrl, turnServer); } + turnServer.observedPeerConnections.set(peerConnection.peerConnectionId, peerConnection); + return turnServer; }