From d2debac17fbecbc617421345b043673bb27f9077 Mon Sep 17 00:00:00 2001 From: Marcin Date: Mon, 14 Nov 2022 19:45:29 +0000 Subject: [PATCH] feat: added connection state handling (#8) also fixed various warnings that are currently on master --- cspell.json | 2 + src/connection-state-handler.spec.ts | 127 ++++++++++++++++++++++++++ src/connection-state-handler.ts | 118 ++++++++++++++++++++++++ src/event-emitter.ts | 4 +- src/media/local-track.ts | 7 +- src/mocks/mock.ts | 31 +++---- src/mocks/rtc-peer-connection-stub.ts | 2 + src/peer-connection-utils.ts | 2 +- src/peer-connection.spec.ts | 74 ++++++++++++++- src/peer-connection.ts | 59 +++++++++--- 10 files changed, 389 insertions(+), 37 deletions(-) create mode 100644 src/connection-state-handler.spec.ts create mode 100644 src/connection-state-handler.ts diff --git a/cspell.json b/cspell.json index 089aed0..9496ce3 100644 --- a/cspell.json +++ b/cspell.json @@ -32,6 +32,8 @@ "transpiled", "typedoc", "untracked", + "WCME", + "Wcme", "webrtc" ], "flagWords": [], diff --git a/src/connection-state-handler.spec.ts b/src/connection-state-handler.spec.ts new file mode 100644 index 0000000..b33d290 --- /dev/null +++ b/src/connection-state-handler.spec.ts @@ -0,0 +1,127 @@ +import { ConnectionState, ConnectionStateHandler } from './connection-state-handler'; + +describe('ConnectionStateHandler', () => { + let fakeIceState: RTCIceConnectionState; + let fakeConnectionState: RTCPeerConnectionState; + + // eslint-disable-next-line jsdoc/require-jsdoc + const fakeCallback = () => { + return { + connectionState: fakeConnectionState, + iceState: fakeIceState, + }; + }; + + beforeEach(() => { + fakeIceState = 'new'; + fakeConnectionState = 'new'; + }); + + it('reads initial connection state', () => { + expect.assertions(1); + const connStateHandler = new ConnectionStateHandler(fakeCallback); + + expect(connStateHandler.getConnectionState()).toStrictEqual(ConnectionState.New); + }); + + it('updates connection state on ice connection state change and emits the event', () => { + expect.assertions(2); + const connStateHandler = new ConnectionStateHandler(fakeCallback); + + connStateHandler.on(ConnectionStateHandler.Events.ConnectionStateChanged, (state) => { + expect(state).toStrictEqual(ConnectionState.Connecting); + }); + + fakeIceState = 'checking'; + connStateHandler.onIceConnectionStateChange(); + + expect(connStateHandler.getConnectionState()).toStrictEqual(ConnectionState.Connecting); + }); + + it("updates connection state on RTCPeerConnection's connection state change", () => { + expect.assertions(2); + const connStateHandler = new ConnectionStateHandler(fakeCallback); + + connStateHandler.on(ConnectionStateHandler.Events.ConnectionStateChanged, (state) => { + expect(state).toStrictEqual(ConnectionState.Connecting); + }); + + fakeConnectionState = 'connecting'; + connStateHandler.onConnectionStateChange(); + + expect(connStateHandler.getConnectionState()).toStrictEqual(ConnectionState.Connecting); + }); + + // test matrix for all possible combinations of iceConnectionState and connectionState + // some of these cases theoretically should never happen (like iceState: 'closed', connState: 'connected' ) + // but we test them anyway for completeness + const testCases: Array<{ + iceState: RTCIceConnectionState; + connState: RTCPeerConnectionState; + expected: ConnectionState; + }> = [ + { iceState: 'new', connState: 'new', expected: ConnectionState.New }, + { iceState: 'new', connState: 'connecting', expected: ConnectionState.Connecting }, + { iceState: 'new', connState: 'connected', expected: ConnectionState.Connecting }, + { iceState: 'new', connState: 'disconnected', expected: ConnectionState.Disconnected }, + { iceState: 'new', connState: 'failed', expected: ConnectionState.Failed }, + { iceState: 'new', connState: 'closed', expected: ConnectionState.Closed }, + + { iceState: 'checking', connState: 'new', expected: ConnectionState.Connecting }, + { iceState: 'checking', connState: 'connecting', expected: ConnectionState.Connecting }, + { iceState: 'checking', connState: 'connected', expected: ConnectionState.Connecting }, + { iceState: 'checking', connState: 'disconnected', expected: ConnectionState.Disconnected }, + { iceState: 'checking', connState: 'failed', expected: ConnectionState.Failed }, + { iceState: 'checking', connState: 'closed', expected: ConnectionState.Closed }, + + { iceState: 'connected', connState: 'new', expected: ConnectionState.Connecting }, + { iceState: 'connected', connState: 'connecting', expected: ConnectionState.Connecting }, + { iceState: 'connected', connState: 'connected', expected: ConnectionState.Connected }, + { iceState: 'connected', connState: 'disconnected', expected: ConnectionState.Disconnected }, + { iceState: 'connected', connState: 'failed', expected: ConnectionState.Failed }, + { iceState: 'connected', connState: 'closed', expected: ConnectionState.Closed }, + + { iceState: 'completed', connState: 'new', expected: ConnectionState.Connecting }, + { iceState: 'completed', connState: 'connecting', expected: ConnectionState.Connecting }, + { iceState: 'completed', connState: 'connected', expected: ConnectionState.Connected }, + { iceState: 'completed', connState: 'disconnected', expected: ConnectionState.Disconnected }, + { iceState: 'completed', connState: 'failed', expected: ConnectionState.Failed }, + { iceState: 'completed', connState: 'closed', expected: ConnectionState.Closed }, + + { iceState: 'failed', connState: 'new', expected: ConnectionState.Failed }, + { iceState: 'failed', connState: 'connecting', expected: ConnectionState.Failed }, + { iceState: 'failed', connState: 'connected', expected: ConnectionState.Failed }, + { iceState: 'failed', connState: 'disconnected', expected: ConnectionState.Failed }, + { iceState: 'failed', connState: 'failed', expected: ConnectionState.Failed }, + { iceState: 'failed', connState: 'closed', expected: ConnectionState.Closed }, + + { iceState: 'disconnected', connState: 'new', expected: ConnectionState.Disconnected }, + { iceState: 'disconnected', connState: 'connecting', expected: ConnectionState.Disconnected }, + { iceState: 'disconnected', connState: 'connected', expected: ConnectionState.Disconnected }, + { iceState: 'disconnected', connState: 'disconnected', expected: ConnectionState.Disconnected }, + { iceState: 'disconnected', connState: 'failed', expected: ConnectionState.Failed }, + { iceState: 'disconnected', connState: 'closed', expected: ConnectionState.Closed }, + + { iceState: 'closed', connState: 'new', expected: ConnectionState.Closed }, + { iceState: 'closed', connState: 'connecting', expected: ConnectionState.Closed }, + { iceState: 'closed', connState: 'connected', expected: ConnectionState.Closed }, + { iceState: 'closed', connState: 'disconnected', expected: ConnectionState.Closed }, + { iceState: 'closed', connState: 'failed', expected: ConnectionState.Closed }, + { iceState: 'closed', connState: 'closed', expected: ConnectionState.Closed }, + ]; + + testCases.forEach(({ iceState, connState, expected }) => + it(`evaluates overall state to ${expected} when iceConnectionState=${iceState} and connectionState=${connState}`, () => { + expect.assertions(1); + const connStateHandler = new ConnectionStateHandler(fakeCallback); + + fakeConnectionState = connState; + fakeIceState = iceState; + + // it's sufficient to trigger just one of the callbacks + connStateHandler.onConnectionStateChange(); + + expect(connStateHandler.getConnectionState()).toStrictEqual(expected); + }) + ); +}); diff --git a/src/connection-state-handler.ts b/src/connection-state-handler.ts new file mode 100644 index 0000000..1212f3c --- /dev/null +++ b/src/connection-state-handler.ts @@ -0,0 +1,118 @@ +import { EventEmitter, EventMap } from './event-emitter'; +import { logger } from './util/logger'; + +// Overall connection state (based on the ICE and DTLS connection states) +export enum ConnectionState { + New = 'New', // connection attempt has not been started + Closed = 'Closed', // connection closed, there is no way to move out of this state + Connected = 'Connected', // both ICE and DTLS connections are established, media is flowing + Connecting = 'Connecting', // initial connection attempt in progress + Disconnected = 'Disconnected', // connection lost temporarily, the browser is trying to re-establish it automatically + Failed = 'Failed', // connection failed, an ICE restart is required +} + +enum ConnectionStateEvents { + ConnectionStateChanged = 'ConnectionStateChanged', +} + +interface ConnectionStateEventHandlers extends EventMap { + [ConnectionStateEvents.ConnectionStateChanged]: (state: ConnectionState) => void; +} + +type GetCurrentStatesCallback = () => { + connectionState: RTCPeerConnectionState; + iceState: RTCIceConnectionState; +}; + +/** + * Listens on the connection's ICE and DTLS state changes and emits a single + * event that summarizes all the internal states into a single overall connection state. + */ +export class ConnectionStateHandler extends EventEmitter { + static Events = ConnectionStateEvents; + + private mediaConnectionState: ConnectionState; + + private getCurrentStatesCallback: GetCurrentStatesCallback; + + /** + * Creates an instance of ConnectionStateHandler. + * + * @param getCurrentStatesCallback - Callback for getting the connection state information + * from the peer connection. + */ + constructor(getCurrentStatesCallback: GetCurrentStatesCallback) { + super(); + this.getCurrentStatesCallback = getCurrentStatesCallback; + this.mediaConnectionState = this.evaluateMediaConnectionState(); + } + + /** + * Handler for connection state change. + */ + public onConnectionStateChange(): void { + this.handleAnyConnectionStateChange(); + } + + /** + * Handler for ice connection state change. + */ + public onIceConnectionStateChange(): void { + this.handleAnyConnectionStateChange(); + } + + /** + * Method to be called whenever ice connection or dtls connection state is changed. + */ + private handleAnyConnectionStateChange() { + const newConnectionState = this.evaluateMediaConnectionState(); + + if (newConnectionState !== this.mediaConnectionState) { + this.mediaConnectionState = newConnectionState; + this.emit(ConnectionStateEvents.ConnectionStateChanged, this.mediaConnectionState); + } + } + + /** + * Evaluates the overall connection state based on peer connection's + * connectionState and iceConnectionState. + * + * @returns Current overall connection state. + */ + private evaluateMediaConnectionState() { + const { connectionState, iceState } = this.getCurrentStatesCallback(); + + const connectionStates = [connectionState, iceState]; + + let mediaConnectionState; + + if (connectionStates.every((value) => value === 'new')) { + mediaConnectionState = ConnectionState.New; + } else if (connectionStates.some((value) => value === 'closed')) { + mediaConnectionState = ConnectionState.Closed; + } else if (connectionStates.some((value) => value === 'failed')) { + mediaConnectionState = ConnectionState.Failed; + } else if (connectionStates.some((value) => value === 'disconnected')) { + mediaConnectionState = ConnectionState.Disconnected; + } else if (connectionStates.every((value) => value === 'connected' || value === 'completed')) { + mediaConnectionState = ConnectionState.Connected; + } else { + mediaConnectionState = ConnectionState.Connecting; + } + + logger.log( + `iceConnectionState=${iceState} connectionState=${connectionState} => ${this.mediaConnectionState}` + ); + + return mediaConnectionState; + } + + /** + * Gets current connection state. + * + * @returns Current connection state. + */ + public getConnectionState(): ConnectionState { + return this.mediaConnectionState; + } +} diff --git a/src/event-emitter.ts b/src/event-emitter.ts index f55e5ce..53fddd2 100644 --- a/src/event-emitter.ts +++ b/src/event-emitter.ts @@ -4,6 +4,8 @@ import TypedEmitter, { EventMap } from 'typed-emitter'; /** * Typed event emitter class. */ -export default class EventEmitter extends (EE as { +export class EventEmitter extends (EE as { new (): TypedEmitter; }) {} + +export { EventMap } from 'typed-emitter'; diff --git a/src/media/local-track.ts b/src/media/local-track.ts index 0c2bccd..f45e2b3 100644 --- a/src/media/local-track.ts +++ b/src/media/local-track.ts @@ -1,5 +1,5 @@ /* eslint-disable no-underscore-dangle */ -import { EventMap } from 'typed-emitter'; +import { EventMap } from '../event-emitter'; import { MediaStreamTrackKind } from '../peer-connection'; import { logger } from '../util/logger'; import { Events, Track } from './track'; @@ -28,6 +28,11 @@ export interface LocalTrackEvents extends EventMap { [Events.PublishedStateUpdate]: (event: TrackPublishEvent) => void; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type TrackEffect = any; +// TBD: Fix this once types are published separately +// export type TrackEffect = BaseMicrophoneEffect | BaseCameraEffect; + /** * Basic Track class. Wrapper for LocalTrack from 'webrtc-core'. */ diff --git a/src/mocks/mock.ts b/src/mocks/mock.ts index 9c60e62..979ee0b 100644 --- a/src/mocks/mock.ts +++ b/src/mocks/mock.ts @@ -25,28 +25,23 @@ type MaybeMockedConstructor = T extends new (...args: any[]) => infer R ? jest.MockInstance> : T; -type MockedFunction = MockWithArgs & - { - [K in keyof T]: T[K]; - }; +type MockedFunction = MockWithArgs & { + [K in keyof T]: T[K]; +}; type MockedFunctionDeep = MockWithArgs & MockedObjectDeep; -type MockedObject = MaybeMockedConstructor & - { - [K in MethodKeysOf]: T[K] extends MockableFunction ? MockedFunction : T[K]; - } & - { - [K in PropertyKeysOf]: T[K]; - }; +type MockedObject = MaybeMockedConstructor & { + [K in MethodKeysOf]: T[K] extends MockableFunction ? MockedFunction : T[K]; +} & { + [K in PropertyKeysOf]: T[K]; +}; -type MockedObjectDeep = MaybeMockedConstructor & - { - [K in MethodKeysOf]: T[K] extends MockableFunction ? MockedFunctionDeep : T[K]; - } & - { - [K in PropertyKeysOf]: MaybeMockedDeep; - }; +type MockedObjectDeep = MaybeMockedConstructor & { + [K in MethodKeysOf]: T[K] extends MockableFunction ? MockedFunctionDeep : T[K]; +} & { + [K in PropertyKeysOf]: MaybeMockedDeep; +}; export type MaybeMockedDeep = T extends MockableFunction ? MockedFunctionDeep diff --git a/src/mocks/rtc-peer-connection-stub.ts b/src/mocks/rtc-peer-connection-stub.ts index edbd935..ec08d48 100644 --- a/src/mocks/rtc-peer-connection-stub.ts +++ b/src/mocks/rtc-peer-connection-stub.ts @@ -10,6 +10,8 @@ class RTCPeerConnectionStub { createOffer(options?: RTCOfferOptions): Promise { return new Promise(() => {}); } + onconnectionstatechange: () => void = () => {}; + oniceconnectionstatechange: () => void = () => {}; } /** diff --git a/src/peer-connection-utils.ts b/src/peer-connection-utils.ts index 1fecc0d..f3351a3 100644 --- a/src/peer-connection-utils.ts +++ b/src/peer-connection-utils.ts @@ -24,7 +24,7 @@ export function getLocalDescriptionWithIceCandidates( } }; peerConnection.on(PeerConnection.Events.IceGatheringStateChange, (e) => { - if (e.target.iceGatheringState === 'complete') { + if ((e.target as RTCPeerConnection).iceGatheringState === 'complete') { getLocalDescAndResolve(); } // TODO(brian): throw an error if we see an error iceGatheringState diff --git a/src/peer-connection.spec.ts b/src/peer-connection.spec.ts index ee860ce..f651a7f 100644 --- a/src/peer-connection.spec.ts +++ b/src/peer-connection.spec.ts @@ -1,10 +1,12 @@ -import { createRTCPeerConnection } from './rtc-peer-connection-factory'; -import { PeerConnection } from './peer-connection'; -import { RTCPeerConnectionStub } from './mocks/rtc-peer-connection-stub'; +import { ConnectionState, ConnectionStateHandler } from './connection-state-handler'; import { mocked } from './mocks/mock'; +import { RTCPeerConnectionStub } from './mocks/rtc-peer-connection-stub'; +import { PeerConnection } from './peer-connection'; +import { createRTCPeerConnection } from './rtc-peer-connection-factory'; jest.mock('./mocks/rtc-peer-connection-stub'); jest.mock('./rtc-peer-connection-factory'); +jest.mock('./connection-state-handler'); const mockCreateRTCPeerConnection = mocked(createRTCPeerConnection, true); @@ -29,4 +31,70 @@ describe('PeerConnection', () => { expect(mockPc.createOffer.mock.calls).toHaveLength(1); expect(mockPc.createOffer.mock.calls[0][0]).toStrictEqual(createOfferOptions); }); + + describe('connection state handling', () => { + let mockPc: RTCPeerConnectionStub; + let pc: PeerConnection; + + beforeEach(() => { + mockPc = mocked(new RTCPeerConnectionStub(), true); + mockCreateRTCPeerConnection.mockReturnValueOnce(mockPc as unknown as RTCPeerConnection); + pc = new PeerConnection(); + }); + + /** + * Gets the instance of ConnectionStateHandler that was created by the PeerConnection + * under test. + * + * @returns Instance of ConnectionStateHandler. + */ + const getInstantiatedConnectionStateHandler = () => + (ConnectionStateHandler as unknown as jest.Mock).mock.instances[0]; + + it('instantiates ConnectionStateHandler', () => { + expect.hasAssertions(); + expect(ConnectionStateHandler).toHaveBeenCalledTimes(1); + }); + it("forwards RTCPeerConnection's ice connection event to ConnectionStateHandler", () => { + expect.hasAssertions(); + const connectionStateHandler = getInstantiatedConnectionStateHandler(); + + mockPc.oniceconnectionstatechange(); + + expect(connectionStateHandler.onIceConnectionStateChange).toHaveBeenCalledTimes(1); + }); + it("forwards RTCPeerConnection's connection event to ConnectionStateHandler", () => { + expect.hasAssertions(); + const connectionStateHandler = getInstantiatedConnectionStateHandler(); + + mockPc.onconnectionstatechange(); + + expect(connectionStateHandler.onConnectionStateChange).toHaveBeenCalledTimes(1); + }); + it('returns connection state from connection state handler when geConnectionState() is called', () => { + expect.assertions(2); + const connectionStateHandler = getInstantiatedConnectionStateHandler(); + connectionStateHandler.getConnectionState.mockReturnValueOnce(ConnectionState.Connected); + + expect(pc.getConnectionState()).toStrictEqual(ConnectionState.Connected); + expect(connectionStateHandler.getConnectionState).toHaveBeenCalledTimes(1); + }); + it("listens on ConnectionStateHandler's ConnectionStateChange event and emits it", () => { + expect.assertions(2); + const connectionStateHandler = getInstantiatedConnectionStateHandler(); + + pc.on(PeerConnection.Events.ConnectionStateChange, (state) => { + expect(state).toStrictEqual(ConnectionState.Connecting); + }); + + // verify that PeerConnection listens for the right event + expect(connectionStateHandler.on.mock.calls[0][0]).toStrictEqual( + ConnectionStateHandler.Events.ConnectionStateChanged + ); + + // trigger the fake event from ConnectionStateHandler + const connectionStateHandlerListener = connectionStateHandler.on.mock.calls[0][1]; + connectionStateHandlerListener(ConnectionState.Connecting); + }); + }); }); diff --git a/src/peer-connection.ts b/src/peer-connection.ts index fceabbe..731d612 100644 --- a/src/peer-connection.ts +++ b/src/peer-connection.ts @@ -1,5 +1,5 @@ -import { EventMap } from 'typed-emitter'; -import EventEmitter from './event-emitter'; +import { ConnectionState, ConnectionStateHandler } from './connection-state-handler'; +import { EventEmitter, EventMap } from './event-emitter'; import { createRTCPeerConnection } from './rtc-peer-connection-factory'; import { logger } from './util/logger'; /** @@ -19,28 +19,30 @@ type RTCDataChannelOptions = { id?: number; }; -enum IceEvents { - IceGatheringStateChange = 'icegatheringstatechange', -} - type IceGatheringStateChangeEvent = { - target: any; + target: EventTarget | null; }; -export interface PeerConnectionEvents extends EventMap { - [IceEvents.IceGatheringStateChange]: (ev: IceGatheringStateChangeEvent) => void; +enum PeerConnectionEvents { + IceGatheringStateChange = 'icegatheringstatechange', + ConnectionStateChange = 'connectionstatechange', +} + +interface PeerConnectionEventHandlers extends EventMap { + [PeerConnectionEvents.IceGatheringStateChange]: (ev: IceGatheringStateChangeEvent) => void; + [PeerConnectionEvents.ConnectionStateChange]: (state: ConnectionState) => void; } /** * Manages a single RTCPeerConnection with the server. */ -class PeerConnection extends EventEmitter { - static Events = { - IceGatheringStateChange: IceEvents.IceGatheringStateChange, - }; +class PeerConnection extends EventEmitter { + static Events = PeerConnectionEvents; private pc: RTCPeerConnection; + private connectionStateHandler: ConnectionStateHandler; + /** * Creates an instance of the RTCPeerConnection. */ @@ -50,6 +52,28 @@ class PeerConnection extends EventEmitter { this.pc = createRTCPeerConnection(); + this.connectionStateHandler = new ConnectionStateHandler(() => { + return { + connectionState: this.pc.connectionState, + iceState: this.pc.iceConnectionState, + }; + }); + + this.connectionStateHandler.on( + ConnectionStateHandler.Events.ConnectionStateChanged, + (state: ConnectionState) => { + this.emit(PeerConnection.Events.ConnectionStateChange, state); + } + ); + + // Forward the connection state related events to connection state handler + // eslint-disable-next-line jsdoc/require-jsdoc + this.pc.oniceconnectionstatechange = () => + this.connectionStateHandler.onIceConnectionStateChange(); + + // eslint-disable-next-line jsdoc/require-jsdoc + this.pc.onconnectionstatechange = () => this.connectionStateHandler.onConnectionStateChange(); + // Subscribe to underlying PeerConnection events and emit them via the EventEmitter /* eslint-disable jsdoc/require-jsdoc */ this.pc.onicegatheringstatechange = (ev: Event) => { @@ -66,6 +90,15 @@ class PeerConnection extends EventEmitter { return this.pc; } + /** + * Gets the overall connection state of the underlying RTCPeerConnection. + * + * @returns The underlying connection's overall state. + */ + getConnectionState(): ConnectionState { + return this.connectionStateHandler.getConnectionState(); + } + /** * Adds a new media track to the set of tracks which will be transmitted to the other peer. *