@signalwire/js
Version:
431 lines (389 loc) • 12.7 kB
text/typescript
import {
actions,
BaseClient,
CallJoinedEventParams as InternalCallJoinedEventParams,
VertoBye,
VertoSubscribe,
} from '@signalwire/core'
import { sessionConnectionPoolWorker } from '@signalwire/webrtc'
import { MakeRoomOptions } from '../video'
import {
createFabricRoomSessionObject,
FabricRoomSession,
} from './FabricRoomSession'
import { buildVideoElement } from '../buildVideoElement'
import {
CallParams,
DialParams,
ReattachParams,
IncomingInvite,
OnlineParams,
HandlePushNotificationParams,
WSClientOptions,
HandlePushNotificationResult,
} from './interfaces'
import { IncomingCallManager } from './IncomingCallManager'
import { wsClientWorker } from './workers'
import { createWSClient } from './createWSClient'
import { WSClientContract } from './interfaces/wsClient'
import { getStorage } from '../utils/storage'
import { PREVIOUS_CALLID_STORAGE_KEY } from './utils/constants'
export class WSClient extends BaseClient<{}> implements WSClientContract {
private _incomingCallManager: IncomingCallManager
private _disconnected: boolean = false
constructor(private wsClientOptions: WSClientOptions) {
const client = createWSClient(wsClientOptions)
super(client)
this._incomingCallManager = new IncomingCallManager({
client: this,
buildInboundCall: this.buildInboundCall.bind(this),
executeVertoBye: this.executeVertoBye.bind(this),
})
this.runWorker('wsClientWorker', {
worker: wsClientWorker,
initialState: {
handleIncomingInvite: (invite: IncomingInvite) => {
this._incomingCallManager.handleIncomingInvite({
source: 'websocket',
...invite,
})
},
},
})
// Initialize the session-level connection pool
// This will start pre-warming connections as soon as the session is authorized
this.initializeSessionConnectionPool()
}
private makeFabricObject(makeRoomOptions: MakeRoomOptions) {
const {
rootElement,
applyLocalVideoOverlay = true,
applyMemberOverlay = true,
stopCameraWhileMuted = true,
stopMicrophoneWhileMuted = true,
mirrorLocalVideoOverlay = true,
...options
} = makeRoomOptions
const room = createFabricRoomSessionObject({
...options,
store: this.store,
})
/**
* If the user provides a `rootElement` we'll
* automatically handle the Video element for them
*/
if (rootElement) {
try {
buildVideoElement({
applyLocalVideoOverlay,
applyMemberOverlay,
mirrorLocalVideoOverlay,
room,
rootElement,
})
} catch (error) {
this.logger.error('Unable to build the video element automatically')
}
}
/**
* If the user joins with `join_video_muted: true` or
* `join_audio_muted: true` we'll stop the streams
* right away.
*/
const joinMutedHandler = (params: InternalCallJoinedEventParams) => {
const member = params.room_session.members?.find(
(m) => m.member_id === room.memberId
)
if (member?.audio_muted) {
try {
room.stopOutboundAudio()
} catch (error) {
this.logger.error('Error handling audio_muted', error)
}
}
if (member?.video_muted) {
try {
room.stopOutboundVideo()
} catch (error) {
this.logger.error('Error handling video_muted', error)
}
}
}
room.on('room.subscribed', joinMutedHandler)
/**
* Stop or Restore outbound audio on "member.updated" event
*/
if (stopMicrophoneWhileMuted) {
room.on('member.updated.audioMuted', ({ member }) => {
try {
if (member.member_id === room.memberId && 'audio_muted' in member) {
member.audio_muted
? room.stopOutboundAudio()
: room.restoreOutboundAudio()
}
} catch (error) {
this.logger.error('Error handling audio_muted', error)
}
})
}
/**
* Stop or Restore outbound video on "member.updated" event
*/
if (stopCameraWhileMuted) {
room.on('member.updated.videoMuted', ({ member }) => {
try {
if (member.member_id === room.memberId && 'video_muted' in member) {
member.video_muted
? room.stopOutboundVideo()
: room.restoreOutboundVideo()
}
} catch (error) {
this.logger.error('Error handling video_muted', error)
}
})
}
return room
}
private buildOutboundCall(params: ReattachParams & { attach?: boolean }) {
let video = false
let negotiateVideo = false
if (params.to) {
const [pathname, query] = params.to.split('?')
if (!pathname) {
throw new Error('Invalid destination address')
}
const queryParams = new URLSearchParams(query)
const channel = queryParams.get('channel')
if (channel === 'video') {
video = true
negotiateVideo = true
}
}
const call = this.makeFabricObject({
audio: params.audio ?? true,
video: params.video ?? video,
negotiateAudio: params.negotiateAudio ?? true,
negotiateVideo: params.negotiateVideo ?? negotiateVideo,
rootElement: params.rootElement || this.wsClientOptions.rootElement,
applyLocalVideoOverlay: params.applyLocalVideoOverlay,
applyMemberOverlay: params.applyMemberOverlay,
stopCameraWhileMuted: params.stopCameraWhileMuted,
stopMicrophoneWhileMuted: params.stopMicrophoneWhileMuted,
mirrorLocalVideoOverlay: params.mirrorLocalVideoOverlay,
watchMediaPackets: false,
destinationNumber: params.to ?? '',
nodeId: params.nodeId,
attach: params.attach ?? false,
disableUdpIceServers: params.disableUdpIceServers || false,
userVariables: params.userVariables || this.wsClientOptions.userVariables,
fromFabricAddressId: params.fromFabricAddressId,
})
// WebRTC connection left the room.
call.once('destroy', () => {
this.logger.debug('RTC Connection Destroyed')
call.destroy()
})
this.session.once('session.disconnected', () => {
this.logger.debug('Session Disconnected')
call.destroy()
this.destroy()
})
// TODO: This is for memberList.updated event and it is not yet supported in CF SDK
// @ts-expect-error
call.attachPreConnectWorkers()
return call
}
private buildInboundCall(payload: IncomingInvite, params: CallParams) {
const call = this.makeFabricObject({
audio: params.audio ?? true,
video: params.video ?? true,
negotiateAudio: params.negotiateAudio ?? true,
negotiateVideo: params.negotiateVideo ?? true,
rootElement: params.rootElement || this.wsClientOptions.rootElement,
applyLocalVideoOverlay: true,
applyMemberOverlay: true,
stopCameraWhileMuted: true,
stopMicrophoneWhileMuted: true,
watchMediaPackets: false,
nodeId: payload.nodeId,
remoteSdp: payload.sdp,
prevCallId: payload.callID,
disableUdpIceServers: params.disableUdpIceServers || false,
userVariables: params.userVariables || this.wsClientOptions.userVariables,
})
// WebRTC connection left the room.
call.once('destroy', () => {
this.logger.debug('RTC Connection Destroyed')
call.destroy()
})
this.session.once('session.disconnected', () => {
this.logger.debug('Session Disconnected')
call.destroy()
this.destroy()
})
// TODO: This is for memberList.updated event and it is not yet supported in CF SDK
// @ts-expect-error
call.attachPreConnectWorkers()
return call
}
private async executeVertoBye(callId: string, nodeId: string) {
try {
return await this.execute<unknown, void>({
method: 'webrtc.verto',
params: {
callID: callId,
node_id: nodeId,
message: VertoBye({
cause: 'USER_BUSY',
causeCode: '17',
dialogParams: { callID: callId },
}),
},
})
} catch (error) {
this.logger.warn('The call is not available anymore', callId)
throw error
}
}
private async executeVertoSubscribe(callId: string, nodeId: string) {
try {
return await this.execute<unknown, void>({
method: 'webrtc.verto',
params: {
callID: callId,
node_id: nodeId,
subscribe: [],
message: VertoSubscribe({
sessid: callId,
eventChannel: [],
}),
},
})
} catch (error) {
this.logger.warn('The call is not available anymore', callId)
throw error
}
}
public override disconnect() {
return new Promise<void>((resolve, _reject) => {
if (this._disconnected) {
resolve()
}
this.session.once('session.disconnected', () => {
this.destroy()
resolve()
this._disconnected = true
})
super.disconnect()
})
}
public async dial(params: DialParams) {
return new Promise<FabricRoomSession>(async (resolve, reject) => {
try {
// in case the user left the previous call with hangup, and is not reattaching
getStorage()?.removeItem(PREVIOUS_CALLID_STORAGE_KEY)
const call = this.buildOutboundCall(params)
resolve(call)
} catch (error) {
this.logger.error('Unable to connect and dial a call', error)
reject(error)
}
})
}
public async reattach(params: ReattachParams) {
return new Promise<FabricRoomSession>(async (resolve, reject) => {
try {
const call = this.buildOutboundCall({ ...params, attach: true })
resolve(call)
} catch (error) {
this.logger.error('Unable to connect and reattach a call', error)
reject(error)
}
})
}
public handlePushNotification(params: HandlePushNotificationParams) {
const { incomingCallHandler } = params
this._incomingCallManager.setNotificationHandlers({
pushNotification: incomingCallHandler,
})
return new Promise<HandlePushNotificationResult>(
async (resolve, reject) => {
const { decrypted, type } = params
if (type !== 'call_invite') {
this.logger.warn('Unknown notification type', params)
reject('Unknown notification type')
}
this.logger.debug('handlePushNotification', params)
const {
params: { params: payload },
node_id: nodeId,
} = decrypted
try {
// Catch the error temporarly
try {
// Send verto.subscribe
await this.executeVertoSubscribe(payload.callID, nodeId)
} catch (error) {
this.logger.warn('Verto Subscribe', error)
}
this._incomingCallManager.handleIncomingInvite({
source: 'pushNotification',
nodeId,
...payload,
})
resolve({ resultType: 'inboundCall' })
} catch (error) {
reject(error)
}
}
)
}
public updateToken(token: string) {
return new Promise<void>((resolve, reject) => {
this.session.once('session.auth_error', (error) => {
reject(error)
})
this.session.once('session.connected', () => {
resolve()
})
this.store.dispatch(actions.reauthAction({ token }))
})
}
/**
* Mark the client as 'online' to receive calls over WebSocket
*/
public async online({ incomingCallHandlers }: OnlineParams) {
if (incomingCallHandlers.all || incomingCallHandlers.pushNotification) {
this.logger.warn(
`Make sure the device is not registered to receive Push Notifications while it is online`
)
}
this._incomingCallManager.setNotificationHandlers(incomingCallHandlers)
return this.execute<unknown, void>({
method: 'subscriber.online',
params: {},
})
}
/**
* Mark the client as 'offline' to receive calls over WebSocket
*/
public offline() {
this._incomingCallManager.setNotificationHandlers({})
return this.execute<unknown, void>({
method: 'subscriber.offline',
params: {},
})
}
/**
* Initialize the session-level connection pool
*/
private initializeSessionConnectionPool() {
this.runWorker('sessionConnectionPoolWorker', {
worker: sessionConnectionPoolWorker,
initialState: {
poolSize: 1, // Only one connection per session is required
iceCandidatePoolSize: 10,
},
})
}
}