Skip to content

Commit

Permalink
feat: added connection state handling (#8)
Browse files Browse the repository at this point in the history
also fixed various warnings that are currently on master
  • Loading branch information
marcin-bazyl authored Nov 14, 2022
1 parent 09fbff8 commit 0c7c7e0
Show file tree
Hide file tree
Showing 10 changed files with 386 additions and 39 deletions.
2 changes: 2 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"transpiled",
"typedoc",
"untracked",
"WCME",
"Wcme",
"webrtc"
],
"flagWords": [],
Expand Down
127 changes: 127 additions & 0 deletions src/connection-state-handler.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
})
);
});
118 changes: 118 additions & 0 deletions src/connection-state-handler.ts
Original file line number Diff line number Diff line change
@@ -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<ConnectionStateEventHandlers> {
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;
}
}
4 changes: 3 additions & 1 deletion src/event-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import TypedEmitter, { EventMap } from 'typed-emitter';
/**
* Typed event emitter class.
*/
export default class EventEmitter<T extends EventMap> extends (EE as {
export class EventEmitter<T extends EventMap> extends (EE as {
new <TT extends EventMap>(): TypedEmitter<TT>;
})<T> {}

export { EventMap } from 'typed-emitter';
6 changes: 3 additions & 3 deletions src/media/local-track.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/* eslint-disable no-underscore-dangle */
import { EventMap } from 'typed-emitter';
import EventEmitter from '../event-emitter';
import { EventEmitter, EventMap } from '../event-emitter';
import { MediaStreamTrackKind } from '../peer-connection';
import { logger } from '../util/logger';

Expand Down Expand Up @@ -47,8 +46,9 @@ export interface TrackEvents extends EventMap {
[LocalTrackEvents.UnderlyingTrackChange]: () => void;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type TrackEffect = any;
// TBD: Fix this once types are published seperatly
// TBD: Fix this once types are published separately
// export type TrackEffect = BaseMicrophoneEffect | BaseCameraEffect;

/**
Expand Down
31 changes: 13 additions & 18 deletions src/mocks/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,23 @@ type MaybeMockedConstructor<T> = T extends new (...args: any[]) => infer R
? jest.MockInstance<R, ConstructorArgumentsOf<T>>
: T;

type MockedFunction<T extends MockableFunction> = MockWithArgs<T> &
{
[K in keyof T]: T[K];
};
type MockedFunction<T extends MockableFunction> = MockWithArgs<T> & {
[K in keyof T]: T[K];
};

type MockedFunctionDeep<T extends MockableFunction> = MockWithArgs<T> & MockedObjectDeep<T>;

type MockedObject<T> = MaybeMockedConstructor<T> &
{
[K in MethodKeysOf<T>]: T[K] extends MockableFunction ? MockedFunction<T[K]> : T[K];
} &
{
[K in PropertyKeysOf<T>]: T[K];
};
type MockedObject<T> = MaybeMockedConstructor<T> & {
[K in MethodKeysOf<T>]: T[K] extends MockableFunction ? MockedFunction<T[K]> : T[K];
} & {
[K in PropertyKeysOf<T>]: T[K];
};

type MockedObjectDeep<T> = MaybeMockedConstructor<T> &
{
[K in MethodKeysOf<T>]: T[K] extends MockableFunction ? MockedFunctionDeep<T[K]> : T[K];
} &
{
[K in PropertyKeysOf<T>]: MaybeMockedDeep<T[K]>;
};
type MockedObjectDeep<T> = MaybeMockedConstructor<T> & {
[K in MethodKeysOf<T>]: T[K] extends MockableFunction ? MockedFunctionDeep<T[K]> : T[K];
} & {
[K in PropertyKeysOf<T>]: MaybeMockedDeep<T[K]>;
};

export type MaybeMockedDeep<T> = T extends MockableFunction
? MockedFunctionDeep<T>
Expand Down
2 changes: 2 additions & 0 deletions src/mocks/rtc-peer-connection-stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ class RTCPeerConnectionStub {
createOffer(options?: RTCOfferOptions): Promise<RTCSessionDescriptionInit> {
return new Promise(() => {});
}
onconnectionstatechange: () => void = () => {};
oniceconnectionstatechange: () => void = () => {};
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/peer-connection-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 0c7c7e0

Please sign in to comment.