Skip to content

feat: RTCRtpSender/Receiver.transport + selectedcandidatepairchange#47

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

feat: RTCRtpSender/Receiver.transport + selectedcandidatepairchange#47
oliverlaz wants to merge 4 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 for managing media transport connections.
    • Added support for selected ICE candidate pair change events to track active connections.
    • RTP senders and receivers now expose their underlying DTLS transport for transport inspection.
  • Documentation

    • Added comprehensive development guide covering setup, architecture, build process, and release workflow.

…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 40 minutes and 25 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ 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.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4140aa24-f0e6-4654-a937-57a47bb89212

📥 Commits

Reviewing files that changed from the base of the PR and between a50c943 and 7870dec.

📒 Files selected for processing (1)
  • package.json
📝 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.

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. Android and iOS implementations serialize ice candidates and metadata; JavaScript registers the native event for re-emission.
RTCIceTransport and RTCDtlsTransport abstractions
src/RTCIceTransport.ts, src/RTCDtlsTransport.ts
New W3C-style transport classes expose ICE state, gathering state, and selected candidate pairs via getters and event handlers. RTCDtlsTransport wraps an RTCIceTransport and tracks DTLS state independently.
RTCPeerConnection transport lifecycle and state management
src/RTCPeerConnection.ts
RTCPeerConnection creates and manages shared RTCIceTransport and RTCDtlsTransport instances, wires them to all RTP senders and receivers during offer/answer, addTrack, and addTransceiver operations, and updates transport state when native events fire (ICE connection, selected pair, connection state, and gathering state changes).
Transport exposure in RTP senders and receivers
src/RTCRtpSender.ts, src/RTCRtpReceiver.ts
RTCRtpSender and RTCRtpReceiver now accept optional DTLS transport in their 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.

Project Documentation

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 event flow synchronization requirements, Stream-specific customization rules, upstream-sync mechanics, and semantic-release automation.

🎯 3 (Moderate) | ⏱️ ~25 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 PR title accurately summarizes the main changes: adding RTCRtpSender/Receiver.transport property and selectedcandidatepairchange event support across the codebase.
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.

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.

1 participant