Created
September 30, 2019 02:40
-
-
Save kdxu/fe53a76e5db7a9eb3fe8af185e99091a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* @private */ | |
import { traceLog, getVideoCodecsFromString, removeCodec, browser } from '../utils'; | |
import { ConnectionOptions, VideoCodecOption } from './options'; | |
/** | |
* @ignore | |
*/ | |
interface AyameRegisterMessage { | |
type: string; | |
roomId: string; | |
clientId: string; | |
key?: string; | |
authnMetadata?: Record<string, any>; | |
} | |
interface RTCPeerConnectionStatic { | |
new(configuration?: RTCConfiguration, constraints?: any): RTCPeerConnection; | |
} | |
interface Window { | |
RTCPeerConnection: RTCPeerConnectionStatic; | |
} | |
declare let window: Window; | |
/** | |
* @ignore | |
*/ | |
class ConnectionBase { | |
debug: boolean; | |
roomId: string; | |
signalingUrl: string; | |
options: ConnectionOptions; | |
connectionState: string; | |
stream: MediaStream | null; | |
remoteStream: MediaStream | null; | |
authnMetadata: Record<string, any> | null; | |
authzMetadata: Record<string, any> | null; | |
_ws: WebSocket | null; | |
_pc: RTCPeerConnection | null; | |
_callbacks: any; | |
_removeCodec: boolean; | |
_isOffer: boolean; | |
_dataChannels: Array<RTCDataChannel>; | |
_pcConfig: { | |
iceServers: Array<RTCIceServer>; | |
iceTransportPolicy: RTCIceTransportPolicy; | |
}; | |
/** | |
* @ignore | |
*/ | |
on(kind: string, callback: Function): void { | |
if (kind in this._callbacks) { | |
this._callbacks[kind] = callback; | |
} | |
} | |
constructor(signalingUrl: string, roomId: string, options: ConnectionOptions, debug = false, isRelay = false) { | |
this.debug = debug; | |
this.roomId = roomId; | |
this.signalingUrl = signalingUrl; | |
this.options = options; | |
this._removeCodec = false; | |
this.stream = null; | |
this.remoteStream = null; | |
this._pc = null; | |
this._ws = null; | |
this.authnMetadata = null; | |
this.authzMetadata = null; | |
this._dataChannels = []; | |
this._isOffer = false; | |
this.connectionState = 'new'; | |
this._pcConfig = { | |
iceServers: this.options.iceServers, | |
iceTransportPolicy: isRelay ? 'relay' : 'all' | |
}; | |
this._callbacks = { | |
open: () => {}, | |
connect: () => {}, | |
disconnect: () => {}, | |
addstream: () => {}, | |
removestream: () => {}, | |
data: () => {} | |
}; | |
} | |
async _disconnect(): Promise<void> { | |
await this._dataChannels.forEach(async (dataChannel: RTCDataChannel) => { | |
await this._closeDataChannel(dataChannel); | |
}); | |
await this._closePeerConnection(); | |
await this._closeWebSocketConnection(); | |
this.authzMetadata = null; | |
this._ws = null; | |
this._pc = null; | |
this._removeCodec = false; | |
this._isOffer = false; | |
this._dataChannels = []; | |
this.connectionState = 'new'; | |
} | |
async _signaling(): Promise<void> { | |
return new Promise((resolve, reject) => { | |
if (this._ws) { | |
return reject('WS-ALREADY-EXISTS'); | |
} | |
this._ws = new WebSocket(this.signalingUrl); | |
this._ws.onclose = async () => { | |
await this._disconnect(); | |
return reject('WS-CLOSED'); | |
}; | |
this._ws.onerror = async () => { | |
await this._disconnect(); | |
return reject('WS-CLOSED-WITH-ERROR'); | |
}; | |
this._ws.onopen = () => { | |
const registerMessage: AyameRegisterMessage = { | |
type: 'register', | |
roomId: this.roomId, | |
clientId: this.options.clientId, | |
authnMetadata: undefined, | |
key: undefined | |
}; | |
if (this.authnMetadata !== null) { | |
registerMessage.authnMetadata = this.authnMetadata; | |
} | |
if (this.options.signalingKey !== null) { | |
registerMessage.key = this.options.signalingKey; | |
} | |
this._sendWs(registerMessage); | |
if (this._ws) { | |
this._ws.onmessage = async (event: MessageEvent) => { | |
try { | |
if (typeof event.data !== 'string') { | |
return; | |
} | |
const message = JSON.parse(event.data); | |
if (message.type === 'ping') { | |
this._sendWs({ type: 'pong' }); | |
} else if (message.type === 'close') { | |
this._callbacks.close(event); | |
} else if (message.type === 'accept') { | |
this.authzMetadata = message.authzMetadata; | |
if (Array.isArray(message.iceServers) && message.iceServers.length > 0) { | |
this._traceLog('iceServers=>', message.iceServers); | |
this._pcConfig.iceServers = message.iceServers; | |
} | |
if (!this._pc) this._createPeerConnection(); | |
await this._sendOffer(); | |
return resolve(); | |
} else if (message.type === 'reject') { | |
await this._disconnect(); | |
this._callbacks.disconnect({ reason: message.reason || 'REJECTED' }); | |
return reject('REJECTED'); | |
} else if (message.type === 'offer') { | |
this._setOffer(new RTCSessionDescription(message)); | |
} else if (message.type === 'answer') { | |
await this._setAnswer(new RTCSessionDescription(message)); | |
} else if (message.type === 'candidate') { | |
if (message.ice) { | |
this._traceLog('Received ICE candidate ...', message.ice); | |
const candidate = new RTCIceCandidate(message.ice); | |
this._addIceCandidate(candidate); | |
} | |
} | |
} catch (error) { | |
await this._disconnect(); | |
this._callbacks.disconnect({ reason: 'SIGNALING-ERROR', error: error }); | |
} | |
}; | |
} | |
}; | |
}); | |
} | |
_createPeerConnection(): void { | |
this._traceLog('RTCConfiguration=>', this._pcConfig); | |
const pc = new window.RTCPeerConnection(this._pcConfig , {}); | |
const audioTrack = this.stream && this.stream.getAudioTracks()[0]; | |
if (audioTrack && this.options.audio.direction !== 'recvonly') { | |
pc.addTrack(audioTrack, this.stream!); | |
} else if (this.options.audio.enabled) { | |
pc.addTransceiver('audio', { direction: 'recvonly' }); | |
} | |
const videoTrack = this.stream && this.stream.getVideoTracks()[0]; | |
if (videoTrack && this.options.video.direction !== 'recvonly') { | |
const videoSender = pc.addTrack(videoTrack, this.stream!); | |
const videoTransceiver = this._getTransceiver(pc, videoSender); | |
if (this._isVideoCodecSpecified() && videoTransceiver !== null) { | |
if (typeof videoTransceiver.setCodecPreferences !== 'undefined') { | |
const videoCapabilities = RTCRtpSender.getCapabilities('video'); | |
if (videoCapabilities) { | |
const videoCodecs = getVideoCodecsFromString(this.options.video.codec || 'VP9', videoCapabilities.codecs); | |
this._traceLog('video codecs=', videoCodecs); | |
videoTransceiver.setCodecPreferences(videoCodecs); | |
} | |
} else { | |
this._removeCodec = true; | |
} | |
} | |
} else if (this.options.video.enabled) { | |
const videoTransceiver = pc.addTransceiver('video', { direction: 'recvonly' }); | |
if (this._isVideoCodecSpecified()) { | |
if (typeof videoTransceiver.setCodecPreferences !== 'undefined') { | |
const videoCapabilities = RTCRtpSender.getCapabilities('video'); | |
if (videoCapabilities) { | |
const videoCodecs = getVideoCodecsFromString(this.options.video.codec || 'VP9', videoCapabilities.codecs); | |
this._traceLog('video codecs=', videoCodecs); | |
videoTransceiver.setCodecPreferences(videoCodecs); | |
} | |
} else { | |
this._removeCodec = true; | |
} | |
} | |
} | |
const tracks: Array<MediaStreamTrack> = []; | |
pc.ontrack = (event: RTCTrackEvent) => { | |
const callbackEvent: any = event; | |
this._traceLog('peer.ontrack()', event); | |
if (browser() === 'safari') { | |
tracks.push(event.track); | |
const mediaStream = new MediaStream(tracks); | |
this.remoteStream = mediaStream; | |
} else { | |
this.remoteStream = event.streams[0]; | |
} | |
callbackEvent.stream = this.remoteStream; | |
this._callbacks.addstream(callbackEvent); | |
}; | |
pc.onicecandidate = (event: RTCPeerConnectionIceEvent) => { | |
this._traceLog('peer.onicecandidate()', event); | |
if (event.candidate) { | |
this._sendIceCandidate(event.candidate); | |
} else { | |
this._traceLog('empty ice event', ''); | |
} | |
}; | |
pc.oniceconnectionstatechange = async () => { | |
this._traceLog('ICE connection Status has changed to ', pc.iceConnectionState); | |
if (this.connectionState !== pc.iceConnectionState) { | |
this.connectionState = pc.iceConnectionState; | |
switch (this.connectionState) { | |
case 'connected': | |
this._isOffer = false; | |
this._callbacks.connect(); | |
break; | |
case 'disconnected': | |
case 'failed': | |
await this._disconnect(); | |
this._callbacks.disconnect({ reason: 'ICE-CONNECTION-STATE-FAILED' }); | |
break; | |
} | |
} | |
}; | |
pc.onsignalingstatechange = _ => { | |
this._traceLog('signaling state changes:', pc.signalingState); | |
}; | |
pc.ondatachannel = this._onDataChannel.bind(this); | |
if (!this._pc) { | |
this._pc = pc; | |
this._addDataChannel('dataChannel', undefined); | |
this._callbacks.open({ authzMetadata: this.authzMetadata }); | |
} else { | |
this._pc = pc; | |
} | |
} | |
async _addDataChannel(channelId: string, options: RTCDataChannelInit | undefined): Promise<void> { | |
return new Promise((resolve, reject) => { | |
if (!this._pc) return reject('PeerConnection Does Not Ready'); | |
if (this._isOffer) return reject('PeerConnection Has Local Offer'); | |
let dataChannel = this._findDataChannel(channelId); | |
if (dataChannel) { | |
return reject('DataChannel Already Exists!'); | |
} | |
dataChannel = this._pc.createDataChannel(channelId, options); | |
dataChannel.onclose = (event: Record<string, any>) => { | |
this._traceLog('datachannel onclosed=>', event); | |
this._dataChannels = this._dataChannels.filter(dataChannel => dataChannel.label != channelId); | |
}; | |
dataChannel.onerror = (event: Record<string, any>) => { | |
this._traceLog('datachannel onerror=>', event); | |
this._dataChannels = this._dataChannels.filter(dataChannel => dataChannel.label != channelId); | |
}; | |
dataChannel.onmessage = (event: any) => { | |
this._traceLog('datachannel onmessage=>', event.data); | |
event.channelId = channelId; | |
this._callbacks.data(event); | |
}; | |
dataChannel.onopen = (event: Record<string, any>) => { | |
this._traceLog('datachannel onopen=>', event); | |
}; | |
this._dataChannels.push(dataChannel); | |
return resolve(); | |
}); | |
} | |
_onDataChannel(event: RTCDataChannelEvent): void { | |
this._traceLog('on data channel', event); | |
if (!this._pc) return; | |
const dataChannel = event.channel; | |
const channelId = event.channel.label; | |
if (!event.channel) return; | |
if (!channelId || channelId.length < 1) return; | |
dataChannel.onopen = async (event: Record<string, any>) => { | |
this._traceLog('datachannel onopen=>', event); | |
}; | |
dataChannel.onclose = async (event: Record<string, any>) => { | |
this._traceLog('datachannel onclosed=>', event); | |
}; | |
dataChannel.onerror = async (event: Record<string, any>) => { | |
this._traceLog('datachannel onerror=>', event); | |
}; | |
dataChannel.onmessage = (event: any) => { | |
this._traceLog('datachannel onmessage=>', event.data); | |
event.channelId = channelId; | |
this._callbacks.data(event); | |
}; | |
if (!this._findDataChannel(channelId)) { | |
this._dataChannels.push(event.channel); | |
} else { | |
this._dataChannels = this._dataChannels.map(channel => { | |
if (channel.label == channelId) { | |
return dataChannel; | |
} else { | |
return channel; | |
} | |
}); | |
} | |
} | |
async _sendOffer() { | |
if (!this._pc) { | |
return; | |
} | |
if (browser() === 'safari') { | |
if (this.options.video.enabled && this.options.video.direction === 'sendrecv') { | |
this._pc.addTransceiver('video', { direction: 'recvonly' }); | |
} | |
if (this.options.audio.enabled && this.options.audio.direction === 'sendrecv') { | |
this._pc.addTransceiver('audio', { direction: 'recvonly' }); | |
} | |
} | |
const offer: any = await this._pc.createOffer({ | |
offerToReceiveAudio: this.options.audio.enabled && this.options.audio.direction !== 'sendonly', | |
offerToReceiveVideo: this.options.video.enabled && this.options.video.direction !== 'sendonly' | |
}); | |
if (this._removeCodec && this.options.video.codec) { | |
const codecs: Array<VideoCodecOption> = ['VP8', 'VP9', 'H264']; | |
codecs.forEach((codec: VideoCodecOption) => { | |
if (this.options.video.codec !== codec) { | |
offer.sdp = removeCodec(offer.sdp, codec); | |
} | |
}); | |
} | |
this._traceLog('create offer sdp, sdp=', offer.sdp); | |
await this._pc.setLocalDescription(offer); | |
if (this._pc.localDescription) { | |
this._sendSdp(this._pc.localDescription); | |
} | |
this._isOffer = true; | |
} | |
_isVideoCodecSpecified(): boolean { | |
return this.options.video.enabled && this.options.video.codec !== null; | |
} | |
async _createAnswer(): Promise<void> { | |
if (!this._pc) { | |
return; | |
} | |
try { | |
const answer = await this._pc.createAnswer(); | |
this._traceLog('create answer sdp, sdp=', answer.sdp); | |
await this._pc.setLocalDescription(answer); | |
if (this._pc.localDescription) this._sendSdp(this._pc.localDescription); | |
} catch (error) { | |
await this._disconnect(); | |
this._callbacks.disconnect({ reason: 'CREATE-ANSWER-ERROR', error: error }); | |
} | |
} | |
async _setAnswer(sessionDescription: RTCSessionDescription): Promise<void> { | |
if (!this._pc) { | |
return; | |
} | |
await this._pc.setRemoteDescription(sessionDescription); | |
this._traceLog('set answer sdp=', sessionDescription.sdp); | |
} | |
async _setOffer(sessionDescription: RTCSessionDescription): Promise<void> { | |
this._createPeerConnection(); | |
try { | |
if (!this._pc) { | |
return; | |
} | |
await this._pc.setRemoteDescription(sessionDescription); | |
this._traceLog('set offer sdp=', sessionDescription.sdp); | |
await this._createAnswer(); | |
} catch (error) { | |
await this._disconnect(); | |
this._callbacks.disconnect({ reason: 'SET-OFFER-ERROR', error: error }); | |
} | |
} | |
async _addIceCandidate(candidate: RTCIceCandidate): Promise<void> { | |
try { | |
if (this._pc) { | |
await this._pc.addIceCandidate(candidate); | |
} | |
} catch (_error) { | |
this._traceLog('invalid ice candidate', candidate); | |
} | |
} | |
_sendIceCandidate(candidate: RTCIceCandidate): void { | |
const message = { type: 'candidate', ice: candidate }; | |
this._sendWs(message); | |
} | |
_sendSdp(sessionDescription: RTCSessionDescription): void { | |
this._sendWs(sessionDescription); | |
} | |
_sendWs(message: Record<string, any>) { | |
if (this._ws) { | |
this._ws.send(JSON.stringify(message)); | |
} | |
} | |
_getTransceiver(pc: RTCPeerConnection, track: any): RTCRtpTransceiver | null { | |
let transceiver = null; | |
pc.getTransceivers().forEach((t: RTCRtpTransceiver) => { | |
if (t.sender == track || t.receiver == track) transceiver = t; | |
}); | |
if (!transceiver) { | |
throw new Error('invalid transceiver'); | |
} | |
return transceiver; | |
} | |
_findDataChannel(channelId: string): RTCDataChannel | undefined { | |
return this._dataChannels.find(channel => channel.label == channelId); | |
} | |
async _closeDataChannel(dataChannel: RTCDataChannel): Promise<void> { | |
return new Promise((resolve, reject) => { | |
if (!dataChannel) return resolve(); | |
if (dataChannel.readyState === 'closed') return resolve(); | |
dataChannel.onclose = null; | |
const timerId = setInterval(() => { | |
if (!dataChannel) { | |
clearInterval(timerId); | |
return reject('DataChannel Closing Error'); | |
} | |
if (dataChannel.readyState === 'closed') { | |
clearInterval(timerId); | |
return resolve(); | |
} | |
}, 800); | |
dataChannel && dataChannel.close(); | |
}); | |
} | |
async _closePeerConnection(): Promise<void> { | |
return new Promise((resolve, reject) => { | |
if (browser() === 'safari' && this._pc) { | |
this._pc.oniceconnectionstatechange = () => {}; | |
this._pc.close(); | |
this._pc = null; | |
return resolve(); | |
} | |
if (!this._pc) return resolve(); | |
if (this._pc && this._pc.signalingState == 'closed') { | |
return resolve(); | |
} | |
this._pc.oniceconnectionstatechange = () => {}; | |
const timerId = setInterval(() => { | |
if (!this._pc) { | |
clearInterval(timerId); | |
return reject('PeerConnection Closing Error'); | |
} | |
if (this._pc && this._pc.signalingState == 'closed') { | |
clearInterval(timerId); | |
return resolve(); | |
} | |
}, 800); | |
this._pc.close(); | |
}); | |
} | |
async _closeWebSocketConnection(): Promise<void> { | |
return new Promise((resolve, reject) => { | |
if (!this._ws) return resolve(); | |
if (this._ws && this._ws.readyState === 3) return resolve(); | |
this._ws.onclose = () => {}; | |
const timerId = setInterval(() => { | |
if (!this._ws) { | |
clearInterval(timerId); | |
return reject('WebSocket Closing Error'); | |
} | |
if (this._ws.readyState === 3) { | |
clearInterval(timerId); | |
return resolve(); | |
} | |
}, 800); | |
this._ws && this._ws.close(); | |
}); | |
} | |
_traceLog(title: string, message?: Record<string, any> | string) { | |
if (!this.debug) return; | |
traceLog(title, message); | |
} | |
} | |
export default ConnectionBase; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment