@twilio/voice-sdk
Version:
Twilio's JavaScript Voice SDK
1,686 lines (1,436 loc) • 54.1 kB
text/typescript
/**
* @packageDocumentation
* @module Voice
* @preferred
* @publicapi
*/
import { EventEmitter } from 'events';
import { levels as LogLevels, LogLevelDesc } from 'loglevel';
import AudioHelper from './audiohelper';
import Call from './call';
import * as C from './constants';
import DialtonePlayer from './dialtonePlayer';
import {
AuthorizationErrors,
ClientErrors,
GeneralErrors,
getErrorByCode,
hasErrorByCode,
InvalidArgumentError,
InvalidStateError,
NotSupportedError,
TwilioError,
} from './errors';
import Publisher from './eventpublisher';
import Log from './log';
import { PreflightTest } from './preflight/preflight';
import PStream from './pstream';
import {
createEventGatewayURI,
createSignalingEndpointURL,
Edge,
getChunderURIs,
getRegionShortcode,
Region,
regionToEdge,
} from './regions';
import * as rtc from './rtc';
import getUserMedia from './rtc/getusermedia';
import Sound from './sound';
import {
isLegacyEdge,
isUnifiedPlanDefault,
queryToJson,
} from './util';
import { generateVoiceEventSid } from './uuid';
// Placeholders until we convert the respective files to TypeScript.
/**
* @private
*/
export type IPStream = any;
/**
* @private
*/
export type IPublisher = any;
/**
* @private
*/
export type ISound = any;
const REGISTRATION_INTERVAL = 30000;
const RINGTONE_PLAY_TIMEOUT = 2000;
const PUBLISHER_PRODUCT_NAME = 'twilio-js-sdk';
const INVALID_TOKEN_MESSAGE = 'Parameter "token" must be of type "string".';
declare const RTCRtpTransceiver: any;
declare const webkitAudioContext: typeof AudioContext;
/**
* Options that may be passed to the {@link Device} constructor for internal testing.
* @private
*/
export interface IExtendedDeviceOptions extends Device.Options {
/**
* Custom {@link AudioHelper} constructor
*/
AudioHelper?: typeof AudioHelper;
/**
* The max amount of time in milliseconds to allow stream (re)-connect
* backoffs.
*/
backoffMaxMs?: number;
/**
* Custom {@link Call} constructor
*/
Call?: typeof Call;
/**
* Hostname of the signaling gateway to connect to.
*/
chunderw?: string | string[];
/**
* Hostname of the event gateway to connect to.
*/
eventgw?: string;
/**
* File input stream to use instead of reading from mic
*/
fileInputStream?: MediaStream;
/**
* Ignore browser support, disabling the exception that is thrown when neither WebRTC nor
* ORTC are supported.
*/
ignoreBrowserSupport?: boolean;
/**
* MediaStream constructor.
*/
MediaStream?: typeof MediaStream;
/**
* Whether this is a preflight call or not
*/
preflight?: boolean;
/**
* Custom PStream constructor
*/
PStream?: IPStream;
/**
* Custom Publisher constructor
*/
Publisher?: IPublisher;
/**
* Whether or not to publish events to insights using {@link Device._publisher}.
*/
publishEvents?: boolean;
/**
* MediaStreamConstraints to pass to getUserMedia when making or accepting a Call.
*/
rtcConstraints?: Call.AcceptOptions['rtcConstraints'];
/**
* Custom Sound constructor
*/
Sound?: ISound;
/**
* Voice event SID generator.
*/
voiceEventSidGenerator?: () => string;
}
/**
* A sound definition used to initialize a Sound file.
* @private
*/
export interface ISoundDefinition {
/**
* Name of the sound file.
*/
filename: string;
/**
* The amount of time this sound file should play before being stopped automatically.
*/
maxDuration?: number;
/**
* Whether or not this sound should loop after playthrough finishes.
*/
shouldLoop?: boolean;
}
/**
* Twilio Device. Allows registration for incoming calls, and placing outgoing calls.
* @publicapi
*/
class Device extends EventEmitter {
/**
* The AudioContext to be used by {@link Device} instances.
* @private
*/
static get audioContext(): AudioContext | undefined {
return Device._audioContext;
}
/**
* Which sound file extension is supported.
* @private
*/
static get extension(): 'mp3' | 'ogg' {
// NOTE(mroberts): Node workaround.
const a: any = typeof document !== 'undefined'
? document.createElement('audio') : { canPlayType: false };
let canPlayMp3;
try {
canPlayMp3 = a.canPlayType && !!a.canPlayType('audio/mpeg').replace(/no/, '');
} catch (e) {
canPlayMp3 = false;
}
let canPlayVorbis;
try {
canPlayVorbis = a.canPlayType && !!a.canPlayType('audio/ogg;codecs=\'vorbis\'').replace(/no/, '');
} catch (e) {
canPlayVorbis = false;
}
return (canPlayVorbis && !canPlayMp3) ? 'ogg' : 'mp3';
}
/**
* Whether or not this SDK is supported by the current browser.
*/
static get isSupported(): boolean { return rtc.enabled(); }
/**
* Package name of the SDK.
*/
static get packageName(): string { return C.PACKAGE_NAME; }
/**
* Run some tests to identify issues, if any, prohibiting successful calling.
* @param token - A Twilio JWT token string
* @param options
*/
static runPreflight(token: string, options?: PreflightTest.Options): PreflightTest {
return new PreflightTest(token, { audioContext: Device._getOrCreateAudioContext(), ...options });
}
/**
* String representation of {@link Device} class.
* @private
*/
static toString(): string {
return '[Twilio.Device class]';
}
/**
* Current SDK version.
*/
static get version(): string { return C.RELEASE_VERSION; }
/**
* An AudioContext to share between {@link Device}s.
*/
private static _audioContext?: AudioContext;
private static _defaultSounds: Record<string, ISoundDefinition> = {
disconnect: { filename: 'disconnect', maxDuration: 3000 },
dtmf0: { filename: 'dtmf-0', maxDuration: 1000 },
dtmf1: { filename: 'dtmf-1', maxDuration: 1000 },
dtmf2: { filename: 'dtmf-2', maxDuration: 1000 },
dtmf3: { filename: 'dtmf-3', maxDuration: 1000 },
dtmf4: { filename: 'dtmf-4', maxDuration: 1000 },
dtmf5: { filename: 'dtmf-5', maxDuration: 1000 },
dtmf6: { filename: 'dtmf-6', maxDuration: 1000 },
dtmf7: { filename: 'dtmf-7', maxDuration: 1000 },
dtmf8: { filename: 'dtmf-8', maxDuration: 1000 },
dtmf9: { filename: 'dtmf-9', maxDuration: 1000 },
dtmfh: { filename: 'dtmf-hash', maxDuration: 1000 },
dtmfs: { filename: 'dtmf-star', maxDuration: 1000 },
incoming: { filename: 'incoming', shouldLoop: true },
outgoing: { filename: 'outgoing', maxDuration: 3000 },
};
/**
* A DialtonePlayer to play mock DTMF sounds through.
*/
private static _dialtonePlayer?: DialtonePlayer;
/**
* Whether or not the browser uses unified-plan SDP by default.
*/
private static _isUnifiedPlanDefault: boolean | undefined;
/**
* Initializes the AudioContext instance shared across the Voice SDK,
* or returns the existing instance if one has already been initialized.
*/
private static _getOrCreateAudioContext(): AudioContext | undefined {
if (!Device._audioContext) {
if (typeof AudioContext !== 'undefined') {
Device._audioContext = new AudioContext();
} else if (typeof webkitAudioContext !== 'undefined') {
Device._audioContext = new webkitAudioContext();
}
}
return Device._audioContext;
}
/**
* The currently active {@link Call}, if there is one.
*/
private _activeCall: Call | null = null;
/**
* The AudioHelper instance associated with this {@link Device}.
*/
private _audio: AudioHelper | null = null;
/**
* {@link Device._confirmClose} bound to the specific {@link Device} instance.
*/
private _boundConfirmClose: typeof Device.prototype._confirmClose;
/**
* {@link Device.destroy} bound to the specific {@link Device} instance.
*/
private _boundDestroy: typeof Device.prototype.destroy;
/**
* An audio input MediaStream to pass to new {@link Call} instances.
*/
private _callInputStream: MediaStream | null = null;
/**
* An array of {@link Call}s. Though only one can be active, multiple may exist when there
* are multiple incoming, unanswered {@link Call}s.
*/
private _calls: Call[] = [];
/**
* An array of {@link Device} IDs to be used to play sounds through, to be passed to
* new {@link Call} instances.
*/
private _callSinkIds: string[] = ['default'];
/**
* The list of chunder URIs that will be passed to PStream
*/
private _chunderURIs: string[] = [];
/**
* Default options used by {@link Device}.
*/
private readonly _defaultOptions: IExtendedDeviceOptions = {
allowIncomingWhileBusy: false,
closeProtection: false,
codecPreferences: [Call.Codec.PCMU, Call.Codec.Opus],
dscp: true,
forceAggressiveIceNomination: false,
logLevel: LogLevels.ERROR,
maxCallSignalingTimeoutMs: 0,
preflight: false,
sounds: { },
tokenRefreshMs: 10000,
voiceEventSidGenerator: generateVoiceEventSid,
};
/**
* The name of the edge the {@link Device} is connected to.
*/
private _edge: string | null = null;
/**
* The name of the home region the {@link Device} is connected to.
*/
private _home: string | null = null;
/**
* The identity associated with this Device.
*/
private _identity: string | null = null;
/**
* Whether SDK is run as a browser extension
*/
private _isBrowserExtension: boolean;
/**
* An instance of Logger to use.
*/
private _log: Log = Log.getInstance();
/**
* Network related information
* See https://developer.mozilla.org/en-US/docs/Web/API/Network_Information_API
*/
private _networkInformation: any;
/**
* The options passed to {@link Device} constructor or {@link Device.updateOptions}.
*/
private _options: IExtendedDeviceOptions = { };
/**
* The preferred URI to (re)-connect signaling to.
*/
private _preferredURI: string | null = null;
/**
* An Insights Event Publisher.
*/
private _publisher: IPublisher | null = null;
/**
* The region the {@link Device} is connected to.
*/
private _region: string | null = null;
/**
* A timeout ID for a setTimeout schedule to re-register the {@link Device}.
*/
private _regTimer: NodeJS.Timer | null = null;
/**
* Boolean representing whether or not the {@link Device} was registered when
* receiving a signaling `offline`. Determines if the {@link Device} attempts
* a `re-register` once signaling is re-established when receiving a
* `connected` event from the stream.
*/
private _shouldReRegister: boolean = false;
/**
* A Map of Sounds to play.
*/
private _soundcache: Map<Device.SoundName, ISound> = new Map();
/**
* The current status of the {@link Device}.
*/
private _state: Device.State = Device.State.Unregistered;
/**
* A map from {@link Device.State} to {@link Device.EventName}.
*/
private readonly _stateEventMapping: Record<Device.State, Device.EventName> = {
[Device.State.Destroyed]: Device.EventName.Destroyed,
[Device.State.Unregistered]: Device.EventName.Unregistered,
[Device.State.Registering]: Device.EventName.Registering,
[Device.State.Registered]: Device.EventName.Registered,
};
/**
* The Signaling stream.
*/
private _stream: IPStream | null = null;
/**
* A promise that will resolve when the Signaling stream is ready.
*/
private _streamConnectedPromise: Promise<IPStream> | null = null;
/**
* The JWT string currently being used to authenticate this {@link Device}.
*/
private _token: string;
/**
* A timeout to track when the current AccessToken will expire.
*/
private _tokenWillExpireTimeout: NodeJS.Timer | null = null;
/**
* Construct a {@link Device} instance. The {@link Device} can be registered
* to make and listen for calls using {@link Device.register}.
* @constructor
* @param options
*/
constructor(token: string, options: Device.Options = { }) {
super();
this.updateToken(token);
if (isLegacyEdge()) {
throw new NotSupportedError(
'Microsoft Edge Legacy (https://support.microsoft.com/en-us/help/4533505/what-is-microsoft-edge-legacy) ' +
'is deprecated and will not be able to connect to Twilio to make or receive calls after September 1st, 2020. ' +
'Please see this documentation for a list of supported browsers ' +
'https://www.twilio.com/docs/voice/client/javascript#supported-browsers',
);
}
if (!Device.isSupported && (options as IExtendedDeviceOptions).ignoreBrowserSupport) {
if (window && window.location && window.location.protocol === 'http:') {
throw new NotSupportedError(`twilio.js wasn't able to find WebRTC browser support. \
This is most likely because this page is served over http rather than https, \
which does not support WebRTC in many browsers. Please load this page over https and \
try again.`);
}
throw new NotSupportedError(`twilio.js 1.3+ SDKs require WebRTC browser support. \
For more information, see <https://www.twilio.com/docs/api/client/twilio-js>. \
If you have any questions about this announcement, please contact \
Twilio Support at <help@twilio.com>.`);
}
if (window) {
const root: any = window as any;
const browser: any = root.msBrowser || root.browser || root.chrome;
this._isBrowserExtension = (!!browser && !!browser.runtime && !!browser.runtime.id)
|| (!!root.safari && !!root.safari.extension);
}
if (this._isBrowserExtension) {
this._log.info('Running as browser extension.');
}
if (navigator) {
const n = navigator as any;
this._networkInformation = n.connection
|| n.mozConnection
|| n.webkitConnection;
}
if (this._networkInformation && typeof this._networkInformation.addEventListener === 'function') {
this._networkInformation.addEventListener('change', this._publishNetworkChange);
}
Device._getOrCreateAudioContext();
if (Device._audioContext) {
if (!Device._dialtonePlayer) {
Device._dialtonePlayer = new DialtonePlayer(Device._audioContext);
}
}
if (typeof Device._isUnifiedPlanDefault === 'undefined') {
Device._isUnifiedPlanDefault = typeof window !== 'undefined'
&& typeof RTCPeerConnection !== 'undefined'
&& typeof RTCRtpTransceiver !== 'undefined'
? isUnifiedPlanDefault(window, window.navigator, RTCPeerConnection, RTCRtpTransceiver)
: false;
}
this._boundDestroy = this.destroy.bind(this);
this._boundConfirmClose = this._confirmClose.bind(this);
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('unload', this._boundDestroy);
window.addEventListener('pagehide', this._boundDestroy);
}
this.updateOptions(options);
}
/**
* Return the {@link AudioHelper} used by this {@link Device}.
*/
get audio(): AudioHelper | null {
return this._audio;
}
/**
* Make an outgoing Call.
* @param options
*/
async connect(options: Device.ConnectOptions = { }): Promise<Call> {
this._throwIfDestroyed();
if (this._activeCall) {
throw new InvalidStateError('A Call is already active');
}
const activeCall = this._activeCall = await this._makeCall(options.params || { }, {
rtcConfiguration: options.rtcConfiguration,
voiceEventSidGenerator: this._options.voiceEventSidGenerator,
});
// Make sure any incoming calls are ignored
this._calls.splice(0).forEach(call => call.ignore());
// Stop the incoming sound if it's playing
this._soundcache.get(Device.SoundName.Incoming).stop();
activeCall.accept({ rtcConstraints: options.rtcConstraints });
this._publishNetworkChange();
return activeCall;
}
/**
* Return the calls that this {@link Device} is maintaining.
*/
get calls(): Call[] {
return this._calls;
}
/**
* Destroy the {@link Device}, freeing references to be garbage collected.
*/
destroy(): void {
this.disconnectAll();
this._stopRegistrationTimer();
if (this._audio) {
this._audio._unbind();
}
this._destroyStream();
this._destroyPublisher();
this._destroyAudioHelper();
if (this._networkInformation && typeof this._networkInformation.removeEventListener === 'function') {
this._networkInformation.removeEventListener('change', this._publishNetworkChange);
}
if (typeof window !== 'undefined' && window.removeEventListener) {
window.removeEventListener('beforeunload', this._boundConfirmClose);
window.removeEventListener('unload', this._boundDestroy);
window.removeEventListener('pagehide', this._boundDestroy);
}
this._setState(Device.State.Destroyed);
EventEmitter.prototype.removeAllListeners.call(this);
}
/**
* Disconnect all {@link Call}s.
*/
disconnectAll(): void {
const calls = this._calls.splice(0);
calls.forEach((call: Call) => call.disconnect());
if (this._activeCall) {
this._activeCall.disconnect();
}
}
/**
* Returns the {@link Edge} value the {@link Device} is currently connected
* to. The value will be `null` when the {@link Device} is offline.
*/
get edge(): string | null {
return this._edge;
}
/**
* Returns the home value the {@link Device} is currently connected
* to. The value will be `null` when the {@link Device} is offline.
*/
get home(): string | null {
return this._home;
}
/**
* Returns the identity associated with the {@link Device} for incoming calls. Only
* populated when registered.
*/
get identity(): string | null {
return this._identity;
}
/**
* Whether the Device is currently on an active Call.
*/
get isBusy(): boolean {
return !!this._activeCall;
}
/**
* Register the `Device` to the Twilio backend, allowing it to receive calls.
*/
async register(): Promise<void> {
if (this.state !== Device.State.Unregistered) {
throw new InvalidStateError(
`Attempt to register when device is in state "${this.state}". ` +
`Must be "${Device.State.Unregistered}".`,
);
}
this._setState(Device.State.Registering);
const stream = await (this._streamConnectedPromise || this._setupStream());
const streamReadyPromise = new Promise(resolve => {
this.once(Device.State.Registered, resolve);
});
await this._sendPresence(true);
await streamReadyPromise;
}
/**
* Get the state of this {@link Device} instance
*/
get state(): Device.State {
return this._state;
}
/**
* Get the token used by this {@link Device}.
*/
get token(): string | null {
return this._token;
}
/**
* String representation of {@link Device} instance.
* @private
*/
toString() {
return '[Twilio.Device instance]';
}
/**
* Unregister the `Device` to the Twilio backend, disallowing it to receive
* calls.
*/
async unregister(): Promise<void> {
if (this.state !== Device.State.Registered) {
throw new InvalidStateError(
`Attempt to unregister when device is in state "${this.state}". ` +
`Must be "${Device.State.Registered}".`,
);
}
this._shouldReRegister = false;
const stream = await this._streamConnectedPromise;
const streamOfflinePromise = new Promise(resolve => {
stream.on('offline', resolve);
});
await this._sendPresence(false);
await streamOfflinePromise;
}
/**
* Set the options used within the {@link Device}.
* @param options
*/
updateOptions(options: Device.Options = { }): void {
if (this.state === Device.State.Destroyed) {
throw new InvalidStateError(
`Attempt to "updateOptions" when device is in state "${this.state}".`,
);
}
this._options = { ...this._defaultOptions, ...this._options, ...options };
const originalChunderURIs: Set<string> = new Set(this._chunderURIs);
const chunderw = typeof this._options.chunderw === 'string'
? [this._options.chunderw]
: Array.isArray(this._options.chunderw) && this._options.chunderw;
const newChunderURIs = this._chunderURIs = (
chunderw || getChunderURIs(this._options.edge)
).map(createSignalingEndpointURL);
let hasChunderURIsChanged =
originalChunderURIs.size !== newChunderURIs.length;
if (!hasChunderURIsChanged) {
for (const uri of newChunderURIs) {
if (!originalChunderURIs.has(uri)) {
hasChunderURIsChanged = true;
break;
}
}
}
if (this.isBusy && hasChunderURIsChanged) {
throw new InvalidStateError('Cannot change Edge while on an active Call');
}
this._log.setDefaultLevel(
typeof this._options.logLevel === 'number'
? this._options.logLevel
: LogLevels.ERROR,
);
if (this._options.dscp) {
if (!this._options.rtcConstraints) {
this._options.rtcConstraints = { };
}
(this._options.rtcConstraints as any).optional = [{ googDscp: true }];
}
for (const name of Object.keys(Device._defaultSounds)) {
const soundDef: ISoundDefinition = Device._defaultSounds[name];
const defaultUrl: string = `${C.SOUNDS_BASE_URL}/${soundDef.filename}.${Device.extension}`
+ `?cache=${C.RELEASE_VERSION}`;
const soundUrl: string = this._options.sounds && this._options.sounds[name as Device.SoundName] || defaultUrl;
const sound: any = new (this._options.Sound || Sound)(name, soundUrl, {
audioContext: this._options.disableAudioContextSounds ? null : Device.audioContext,
maxDuration: soundDef.maxDuration,
shouldLoop: soundDef.shouldLoop,
});
this._soundcache.set(name as Device.SoundName, sound);
}
this._setupAudioHelper();
this._setupPublisher();
if (hasChunderURIsChanged && this._streamConnectedPromise) {
this._setupStream();
}
// Setup close protection and make sure we clean up ongoing calls on unload.
if (
typeof window !== 'undefined' &&
typeof window.addEventListener === 'function' &&
this._options.closeProtection
) {
window.removeEventListener('beforeunload', this._boundConfirmClose);
window.addEventListener('beforeunload', this._boundConfirmClose);
}
}
/**
* Update the token used by this {@link Device} to connect to Twilio.
* It is recommended to call this API after [[Device.tokenWillExpireEvent]] is emitted,
* and before or after a call to prevent a potential ~1s audio loss during the update process.
* @param token
*/
updateToken(token: string) {
if (this.state === Device.State.Destroyed) {
throw new InvalidStateError(
`Attempt to "updateToken" when device is in state "${this.state}".`,
);
}
if (typeof token !== 'string') {
throw new InvalidArgumentError(INVALID_TOKEN_MESSAGE);
}
this._token = token;
if (this._stream) {
this._stream.setToken(this._token);
}
if (this._publisher) {
this._publisher.setToken(this._token);
}
}
/**
* Called on window's beforeunload event if closeProtection is enabled,
* preventing users from accidentally navigating away from an active call.
* @param event
*/
private _confirmClose(event: any): string {
if (!this._activeCall) { return ''; }
const closeProtection: boolean | string = this._options.closeProtection || false;
const confirmationMsg: string = typeof closeProtection !== 'string'
? 'A call is currently in-progress. Leaving or reloading this page will end the call.'
: closeProtection;
(event || window.event).returnValue = confirmationMsg;
return confirmationMsg;
}
/**
* Create the default Insights payload
* @param call
*/
private _createDefaultPayload = (call?: Call): Record<string, any> => {
const payload: Record<string, any> = {
aggressive_nomination: this._options.forceAggressiveIceNomination,
browser_extension: this._isBrowserExtension,
dscp: !!this._options.dscp,
ice_restart_enabled: true,
platform: rtc.getMediaEngine(),
sdk_version: C.RELEASE_VERSION,
};
function setIfDefined(propertyName: string, value: string | undefined | null) {
if (value) { payload[propertyName] = value; }
}
if (call) {
const callSid = call.parameters.CallSid;
setIfDefined('call_sid', /^TJ/.test(callSid) ? undefined : callSid);
setIfDefined('temp_call_sid', call.outboundConnectionId);
setIfDefined('audio_codec', call.codec);
payload.direction = call.direction;
}
setIfDefined('gateway', this._stream && this._stream.gateway);
setIfDefined('region', this._stream && this._stream.region);
return payload;
}
/**
* Destroy the AudioHelper.
*/
private _destroyAudioHelper() {
if (!this._audio) { return; }
this._audio.removeAllListeners();
this._audio = null;
}
/**
* Destroy the publisher.
*/
private _destroyPublisher() {
// Attempt to destroy non-existent publisher.
if (!this._publisher) { return; }
this._publisher = null;
}
/**
* Destroy the connection to the signaling server.
*/
private _destroyStream() {
if (this._stream) {
this._stream.removeListener('close', this._onSignalingClose);
this._stream.removeListener('connected', this._onSignalingConnected);
this._stream.removeListener('error', this._onSignalingError);
this._stream.removeListener('invite', this._onSignalingInvite);
this._stream.removeListener('offline', this._onSignalingOffline);
this._stream.removeListener('ready', this._onSignalingReady);
this._stream.destroy();
this._stream = null;
}
this._onSignalingOffline();
this._streamConnectedPromise = null;
}
/**
* Find a {@link Call} by its CallSid.
* @param callSid
*/
private _findCall(callSid: string): Call | null {
return this._calls.find(call => call.parameters.CallSid === callSid
|| call.outboundConnectionId === callSid) || null;
}
/**
* Create a new {@link Call}.
* @param twimlParams - A flat object containing key:value pairs to be sent to the TwiML app.
* @param options - Options to be used to instantiate the {@link Call}.
*/
private async _makeCall(twimlParams: Record<string, string>, options?: Call.Options): Promise<Call> {
if (typeof Device._isUnifiedPlanDefault === 'undefined') {
throw new InvalidStateError('Device has not been initialized.');
}
const config: Call.Config = {
audioHelper: this._audio,
getUserMedia: this._options.getUserMedia || getUserMedia,
isUnifiedPlanDefault: Device._isUnifiedPlanDefault,
onIgnore: (): void => {
this._soundcache.get(Device.SoundName.Incoming).stop();
},
pstream: await (this._streamConnectedPromise || this._setupStream()),
publisher: this._publisher,
soundcache: this._soundcache,
};
options = Object.assign({
MediaStream: this._options.MediaStream || rtc.PeerConnection,
RTCPeerConnection: this._options.RTCPeerConnection,
beforeAccept: (currentCall: Call) => {
if (!this._activeCall || this._activeCall === currentCall) {
return;
}
this._activeCall.disconnect();
this._removeCall(this._activeCall);
},
codecPreferences: this._options.codecPreferences,
customSounds: this._options.sounds,
dialtonePlayer: Device._dialtonePlayer,
dscp: this._options.dscp,
// TODO(csantos): Remove forceAggressiveIceNomination option in 3.x
forceAggressiveIceNomination: this._options.forceAggressiveIceNomination,
getInputStream: (): MediaStream | null => this._options.fileInputStream || this._callInputStream,
getSinkIds: (): string[] => this._callSinkIds,
maxAverageBitrate: this._options.maxAverageBitrate,
preflight: this._options.preflight,
rtcConstraints: this._options.rtcConstraints,
shouldPlayDisconnect: () => this._audio?.disconnect(),
twimlParams,
voiceEventSidGenerator: this._options.voiceEventSidGenerator,
}, options);
const maybeUnsetPreferredUri = () => {
if (!this._stream) {
this._log.warn('UnsetPreferredUri called without a stream');
return;
}
if (this._activeCall === null && this._calls.length === 0) {
this._stream.updatePreferredURI(null);
}
};
const call = new (this._options.Call || Call)(config, options);
this._publisher.info('settings', 'init', {
RTCPeerConnection: !!this._options.RTCPeerConnection,
enumerateDevices: !!this._options.enumerateDevices,
getUserMedia: !!this._options.getUserMedia,
}, call);
call.once('accept', () => {
this._stream.updatePreferredURI(this._preferredURI);
this._removeCall(call);
this._activeCall = call;
if (this._audio) {
this._audio._maybeStartPollingVolume();
}
if (call.direction === Call.CallDirection.Outgoing && this._audio?.outgoing()) {
this._soundcache.get(Device.SoundName.Outgoing).play();
}
const data: any = { edge: this._edge || this._region };
if (this._options.edge) {
data['selected_edge'] = Array.isArray(this._options.edge)
? this._options.edge
: [this._options.edge];
}
this._publisher.info('settings', 'edge', data, call);
});
call.addListener('error', (error: TwilioError) => {
if (call.status() === 'closed') {
this._removeCall(call);
maybeUnsetPreferredUri();
}
if (this._audio) {
this._audio._maybeStopPollingVolume();
}
this._maybeStopIncomingSound();
});
call.once('cancel', () => {
this._log.info(`Canceled: ${call.parameters.CallSid}`);
this._removeCall(call);
maybeUnsetPreferredUri();
if (this._audio) {
this._audio._maybeStopPollingVolume();
}
this._maybeStopIncomingSound();
});
call.once('disconnect', () => {
if (this._audio) {
this._audio._maybeStopPollingVolume();
}
this._removeCall(call);
maybeUnsetPreferredUri();
/**
* NOTE(kamalbennani): We need to stop the incoming sound when the call is
* disconnected right after the user has accepted the call (activeCall.accept()), and before
* the call has been fully connected (i.e. before the `pstream.answer` event)
*/
this._maybeStopIncomingSound();
});
call.once('reject', () => {
this._log.info(`Rejected: ${call.parameters.CallSid}`);
if (this._audio) {
this._audio._maybeStopPollingVolume();
}
this._removeCall(call);
maybeUnsetPreferredUri();
this._maybeStopIncomingSound();
});
call.on('transportClose', () => {
if (call.status() !== Call.State.Pending) {
return;
}
if (this._audio) {
this._audio._maybeStopPollingVolume();
}
this._removeCall(call);
/**
* NOTE(mhuynh): We don't want to call `maybeUnsetPreferredUri` because
* a `transportClose` will happen during signaling reconnection.
*/
this._maybeStopIncomingSound();
});
return call;
}
/**
* Stop the incoming sound if no {@link Call}s remain.
*/
private _maybeStopIncomingSound(): void {
if (!this._calls.length) {
this._soundcache.get(Device.SoundName.Incoming).stop();
}
}
/**
* Called when a 'close' event is received from the signaling stream.
*/
private _onSignalingClose = () => {
this._stream = null;
this._streamConnectedPromise = null;
}
/**
* Called when a 'connected' event is received from the signaling stream.
*/
private _onSignalingConnected = (payload: Record<string, any>) => {
const region = getRegionShortcode(payload.region);
this._edge = payload.edge || regionToEdge[region as Region] || payload.region;
this._region = region || payload.region;
this._home = payload.home;
this._publisher?.setHost(createEventGatewayURI(payload.home));
if (payload.token) {
this._identity = payload.token.identity;
if (
typeof payload.token.ttl === 'number' &&
typeof this._options.tokenRefreshMs === 'number'
) {
const ttlMs: number = payload.token.ttl * 1000;
const timeoutMs: number = Math.max(0, ttlMs - this._options.tokenRefreshMs);
this._tokenWillExpireTimeout = setTimeout(() => {
this.emit('tokenWillExpire', this);
if (this._tokenWillExpireTimeout) {
clearTimeout(this._tokenWillExpireTimeout);
this._tokenWillExpireTimeout = null;
}
}, timeoutMs);
}
}
const preferredURIs = getChunderURIs(this._edge as Edge);
if (preferredURIs.length > 0) {
const [preferredURI] = preferredURIs;
this._preferredURI = createSignalingEndpointURL(preferredURI);
} else {
this._log.info('Could not parse a preferred URI from the stream#connected event.');
}
// The signaling stream emits a `connected` event after reconnection, if the
// device was registered before this, then register again.
if (this._shouldReRegister) {
this.register();
}
}
/**
* Called when an 'error' event is received from the signaling stream.
*/
private _onSignalingError = (payload: Record<string, any>) => {
if (typeof payload !== 'object') { return; }
const { error: originalError, callsid } = payload;
if (typeof originalError !== 'object') { return; }
const call: Call | undefined =
(typeof callsid === 'string' && this._findCall(callsid)) || undefined;
const { code, message: customMessage } = originalError;
let { twilioError } = originalError;
if (typeof code === 'number') {
if (code === 31201) {
twilioError = new AuthorizationErrors.AuthenticationFailed(originalError);
} else if (code === 31204) {
twilioError = new AuthorizationErrors.AccessTokenInvalid(originalError);
} else if (code === 31205) {
// Stop trying to register presence after token expires
this._stopRegistrationTimer();
twilioError = new AuthorizationErrors.AccessTokenExpired(originalError);
} else if (hasErrorByCode(code)) {
twilioError = new (getErrorByCode(code))(originalError);
}
}
if (!twilioError) {
this._log.error('Unknown signaling error: ', originalError);
twilioError = new GeneralErrors.UnknownError(customMessage, originalError);
}
this._log.info('Received error: ', twilioError);
this.emit(Device.EventName.Error, twilioError, call);
}
/**
* Called when an 'invite' event is received from the signaling stream.
*/
private _onSignalingInvite = async (payload: Record<string, any>) => {
const wasBusy = !!this._activeCall;
if (wasBusy && !this._options.allowIncomingWhileBusy) {
this._log.info('Device busy; ignoring incoming invite');
return;
}
if (!payload.callsid || !payload.sdp) {
this.emit(Device.EventName.Error, new ClientErrors.BadRequest('Malformed invite from gateway'));
return;
}
const callParameters = payload.parameters || { };
callParameters.CallSid = callParameters.CallSid || payload.callsid;
const customParameters = Object.assign({ }, queryToJson(callParameters.Params));
const call = await this._makeCall(customParameters, {
callParameters,
offerSdp: payload.sdp,
reconnectToken: payload.reconnect,
voiceEventSidGenerator: this._options.voiceEventSidGenerator,
});
this._calls.push(call);
call.once('accept', () => {
this._soundcache.get(Device.SoundName.Incoming).stop();
this._publishNetworkChange();
});
const play = (this._audio?.incoming() && !wasBusy)
? () => this._soundcache.get(Device.SoundName.Incoming).play()
: () => Promise.resolve();
this._showIncomingCall(call, play);
}
/**
* Called when an 'offline' event is received from the signaling stream.
*/
private _onSignalingOffline = () => {
this._log.info('Stream is offline');
this._edge = null;
this._region = null;
this._shouldReRegister = this.state !== Device.State.Unregistered;
this._setState(Device.State.Unregistered);
}
/**
* Called when a 'ready' event is received from the signaling stream.
*/
private _onSignalingReady = () => {
this._log.info('Stream is ready');
this._setState(Device.State.Registered);
}
/**
* Publish a NetworkInformation#change event to Insights if there's an active {@link Call}.
*/
private _publishNetworkChange = () => {
if (!this._activeCall) {
return;
}
if (this._networkInformation) {
this._publisher.info('network-information', 'network-change', {
connection_type: this._networkInformation.type,
downlink: this._networkInformation.downlink,
downlinkMax: this._networkInformation.downlinkMax,
effective_type: this._networkInformation.effectiveType,
rtt: this._networkInformation.rtt,
}, this._activeCall);
}
}
/**
* Remove a {@link Call} from device.calls by reference
* @param call
*/
private _removeCall(call: Call): void {
if (this._activeCall === call) {
this._activeCall = null;
}
for (let i = this._calls.length - 1; i >= 0; i--) {
if (call === this._calls[i]) {
this._calls.splice(i, 1);
}
}
}
/**
* Register with the signaling server.
*/
private async _sendPresence(presence: boolean): Promise<void> {
const stream = await this._streamConnectedPromise;
if (!stream) { return; }
stream.register({ audio: presence });
if (presence) {
this._startRegistrationTimer();
} else {
this._stopRegistrationTimer();
}
}
/**
* Helper function that sets and emits the state of the device.
* @param state The new state of the device.
*/
private _setState(state: Device.State): void {
if (state === this.state) {
return;
}
this._state = state;
this.emit(this._stateEventMapping[state]);
}
/**
* Set up an audio helper for usage by this {@link Device}.
*/
private _setupAudioHelper(): void {
const audioOptions: AudioHelper.Options = {
audioContext: Device.audioContext,
enumerateDevices: this._options.enumerateDevices,
};
if (this._audio) {
this._log.info('Found existing audio helper; destroying...');
audioOptions.enabledSounds = this._audio._getEnabledSounds();
this._destroyAudioHelper();
}
this._audio = new (this._options.AudioHelper || AudioHelper)(
this._updateSinkIds,
this._updateInputStream,
this._options.getUserMedia || getUserMedia,
audioOptions,
);
this._audio.on('deviceChange', (lostActiveDevices: MediaDeviceInfo[]) => {
const activeCall: Call | null = this._activeCall;
const deviceIds: string[] = lostActiveDevices.map((device: MediaDeviceInfo) => device.deviceId);
this._publisher.info('audio', 'device-change', {
lost_active_device_ids: deviceIds,
}, activeCall);
if (activeCall) {
activeCall['_mediaHandler']._onInputDevicesChanged();
}
});
}
/**
* Create and set a publisher for the {@link Device} to use.
*/
private _setupPublisher(): IPublisher {
if (this._publisher) {
this._log.info('Found existing publisher; destroying...');
this._destroyPublisher();
}
const publisherOptions = {
defaultPayload: this._createDefaultPayload,
log: this._log,
metadata: {
app_name: this._options.appName,
app_version: this._options.appVersion,
},
} as any;
if (this._options.eventgw) {
publisherOptions.host = this._options.eventgw;
}
if (this._home) {
publisherOptions.host = createEventGatewayURI(this._home);
}
this._publisher = new (this._options.Publisher || Publisher)(PUBLISHER_PRODUCT_NAME, this.token, publisherOptions);
if (this._options.publishEvents === false) {
this._publisher.disable();
} else {
this._publisher.on('error', (error: Error) => {
this._log.warn('Cannot connect to insights.', error);
});
}
return this._publisher;
}
/**
* Set up the connection to the signaling server. Tears down an existing
* stream if called while a stream exists.
*/
private _setupStream(): Promise<IPStream> {
if (this._stream) {
this._log.info('Found existing stream; destroying...');
this._destroyStream();
}
this._log.info('Setting up VSP');
this._stream = new (this._options.PStream || PStream)(
this.token,
this._chunderURIs,
{
backoffMaxMs: this._options.backoffMaxMs,
maxPreferredDurationMs: this._options.maxCallSignalingTimeoutMs,
},
);
this._stream.addListener('close', this._onSignalingClose);
this._stream.addListener('connected', this._onSignalingConnected);
this._stream.addListener('error', this._onSignalingError);
this._stream.addListener('invite', this._onSignalingInvite);
this._stream.addListener('offline', this._onSignalingOffline);
this._stream.addListener('ready', this._onSignalingReady);
return this._streamConnectedPromise = new Promise<IPStream>(resolve =>
this._stream.once('connected', () => {
resolve(this._stream);
}),
);
}
/**
* Start playing the incoming ringtone, and subsequently emit the incoming event.
* @param call
* @param play - The function to be used to play the sound. Must return a Promise.
*/
private _showIncomingCall(call: Call, play: Function): Promise<void> {
let timeout: NodeJS.Timer;
return Promise.race([
play(),
new Promise((resolve, reject) => {
timeout = setTimeout(() => {
const msg = 'Playing incoming ringtone took too long; it might not play. Continuing execution...';
reject(new Error(msg));
}, RINGTONE_PLAY_TIMEOUT);
}),
]).catch(reason => {
this._log.info(reason.message);
}).then(() => {
clearTimeout(timeout);
this.emit(Device.EventName.Incoming, call);
});
}
/**
* Set a timeout to send another register message to the signaling server.
*/
private _startRegistrationTimer(): void {
this._stopRegistrationTimer();
this._regTimer = setTimeout(() => {
this._sendPresence(true);
}, REGISTRATION_INTERVAL);
}
/**
* Stop sending registration messages to the signaling server.
*/
private _stopRegistrationTimer(): void {
if (this._regTimer) {
clearTimeout(this._regTimer);
}
}
/**
* Throw an error if the {@link Device} is destroyed.
*/
private _throwIfDestroyed(): void {
if (this.state === Device.State.Destroyed) {
throw new InvalidStateError('Device has been destroyed.');
}
}
/**
* Update the input stream being used for calls so that any current call and all future calls
* will use the new input stream.
* @param inputStream
*/
private _updateInputStream = (inputStream: MediaStream | null): Promise<void> => {
const call: Call | null = this._activeCall;
if (call && !inputStream) {
return Promise.reject(new InvalidStateError('Cannot unset input device while a call is in progress.'));
}
this._callInputStream = inputStream;
return call
? call._setInputTracksFromStream(inputStream)
: Promise.resolve();
}
/**
* Update the device IDs of output devices being used to play the incoming ringtone through.
* @param sinkIds - An array of device IDs
*/
private _updateRingtoneSinkIds(sinkIds: string[]): Promise<void> {
return Promise.resolve(this._soundcache.get(Device.SoundName.Incoming).setSinkIds(sinkIds));
}
/**
* Update the device IDs of output devices being used to play sounds through.
* @param type - Whether to update ringtone or speaker sounds
* @param sinkIds - An array of device IDs
*/
private _updateSinkIds = (type: 'ringtone' | 'speaker', sinkIds: string[]): Promise<void> => {
const promise: Promise<void> = type === 'ringtone'
? this._updateRingtoneSinkIds(sinkIds)
: this._updateSpeakerSinkIds(sinkIds);
return promise.then(() => {
this._publisher.info('audio', `${type}-devices-set`, {
audio_device_ids: sinkIds,
}, this._activeCall);
}, error => {
this._publisher.error('audio', `${type}-devices-set-failed`, {
audio_device_ids: sinkIds,
message: error.message,
}, this._activeCall);
throw error;
});
}
/**
* Update the device IDs of output devices being used to play the non-ringtone sounds
* and Call audio through.
* @param sinkIds - An array of device IDs
*/
private _updateSpeakerSinkIds(sinkIds: string[]): Promise<void> {
Array.from(this._soundcache.entries())
.filter(entry => entry[0] !== Device.SoundName.Incoming)
.forEach(entry => entry[1].setSinkIds(sinkIds));
this._callSinkIds = sinkIds;
const call = this._activeCall;
return call
? call._setSinkIds(sinkIds)
: Promise.resolve();
}
}
namespace Device {
/**
* Emitted when the {@link Device} receives an error.
* @param error
* @example `device.on('error', call => { })`
* @event
*/
declare function errorEvent(error: TwilioError, call?: Call): void;
/**
* Emitted when an incoming {@link Call} is received.
* @param call - The incoming {@link Call}.
* @example `device.on('incoming', call => { })`
* @event
*/
declare function incomingEvent(call: Call): void;
/**
* Emitted when the {@link Device} is unregistered.
* @param device
* @example `device.on('unregistered', device => { })`
* @event
*/
declare function unregisteredEvent(device: Device): void;
/**
* Emitted when the {@link Device} is registering.
* @param device
* @example `device.on('registering', device => { })`
* @event
*/
declare function registeringEvent(device: Device): void;
/**
* Emitted when the {@link Device} is registered.
* @param device
* @example `device.on('registered', device => { })`
* @event
*/
declare function registeredEvent(device: Device): void;
/**
* Emitted when the {@link Device}'s token is about to expire. Use DeviceOptions.refreshTokenMs
* to set a custom warning time. Default is 10000 (10 seconds) prior to the token expiring.
* @example `device.on('tokenWillExpire', () => {
* const token = getNewTokenViaAjax();
* device.updateToken(token);
* })`
* @event
*/
declare function tokenWillExpireEvent(device: Device): void;
/**
* All valid {@link Device} event names.
*/
export enum EventName {
Error = 'error',
Incoming = 'incoming',
Destroyed = 'destroyed',
Unregistered = 'unregistered',
Registering = 'registering',
Registered = 'registered',
TokenWillExpire = 'tokenWillExpire',
}
/**
* All possible {@link Device} states.
*/
export enum State {
Destroyed = 'destroyed',
Unregistered = 'unregistered',
Registering = 'registering',
Registered = 'registered',
}
/**
* Names of all sounds handled by the {@link Device}.
*/
export enum SoundName {
Incoming = 'incoming',
Outgoing = 'outgoing',
Disconnect = 'disconnect',
Dtmf0 = 'dtmf0',
Dtmf1 = 'dtmf1',
Dtmf2 = 'dtmf2',
Dtmf3 = 'dtmf3',
Dtmf4 = 'dtmf4',
Dtmf5 = 'dtmf5',
Dtmf6 = 'dtmf6',
Dtmf7 = 'dtmf7',
Dtmf8 = 'dtmf8',
Dtmf9 = 'dtmf9',
DtmfS = 'dtmfs',
DtmfH = 'dtmfh',
}
/**
* Names of all togglable sounds.
*/
export type ToggleableSound = Device.SoundName.Incoming | Device.SoundName.Outgoing | Device.SoundName.Disconnect;
/**
* Options to be passed to {@link Device.connect}.
*/
export interface ConnectOptions extends Call.AcceptOptions {
/**
* A flat object containing key:value pairs to be sent to the TwiML app.
*/
params?: Record<string, string>;
}
/**
* Options that may be passed to the {@link Device} constructor, or Device.setup via public API
*/
export interface Options {
/**
* Whether the Device should raise the {@link incomingEvent} event when a new call invite is
* received while already on an active call. Default behavior is false.
*/
allowIncomingWhileBusy?: boolean;
/**
* A name for the application that is instantiating the {@link Device}. This is used to improve logging
* in Insights by associating Insights data with a specific application, particularly in the case where
* one account may be connected to by multiple applications.
*/
appName?: string;
/**
* A version for the application that is instantiating the {@link Device}. This is used to improve logging
* in Insights by associating Insights data with a specific version of the given application. This can help
* track down when application-level bugs were introduced.
*/
appVersion?: string;
/**
* Whether to enable close protection, to prevent users from accidentally
* navigating away from the page during a call. If string, the value will
* be used as a custom message.
*/
closeProtection?: boolean | string;
/**
* An ordered array of codec names, from most to least preferred.
*/
codecPreferences?: Call.Codec[];
/**
* Whether AudioContext sounds should be disabled. Useful for trouble shooting sound issues
* that may be caused by AudioContext-specific s