Skip to content

feat: RTCRtpSender/Receiver.transport + selectedcandidatepairchange#47

Open
oliverlaz wants to merge 6 commits into
masterfrom
feat/rtp-transport-selectedcandidatepairchange
Open

feat: RTCRtpSender/Receiver.transport + selectedcandidatepairchange#47
oliverlaz wants to merge 6 commits into
masterfrom
feat/rtp-transport-selectedcandidatepairchange

Conversation

@oliverlaz

@oliverlaz oliverlaz commented Jun 11, 2026

Copy link
Copy Markdown
Member

What

Adds a partial implementation of the W3C transport chain so consumers can observe ICE candidate-pair changes:

const t = pc.getSenders()[0].transport;   // RTCDtlsTransport (=== receiver.transport)
t.iceTransport.onselectedcandidatepairchange = () => {
  const { local, remote } = t.iceTransport.getSelectedCandidatePair() ?? {};
};
t.iceTransport.onstatechange = () => console.log(t.iceTransport.state);
t.onstatechange = () => console.log(t.state);

New surface:

  • RTCRtpSender.transport / RTCRtpReceiver.transportRTCDtlsTransport
  • RTCDtlsTransport: iceTransport, state, statechange event
  • RTCIceTransport: state, gatheringState, getSelectedCandidatePair(), and selectedcandidatepairchange / statechange / gatheringstatechange events

Why / design

The end goal is the selectedcandidatepairchange event. The underlying WebRTC binaries (iOS StreamWebRTC, Android stream-video-webrtc-android, both 145.x) do not expose RTCDtlsTransport/RTCIceTransport objects or a sender.transport getter — so a faithful native-backed implementation isn't possible without patching the binaries.

However, the candidate-pair change data is available at the peer-connection level on both platforms. And because Stream always uses BUNDLE, there is exactly one ICE/DTLS transport per RTCPeerConnection. So:

  • Each RTCPeerConnection owns a single RTCDtlsTransport + RTCIceTransport pair, injected into every sender/receiver via transport.
  • A new peer-connection-level native event peerConnectionSelectedCandidatePairChanged feeds the ICE transport, sourced from:
    • iOS: RTCPeerConnectionDelegate didChangeLocalCandidate:remoteCandidate:lastReceivedMs:changeReason:
    • Android: PeerConnection.Observer.onSelectedCandidatePairChanged(CandidatePairChangeEvent)
  • state / gatheringState are best-effort, derived from the connection's iceConnectionState / iceGatheringState / connectionState, and fire their corresponding change events on transition.

Known limitation: only the single bundled transport is modeled. If bundling is disabled (multiple m-line transports), all senders/receivers still report the one shared transport. Acceptable — Stream always bundles.

Verification

  • npm run lint (eslint --max-warnings 0 + tsc --noEmit) passes — the JS CI gate.
  • ✅ Fork-preservation check clean (no google-webrtc / webrtc-ios refs).
  • ✅ Codex review: no actionable defects.
  • Native example-app build not yet run (cold npm install + pod install required) — reviewers/CI should confirm the iOS/Android example builds compile.

Also includes the project CLAUDE.md (separate docs: commit).

Summary by CodeRabbit

Release Notes

  • New Features

    • Added RTCDtlsTransport and RTCIceTransport classes, including state and event support (statechange, ICE gathering, and selected-candidate-pair changes).
    • Added peerConnectionSelectedCandidatePairChanged support to reflect the active ICE candidate pair.
    • RTCRtpSender and RTCRtpReceiver now expose their associated DTLS transport via a transport getter.
  • Documentation

    • Added CLAUDE.md with repo guidance, architecture overview, build/release instructions, and verification steps for iOS/Android.

…eTransport

Expose the W3C transport chain sender.transport -> RTCDtlsTransport.iceTransport
-> RTCIceTransport, surfacing the selectedcandidatepairchange event (plus
statechange / gatheringstatechange). Stream always uses BUNDLE, so one ICE/DTLS
transport pair is shared per RTCPeerConnection.

The native binaries don't expose transport objects, so the transports are thin
JS wrappers fed by a new peer-connection-level native event
(peerConnectionSelectedCandidatePairChanged), sourced from iOS
didChangeLocalCandidate:remoteCandidate:lastReceivedMs:changeReason: and Android
PeerConnection.Observer.onSelectedCandidatePairChanged.
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@oliverlaz, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 17 minutes and 11 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9b71a737-81f0-4bd9-bf2d-98ce29cbd3e4

📥 Commits

Reviewing files that changed from the base of the PR and between 3684d45 and 061b693.

📒 Files selected for processing (5)
  • src/RTCDtlsTransport.ts
  • src/RTCIceTransport.ts
  • src/RTCPeerConnection.ts
  • src/RTCRtpReceiver.ts
  • src/RTCRtpSender.ts
📝 Walkthrough

Walkthrough

This PR implements W3C-compliant ICE and DTLS transport abstractions for React Native WebRTC, exposing transport state and lifecycle through structured classes while wiring native candidate-pair-change events from Android and iOS into a unified JavaScript event system. Configuration and build tooling are updated accordingly, and comprehensive project documentation guides future development.

Changes

ICE and DTLS Transport State Management

Layer / File(s) Summary
Native selected candidate pair change events
android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java, ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m, ios/RCTWebRTC/WebRTCModule.h, ios/RCTWebRTC/WebRTCModule.m, src/EventEmitter.ts
Android and iOS now emit peerConnectionSelectedCandidatePairChanged events when ICE candidate pairs change. Both platforms serialize ICE candidates and metadata; JavaScript registers the native event for re-emission through the event emitter.
RTCIceTransport abstraction
src/RTCIceTransport.ts
New W3C-style RTCIceTransport class tracks ICE state, gathering state, and selected candidate pairs via getters and event handlers (onselectedcandidatepairchange, onstatechange, ongatheringstatechange). Internal methods (_setState, _setGatheringState, _setSelectedCandidatePair) update state and dispatch events only on transitions, comparing candidate pairs to avoid redundant dispatches.
RTCDtlsTransport abstraction
src/RTCDtlsTransport.ts
New RTCDtlsTransport wraps an RTCIceTransport and tracks DTLS state independently, exposing iceTransport and state getters with onstatechange event handling.
RTCPeerConnection transport lifecycle and state management
src/RTCPeerConnection.ts
RTCPeerConnection creates and maintains shared RTCIceTransport and RTCDtlsTransport instances, wires them to all RTP senders and receivers during offer/answer processing, addTrack, and addTransceiver. Native events update transport state: peerConnectionIceConnectionChanged → ICE state, peerConnectionSelectedCandidatePairChanged → selected pair, peerConnectionStateChanged → DTLS state (derived from connection state), and peerConnectionIceGatheringChanged → gathering state.
Transport exposure in RTP senders and receivers
src/RTCRtpSender.ts, src/RTCRtpReceiver.ts
RTCRtpSender and RTCRtpReceiver now accept optional DTLS transport in constructors and expose them via public transport getters.
Public module exports and global registration
src/index.ts
RTCIceTransport and RTCDtlsTransport are exported from the main module and registered on the global object for consumer access.

Documentation and Build Configuration

Layer / File(s) Summary
Repository guidelines and architecture documentation
CLAUDE.md
Comprehensive guidance documenting the hard-forked WebRTC package, CI verification commands, three-layer architecture, native-to-JS event flow synchronization requirements, Stream-specific customization rules, upstream-sync mechanics, and semantic-release automation.
Build tooling and TypeScript compilation updates
package.json, tsconfig.json
Dependencies updated (base64-js, debug, react-native-builder-bob to ^0.43.0); scripts.build added for bob build. TypeScript target and lib changed to es2022.

🎯 3 (Moderate) | ⏱️ ~28 minutes


🐰 A bridge built from code to state,
Where ICE candidates migrate with care,
DTLS transport holds the fate,
Of WebRTC streams floating through the air,
Events flow and state updates celebrate!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and concisely describes the main feature added: the transport property for RTP sender/receiver and the selectedcandidatepairchange event implementation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/rtp-transport-selectedcandidatepairchange

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

The shared RTCDtlsTransport/RTCIceTransport model assumes one transport
per connection (max-bundle, which Stream uses). Warn once if more than one
ICE transport name (candidate sdpMid / transport_name) is observed via
selectedcandidatepairchange, and document the limitation.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/RTCPeerConnection.ts (1)

772-779: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate native state values before mutating transport wrapper states.

ev is any here, so unknown native values can leak invalid transport state (notably at Line 826 where DTLS_TRANSPORT_STATE[ev.connectionState] can be undefined).
Please gate native states before calling _setState/_setGatheringState and warn on unknown values.

Suggested fix
 addListener(this, 'peerConnectionIceConnectionChanged', (ev: any) => {
@@
-    this.iceConnectionState = ev.iceConnectionState;
-    this._iceTransport._setState(ev.iceConnectionState);
+    this.iceConnectionState = ev.iceConnectionState;
+    if (
+        ev.iceConnectionState === 'new' ||
+        ev.iceConnectionState === 'checking' ||
+        ev.iceConnectionState === 'connected' ||
+        ev.iceConnectionState === 'completed' ||
+        ev.iceConnectionState === 'disconnected' ||
+        ev.iceConnectionState === 'failed' ||
+        ev.iceConnectionState === 'closed'
+    ) {
+        this._iceTransport._setState(ev.iceConnectionState);
+    } else {
+        log.warn(`${this._pcId} unexpected iceConnectionState: ${String(ev.iceConnectionState)}`);
+    }
@@
 addListener(this, 'peerConnectionStateChanged', (ev: any) => {
@@
-    this.connectionState = ev.connectionState;
-    this._dtlsTransport._setState(DTLS_TRANSPORT_STATE[ev.connectionState]);
+    this.connectionState = ev.connectionState;
+    const dtlsState = DTLS_TRANSPORT_STATE[ev.connectionState as RTCPeerConnectionState];
+    if (dtlsState) {
+        this._dtlsTransport._setState(dtlsState);
+    } else {
+        log.warn(`${this._pcId} unexpected connectionState: ${String(ev.connectionState)}`);
+    }
@@
 addListener(this, 'peerConnectionIceGatheringChanged', (ev: any) => {
@@
-    this.iceGatheringState = ev.iceGatheringState;
-    this._iceTransport._setGatheringState(ev.iceGatheringState);
+    this.iceGatheringState = ev.iceGatheringState;
+    if (
+        ev.iceGatheringState === 'new' ||
+        ev.iceGatheringState === 'gathering' ||
+        ev.iceGatheringState === 'complete'
+    ) {
+        this._iceTransport._setGatheringState(ev.iceGatheringState);
+    } else {
+        log.warn(`${this._pcId} unexpected iceGatheringState: ${String(ev.iceGatheringState)}`);
+    }

Also applies to: 820-827, 914-921

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/RTCPeerConnection.ts` around lines 772 - 779, The callback added via
addListener handling native events (the one that sets this.iceConnectionState
and calls this._iceTransport._setState, plus similar handlers that call
_setGatheringState and use DTLS_TRANSPORT_STATE[ev.connectionState]) must
validate incoming native state values before mutating transport wrapper state:
check that ev is defined and that ev.iceConnectionState / ev.connectionState /
ev.gatheringState map to known enum values (e.g., membership in
DTLS_TRANSPORT_STATE or the allowed ICE states) and only call
this._iceTransport._setState or _setGatheringState when the mapped value is not
undefined; if a value is unknown, log a warning identifying the event and the
raw value instead of calling the setter. Ensure the same guarding logic is
applied to the other similar handlers (the blocks around lines referenced and
where DTLS_TRANSPORT_STATE[...] is used).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java`:
- Around line 341-353: The Android observer currently forwards event.reason
directly (in onSelectedCandidatePairChanged) which sends null to JS; make it
consistent with iOS by normalizing a null reason to an empty string before
calling params.putString — inside the ThreadUtils.runOnExecutor lambda in
onSelectedCandidatePairChanged, replace the direct event.reason usage with a
normalized value (e.g., reason = event.reason == null ? "" : event.reason) and
pass that to params.putString so
webRTCModule.sendEvent("peerConnectionSelectedCandidatePairChanged", params)
behaves the same as the iOS implementation.

---

Outside diff comments:
In `@src/RTCPeerConnection.ts`:
- Around line 772-779: The callback added via addListener handling native events
(the one that sets this.iceConnectionState and calls
this._iceTransport._setState, plus similar handlers that call _setGatheringState
and use DTLS_TRANSPORT_STATE[ev.connectionState]) must validate incoming native
state values before mutating transport wrapper state: check that ev is defined
and that ev.iceConnectionState / ev.connectionState / ev.gatheringState map to
known enum values (e.g., membership in DTLS_TRANSPORT_STATE or the allowed ICE
states) and only call this._iceTransport._setState or _setGatheringState when
the mapped value is not undefined; if a value is unknown, log a warning
identifying the event and the raw value instead of calling the setter. Ensure
the same guarding logic is applied to the other similar handlers (the blocks
around lines referenced and where DTLS_TRANSPORT_STATE[...] is used).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8caa1b56-a551-4051-a876-31c45f1703ab

📥 Commits

Reviewing files that changed from the base of the PR and between 6d2509d and a50c943.

📒 Files selected for processing (12)
  • CLAUDE.md
  • android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java
  • ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m
  • ios/RCTWebRTC/WebRTCModule.h
  • ios/RCTWebRTC/WebRTCModule.m
  • src/EventEmitter.ts
  • src/RTCDtlsTransport.ts
  • src/RTCIceTransport.ts
  • src/RTCPeerConnection.ts
  • src/RTCRtpReceiver.ts
  • src/RTCRtpSender.ts
  • src/index.ts

Comment on lines +341 to +353
@Override
public void onSelectedCandidatePairChanged(CandidatePairChangeEvent event) {
ThreadUtils.runOnExecutor(() -> {
WritableMap params = Arguments.createMap();
params.putInt("pcId", id);
params.putMap("local", serializeIceCandidate(event.local));
params.putMap("remote", serializeIceCandidate(event.remote));
params.putInt("lastDataReceivedMs", event.lastDataReceivedMs);
params.putString("reason", event.reason);

webRTCModule.sendEvent("peerConnectionSelectedCandidatePairChanged", params);
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Cross-platform reason field inconsistency.

Both implementations handle the changeReason/reason parameter differently when it is null:

  • Android (PeerConnectionObserver.java:349): Passes event.reason directly via putString, which sends null to JavaScript when the native value is null.
  • iOS (WebRTCModule+RTCPeerConnection.m:922): Uses reason ?: @"", converting nil to an empty string before sending to JavaScript.

JavaScript consumers will receive null on Android and "" on iOS for the same missing-reason scenario. For consistent cross-platform behavior, both should either pass null or use the same fallback string.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java`
around lines 341 - 353, The Android observer currently forwards event.reason
directly (in onSelectedCandidatePairChanged) which sends null to JS; make it
consistent with iOS by normalizing a null reason to an empty string before
calling params.putString — inside the ThreadUtils.runOnExecutor lambda in
onSelectedCandidatePairChanged, replace the direct event.reason usage with a
normalized value (e.g., reason = event.reason == null ? "" : event.reason) and
pass that to params.putString so
webRTCModule.sendEvent("peerConnectionSelectedCandidatePairChanged", params)
behaves the same as the iOS implementation.

getSelectedCandidatePair() must return either null or a pair with both
members populated, and selectedcandidatepairchange must fire only on an
actual pair change. Native can emit a transient event with one side
unresolved, and re-emit the same pair as lastDataReceivedMs ticks, so:

- drop events where either candidate is null (never expose, or clobber an
  existing pair with, a half-populated pair)
- de-dupe against the current pair so the event fires only on real changes
- tighten RTCIceCandidatePair members to non-nullable RTCIceCandidate

Also bump build tooling/deps: react-native-builder-bob 0.18.2 -> ^0.43.0,
debug 4.3.4 -> ^4.4.3, base64-js -> ^1.5.1, tsconfig target/lib es6 ->
es2022, with the regenerated lockfile.
The shared ICE/DTLS transport pair faithfully represents a connection only
when it negotiates a single transport, i.e. bundlePolicy: 'max-bundle'.
Previously the transport was created for every connection and a runtime
warning fired only *after* the shared selected-pair had already been
overwritten, so on a non-max-bundle connection every sender/receiver's
transport.iceTransport could report a candidate pair belonging to another
path.

Gate exposure structurally instead of warning: create _iceTransport /
_dtlsTransport only when bundlePolicy is 'max-bundle'. Otherwise leave them
null, so sender/receiver `transport` returns null (spec-legal) rather than a
possibly-wrong transport. Drop the now-redundant _seenTransportNames /
_multiTransportWarned detection and update the transport docs accordingly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants