diff --git a/src/mocks/rtc-peer-connection-stub.ts b/src/mocks/rtc-peer-connection-stub.ts index ec08d48..6d950d9 100644 --- a/src/mocks/rtc-peer-connection-stub.ts +++ b/src/mocks/rtc-peer-connection-stub.ts @@ -10,6 +10,9 @@ class RTCPeerConnectionStub { createOffer(options?: RTCOfferOptions): Promise { return new Promise(() => {}); } + getStats(): Promise { + return new Promise(() => {}); + } onconnectionstatechange: () => void = () => {}; oniceconnectionstatechange: () => void = () => {}; } diff --git a/src/peer-connection.spec.ts b/src/peer-connection.spec.ts index f651a7f..e2ad793 100644 --- a/src/peer-connection.spec.ts +++ b/src/peer-connection.spec.ts @@ -10,7 +10,155 @@ jest.mock('./connection-state-handler'); const mockCreateRTCPeerConnection = mocked(createRTCPeerConnection, true); +// eslint-disable-next-line jsdoc/require-jsdoc +function constructCandidatePairStats(id: string, localCandidateId: string, state: string) { + return { + id: `${id}`, + timestamp: 1671091266890.878, + type: 'candidate-pair', + transportId: 'T11', + localCandidateId: `${localCandidateId}`, + remoteCandidateId: 'I+UUFv24B', + state: `${state}`, + }; +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function constructLocalCandidateStats( + id: string, + protocol: string, + relayProtocol: string | undefined +) { + return { + id, + timestamp: 1671091266890.878, + type: 'local-candidate', + transportId: 'T21', + isRemote: false, + networkType: 'vpn', + ip: '2001:420:c0c8:1005::456', + address: '2001:420:c0c8:1005::456', + port: 53906, + protocol, + candidateType: 'host', + priority: 2122197247, + relayProtocol, + }; +} + describe('PeerConnection', () => { + describe('getCurrentConnectionType', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const mockPc = mocked(new RTCPeerConnectionStub(), true); + Object.defineProperty(mockPc, 'iceConnectionState', { + value: 'connected', + writable: true, + configurable: true, + }); + + it('normal case', async () => { + expect.hasAssertions(); + mockCreateRTCPeerConnection.mockReturnValueOnce(mockPc as unknown as RTCPeerConnection); + mockPc.getStats.mockImplementation(() => { + return new Promise((resolve) => { + resolve([ + constructCandidatePairStats('001', 'localCandidateId1', 'succeeded'), + constructCandidatePairStats('002', 'localCandidateId2', 'succeeded'), + constructLocalCandidateStats('localCandidateId1', 'udp', undefined), + constructLocalCandidateStats('localCandidateId1', 'udp', undefined), + ]); + }); + }); + const pc = new PeerConnection(); + const connectionType = await pc.getCurrentConnectionType(); + expect(connectionType).toBe('UDP'); + }); + it('first candidate pair state is not succeeded', async () => { + expect.hasAssertions(); + mockCreateRTCPeerConnection.mockReturnValueOnce(mockPc as unknown as RTCPeerConnection); + mockPc.getStats.mockImplementation(() => { + return new Promise((resolve) => { + resolve([ + constructCandidatePairStats('001', 'localCandidateId1', 'failed'), + constructCandidatePairStats('002', 'localCandidateId2', 'succeeded'), + constructLocalCandidateStats('localCandidateId2', 'udp', undefined), + constructLocalCandidateStats('localCandidateId1', 'udp', undefined), + ]); + }); + }); + const pc = new PeerConnection(); + const connectionType = await pc.getCurrentConnectionType(); + expect(connectionType).toBe('UDP'); + }); + it('no candidate matched', async () => { + expect.hasAssertions(); + mockCreateRTCPeerConnection.mockReturnValueOnce(mockPc as unknown as RTCPeerConnection); + mockPc.getStats.mockImplementation(() => { + return new Promise((resolve) => { + resolve([ + constructCandidatePairStats('001', 'localCandidateId1', 'failed'), + constructCandidatePairStats('002', 'localCandidateId2', 'succeeded'), + constructLocalCandidateStats('localCandidateId1', 'udp', undefined), + constructLocalCandidateStats('localCandidateId1', 'udp', undefined), + ]); + }); + }); + const pc = new PeerConnection(); + const connectionType = await pc.getCurrentConnectionType(); + expect(connectionType).toBe('unknown'); + }); + it('no candidate matched caused by all failed', async () => { + expect.hasAssertions(); + mockCreateRTCPeerConnection.mockReturnValueOnce(mockPc as unknown as RTCPeerConnection); + mockPc.getStats.mockImplementation(() => { + return new Promise((resolve) => { + resolve([ + constructCandidatePairStats('001', 'localCandidateId1', 'failed'), + constructCandidatePairStats('002', 'localCandidateId2', 'failed'), + constructLocalCandidateStats('localCandidateId2', 'udp', undefined), + constructLocalCandidateStats('localCandidateId2', 'udp', undefined), + ]); + }); + }); + const pc = new PeerConnection(); + const connectionType = await pc.getCurrentConnectionType(); + expect(connectionType).toBe('unknown'); + }); + it('relay candidate case', async () => { + expect.hasAssertions(); + mockCreateRTCPeerConnection.mockReturnValueOnce(mockPc as unknown as RTCPeerConnection); + mockPc.getStats.mockImplementation(() => { + return new Promise((resolve) => { + resolve([ + constructCandidatePairStats('001', 'localCandidateId1', 'failed'), + constructCandidatePairStats('002', 'localCandidateId2', 'succeeded'), + constructLocalCandidateStats('localCandidateId1', 'udp', 'tls'), + constructLocalCandidateStats('localCandidateId2', 'udp', 'tls'), + ]); + }); + }); + const pc = new PeerConnection(); + const connectionType = await pc.getCurrentConnectionType(); + expect(connectionType).toBe('TURN-TLS'); + }); + + it('ice state is not connected/completed', async () => { + expect.hasAssertions(); + Object.defineProperty(mockPc, 'iceConnectionState', { + // eslint-disable-next-line jsdoc/require-jsdoc + get() { + return 'disconnected'; + }, + }); + mockCreateRTCPeerConnection.mockReturnValueOnce(mockPc as unknown as RTCPeerConnection); + const pc = new PeerConnection(); + await expect(pc.getCurrentConnectionType()).rejects.toThrow( + 'Ice connection is not established' + ); + }); + }); it('should pass the correct options through when calling createOffer', async () => { expect.hasAssertions(); const mockPc = mocked(new RTCPeerConnectionStub(), true); diff --git a/src/peer-connection.ts b/src/peer-connection.ts index 188ed93..c100345 100644 --- a/src/peer-connection.ts +++ b/src/peer-connection.ts @@ -33,6 +33,7 @@ interface PeerConnectionEventHandlers extends EventMap { [PeerConnectionEvents.ConnectionStateChange]: (state: ConnectionState) => void; } +type ConnectionType = 'UDP' | 'TCP' | 'TURN-TLS' | 'TURN-TCP' | 'TURN-UDP' | 'unknown'; /** * Manages a single RTCPeerConnection with the server. */ @@ -285,6 +286,45 @@ class PeerConnection extends EventEmitter { get iceGatheringState(): RTCIceGathererState { return this.pc.iceGatheringState; } + + /** + * Returns the type of a connection that has been established. + * + * @returns The connection type which would be `ConnectionType`. + */ + async getCurrentConnectionType(): Promise { + // make sure this method only can be called when the ice connection is established; + const isIceConnected = + this.pc.iceConnectionState === 'connected' || this.pc.iceConnectionState === 'completed'; + if (!isIceConnected) { + throw new Error('Ice connection is not established'); + } + const succeededLocalCandidateIds = new Set(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const localCandidateStatsReports: any[] = []; + + (await this.pc.getStats()).forEach((report) => { + // collect all local candidate ids from `candidate-pair` stats reports with `succeeded` state. + if (report.type === 'candidate-pair' && report.state?.toLowerCase() === 'succeeded') { + succeededLocalCandidateIds.add(report.localCandidateId); + } + // collect all `local-candidate` stats. + if (report.type === 'local-candidate') { + localCandidateStatsReports.push(report); + } + }); + // find the `local-candidate` stats which report id contains in `succeededLocalCandidateIds`. + const localCandidate = localCandidateStatsReports.find((report) => + succeededLocalCandidateIds.has(report.id) + ); + if (!localCandidate) { + return 'unknown'; + } + if (localCandidate.relayProtocol) { + return `TURN-${localCandidate.relayProtocol.toUpperCase()}` as ConnectionType; + } + return localCandidate.protocol?.toUpperCase(); + } } export { ConnectionState } from './connection-state-handler';