jssip
Version:
the Javascript SIP library
1,996 lines (1,670 loc) • 89 kB
JavaScript
/* globals RTCPeerConnection: false, RTCSessionDescription: false */
const EventEmitter = require('events').EventEmitter;
const sdp_transform = require('sdp-transform');
const Logger = require('./Logger');
const JsSIP_C = require('./Constants');
const Exceptions = require('./Exceptions');
const Transactions = require('./Transactions');
const Utils = require('./Utils');
const Timers = require('./Timers');
const SIPMessage = require('./SIPMessage');
const Dialog = require('./Dialog');
const RequestSender = require('./RequestSender');
const RTCSession_DTMF = require('./RTCSession/DTMF');
const RTCSession_Info = require('./RTCSession/Info');
const RTCSession_ReferNotifier = require('./RTCSession/ReferNotifier');
const RTCSession_ReferSubscriber = require('./RTCSession/ReferSubscriber');
const URI = require('./URI');
const logger = new Logger('RTCSession');
const C = {
// RTCSession states.
STATUS_NULL : 0,
STATUS_INVITE_SENT : 1,
STATUS_1XX_RECEIVED : 2,
STATUS_INVITE_RECEIVED : 3,
STATUS_WAITING_FOR_ANSWER : 4,
STATUS_ANSWERED : 5,
STATUS_WAITING_FOR_ACK : 6,
STATUS_CANCELED : 7,
STATUS_TERMINATED : 8,
STATUS_CONFIRMED : 9
};
/**
* Local variables.
*/
const holdMediaTypes = [ 'audio', 'video' ];
module.exports = class RTCSession extends EventEmitter
{
/**
* Expose C object.
*/
static get C()
{
return C;
}
constructor(ua)
{
logger.debug('new');
super();
this._id = null;
this._ua = ua;
this._status = C.STATUS_NULL;
this._dialog = null;
this._earlyDialogs = {};
this._contact = null;
this._from_tag = null;
this._to_tag = null;
// The RTCPeerConnection instance (public attribute).
this._connection = null;
// Prevent races on serial PeerConnction operations.
this._connectionPromiseQueue = Promise.resolve();
// Incoming/Outgoing request being currently processed.
this._request = null;
// Cancel state for initial outgoing request.
this._is_canceled = false;
this._cancel_reason = '';
// RTCSession confirmation flag.
this._is_confirmed = false;
// Is late SDP being negotiated.
this._late_sdp = false;
// Default rtcOfferConstraints and rtcAnswerConstrainsts (passed in connect() or answer()).
this._rtcOfferConstraints = null;
this._rtcAnswerConstraints = null;
// Local MediaStream.
this._localMediaStream = null;
this._localMediaStreamLocallyGenerated = false;
// Flag to indicate PeerConnection ready for new actions.
this._rtcReady = true;
// Flag to indicate ICE candidate gathering is finished even if iceGatheringState is not yet 'complete'.
this._iceReady = false;
// SIP Timers.
this._timers = {
ackTimer : null,
expiresTimer : null,
invite2xxTimer : null,
userNoAnswerTimer : null
};
// Session info.
this._direction = null;
this._local_identity = null;
this._remote_identity = null;
this._start_time = null;
this._end_time = null;
this._tones = null;
// Mute/Hold state.
this._audioMuted = false;
this._videoMuted = false;
this._localHold = false;
this._remoteHold = false;
// Session Timers (RFC 4028).
this._sessionTimers = {
enabled : this._ua.configuration.session_timers,
refreshMethod : this._ua.configuration.session_timers_refresh_method,
defaultExpires : JsSIP_C.SESSION_EXPIRES,
currentExpires : null,
running : false,
refresher : false,
timer : null // A setTimeout.
};
// Map of ReferSubscriber instances indexed by the REFER's CSeq number.
this._referSubscribers = {};
// Custom session empty object for high level use.
this._data = {};
}
/**
* User API
*/
// Expose RTCSession constants as a property of the RTCSession instance.
get C()
{
return C;
}
// Expose session failed/ended causes as a property of the RTCSession instance.
get causes()
{
return JsSIP_C.causes;
}
get id()
{
return this._id;
}
get connection()
{
return this._connection;
}
get contact()
{
return this._contact;
}
get direction()
{
return this._direction;
}
get local_identity()
{
return this._local_identity;
}
get remote_identity()
{
return this._remote_identity;
}
get start_time()
{
return this._start_time;
}
get end_time()
{
return this._end_time;
}
get data()
{
return this._data;
}
set data(_data)
{
this._data = _data;
}
get status()
{
return this._status;
}
isInProgress()
{
switch (this._status)
{
case C.STATUS_NULL:
case C.STATUS_INVITE_SENT:
case C.STATUS_1XX_RECEIVED:
case C.STATUS_INVITE_RECEIVED:
case C.STATUS_WAITING_FOR_ANSWER:
return true;
default:
return false;
}
}
isEstablished()
{
switch (this._status)
{
case C.STATUS_ANSWERED:
case C.STATUS_WAITING_FOR_ACK:
case C.STATUS_CONFIRMED:
return true;
default:
return false;
}
}
isEnded()
{
switch (this._status)
{
case C.STATUS_CANCELED:
case C.STATUS_TERMINATED:
return true;
default:
return false;
}
}
isMuted()
{
return {
audio : this._audioMuted,
video : this._videoMuted
};
}
isOnHold()
{
return {
local : this._localHold,
remote : this._remoteHold
};
}
connect(target, options = {}, initCallback)
{
logger.debug('connect()');
const originalTarget = target;
const eventHandlers = Utils.cloneObject(options.eventHandlers);
const extraHeaders = Utils.cloneArray(options.extraHeaders);
const mediaConstraints = Utils.cloneObject(options.mediaConstraints, {
audio : true,
video : true
});
const mediaStream = options.mediaStream || null;
const pcConfig = Utils.cloneObject(options.pcConfig, { iceServers: [] });
const rtcConstraints = options.rtcConstraints || null;
const rtcOfferConstraints = options.rtcOfferConstraints || null;
this._rtcOfferConstraints = rtcOfferConstraints;
this._rtcAnswerConstraints = options.rtcAnswerConstraints || null;
this._data = options.data || this._data;
// Check target.
if (target === undefined)
{
throw new TypeError('Not enough arguments');
}
// Check Session Status.
if (this._status !== C.STATUS_NULL)
{
throw new Exceptions.InvalidStateError(this._status);
}
// Check WebRTC support.
if (!window.RTCPeerConnection)
{
throw new Exceptions.NotSupportedError('WebRTC not supported');
}
// Check target validity.
target = this._ua.normalizeTarget(target);
if (!target)
{
throw new TypeError(`Invalid target: ${originalTarget}`);
}
// Session Timers.
if (this._sessionTimers.enabled)
{
if (Utils.isDecimal(options.sessionTimersExpires))
{
if (options.sessionTimersExpires >= JsSIP_C.MIN_SESSION_EXPIRES)
{
this._sessionTimers.defaultExpires = options.sessionTimersExpires;
}
else
{
this._sessionTimers.defaultExpires = JsSIP_C.SESSION_EXPIRES;
}
}
}
// Set event handlers.
for (const event in eventHandlers)
{
if (Object.prototype.hasOwnProperty.call(eventHandlers, event))
{
this.on(event, eventHandlers[event]);
}
}
// Session parameter initialization.
this._from_tag = Utils.newTag();
// Set anonymous property.
const anonymous = options.anonymous || false;
const requestParams = { from_tag: this._from_tag };
this._contact = this._ua.contact.toString({
anonymous,
outbound : true
});
if (anonymous)
{
requestParams.from_display_name = 'Anonymous';
requestParams.from_uri = new URI('sip', 'anonymous', 'anonymous.invalid');
extraHeaders.push(`P-Preferred-Identity: ${this._ua.configuration.uri.toString()}`);
extraHeaders.push('Privacy: id');
}
else if (options.fromUserName)
{
requestParams.from_uri = new URI('sip', options.fromUserName, this._ua.configuration.uri.host);
extraHeaders.push(`P-Preferred-Identity: ${this._ua.configuration.uri.toString()}`);
}
if (options.fromDisplayName)
{
requestParams.from_display_name = options.fromDisplayName;
}
extraHeaders.push(`Contact: ${this._contact}`);
extraHeaders.push('Content-Type: application/sdp');
if (this._sessionTimers.enabled)
{
extraHeaders.push(`Session-Expires: ${this._sessionTimers.defaultExpires}${this._ua.configuration.session_timers_force_refresher ? ';refresher=uac' : ''}`);
}
this._request = new SIPMessage.InitialOutgoingInviteRequest(
target, this._ua, requestParams, extraHeaders);
this._id = this._request.call_id + this._from_tag;
// Create a new RTCPeerConnection instance.
this._createRTCConnection(pcConfig, rtcConstraints);
// Set internal properties.
this._direction = 'outgoing';
this._local_identity = this._request.from;
this._remote_identity = this._request.to;
// User explicitly provided a newRTCSession callback for this session.
if (initCallback)
{
initCallback(this);
}
this._newRTCSession('local', this._request);
this._sendInitialRequest(mediaConstraints, rtcOfferConstraints, mediaStream);
}
init_incoming(request, initCallback)
{
logger.debug('init_incoming()');
let expires;
const contentType = request.hasHeader('Content-Type') ?
request.getHeader('Content-Type').toLowerCase() : undefined;
// Check body and content type.
if (request.body && (contentType !== 'application/sdp'))
{
request.reply(415);
return;
}
// Session parameter initialization.
this._status = C.STATUS_INVITE_RECEIVED;
this._from_tag = request.from_tag;
this._id = request.call_id + this._from_tag;
this._request = request;
this._contact = this._ua.contact.toString();
// Get the Expires header value if exists.
if (request.hasHeader('expires'))
{
expires = request.getHeader('expires') * 1000;
}
/* Set the to_tag before
* replying a response code that will create a dialog.
*/
request.to_tag = Utils.newTag();
// An error on dialog creation will fire 'failed' event.
if (!this._createDialog(request, 'UAS', true))
{
request.reply(500, 'Missing Contact header field');
return;
}
if (request.body)
{
this._late_sdp = false;
}
else
{
this._late_sdp = true;
}
this._status = C.STATUS_WAITING_FOR_ANSWER;
// Set userNoAnswerTimer.
this._timers.userNoAnswerTimer = setTimeout(() =>
{
request.reply(408);
this._failed('local', null, JsSIP_C.causes.NO_ANSWER);
}, this._ua.configuration.no_answer_timeout
);
/* Set expiresTimer
* RFC3261 13.3.1
*/
if (expires)
{
this._timers.expiresTimer = setTimeout(() =>
{
if (this._status === C.STATUS_WAITING_FOR_ANSWER)
{
request.reply(487);
this._failed('system', null, JsSIP_C.causes.EXPIRES);
}
}, expires
);
}
// Set internal properties.
this._direction = 'incoming';
this._local_identity = request.to;
this._remote_identity = request.from;
// A init callback was specifically defined.
if (initCallback)
{
initCallback(this);
}
// Fire 'newRTCSession' event.
this._newRTCSession('remote', request);
// The user may have rejected the call in the 'newRTCSession' event.
if (this._status === C.STATUS_TERMINATED)
{
return;
}
// Reply 180.
request.reply(180, null, [ `Contact: ${this._contact}` ]);
// Fire 'progress' event.
// TODO: Document that 'response' field in 'progress' event is null for incoming calls.
this._progress('local', null);
}
/**
* Answer the call.
*/
answer(options = {})
{
logger.debug('answer()');
const request = this._request;
const extraHeaders = Utils.cloneArray(options.extraHeaders);
const mediaConstraints = Utils.cloneObject(options.mediaConstraints);
const mediaStream = options.mediaStream || null;
const pcConfig = Utils.cloneObject(options.pcConfig, { iceServers: [] });
const rtcConstraints = options.rtcConstraints || null;
const rtcAnswerConstraints = options.rtcAnswerConstraints || null;
const rtcOfferConstraints = Utils.cloneObject(options.rtcOfferConstraints);
let tracks;
let peerHasAudioLine = false;
let peerHasVideoLine = false;
let peerOffersFullAudio = false;
let peerOffersFullVideo = false;
this._rtcAnswerConstraints = rtcAnswerConstraints;
this._rtcOfferConstraints = options.rtcOfferConstraints || null;
this._data = options.data || this._data;
// Check Session Direction and Status.
if (this._direction !== 'incoming')
{
throw new Exceptions.NotSupportedError('"answer" not supported for outgoing RTCSession');
}
// Check Session status.
if (this._status !== C.STATUS_WAITING_FOR_ANSWER)
{
throw new Exceptions.InvalidStateError(this._status);
}
// Session Timers.
if (this._sessionTimers.enabled)
{
if (Utils.isDecimal(options.sessionTimersExpires))
{
if (options.sessionTimersExpires >= JsSIP_C.MIN_SESSION_EXPIRES)
{
this._sessionTimers.defaultExpires = options.sessionTimersExpires;
}
else
{
this._sessionTimers.defaultExpires = JsSIP_C.SESSION_EXPIRES;
}
}
}
this._status = C.STATUS_ANSWERED;
// An error on dialog creation will fire 'failed' event.
if (!this._createDialog(request, 'UAS'))
{
request.reply(500, 'Error creating dialog');
return;
}
clearTimeout(this._timers.userNoAnswerTimer);
extraHeaders.unshift(`Contact: ${this._contact}`);
// Determine incoming media from incoming SDP offer (if any).
const sdp = request.parseSDP();
// Make sure sdp.media is an array, not the case if there is only one media.
if (!Array.isArray(sdp.media))
{
sdp.media = [ sdp.media ];
}
// Go through all medias in SDP to find offered capabilities to answer with.
for (const m of sdp.media)
{
if (m.type === 'audio')
{
peerHasAudioLine = true;
if (!m.direction || m.direction === 'sendrecv')
{
peerOffersFullAudio = true;
}
}
if (m.type === 'video')
{
peerHasVideoLine = true;
if (!m.direction || m.direction === 'sendrecv')
{
peerOffersFullVideo = true;
}
}
}
// Remove audio from mediaStream if suggested by mediaConstraints.
if (mediaStream && mediaConstraints.audio === false)
{
tracks = mediaStream.getAudioTracks();
for (const track of tracks)
{
mediaStream.removeTrack(track);
}
}
// Remove video from mediaStream if suggested by mediaConstraints.
if (mediaStream && mediaConstraints.video === false)
{
tracks = mediaStream.getVideoTracks();
for (const track of tracks)
{
mediaStream.removeTrack(track);
}
}
// Set audio constraints based on incoming stream if not supplied.
if (!mediaStream && mediaConstraints.audio === undefined)
{
mediaConstraints.audio = peerOffersFullAudio;
}
// Set video constraints based on incoming stream if not supplied.
if (!mediaStream && mediaConstraints.video === undefined)
{
mediaConstraints.video = peerOffersFullVideo;
}
// Don't ask for audio if the incoming offer has no audio section.
if (!mediaStream && !peerHasAudioLine && !rtcOfferConstraints.offerToReceiveAudio)
{
mediaConstraints.audio = false;
}
// Don't ask for video if the incoming offer has no video section.
if (!mediaStream && !peerHasVideoLine && !rtcOfferConstraints.offerToReceiveVideo)
{
mediaConstraints.video = false;
}
// Create a new RTCPeerConnection instance.
// TODO: This may throw an error, should react.
this._createRTCConnection(pcConfig, rtcConstraints);
Promise.resolve()
// Handle local MediaStream.
.then(() =>
{
// A local MediaStream is given, use it.
if (mediaStream)
{
return mediaStream;
}
// Audio and/or video requested, prompt getUserMedia.
else if (mediaConstraints.audio || mediaConstraints.video)
{
this._localMediaStreamLocallyGenerated = true;
return navigator.mediaDevices.getUserMedia(mediaConstraints)
.catch((error) =>
{
if (this._status === C.STATUS_TERMINATED)
{
throw new Error('terminated');
}
request.reply(480);
this._failed('local', null, JsSIP_C.causes.USER_DENIED_MEDIA_ACCESS);
logger.warn('emit "getusermediafailed" [error:%o]', error);
this.emit('getusermediafailed', error);
throw new Error('getUserMedia() failed');
});
}
})
// Attach MediaStream to RTCPeerconnection.
.then((stream) =>
{
if (this._status === C.STATUS_TERMINATED)
{
throw new Error('terminated');
}
this._localMediaStream = stream;
if (stream)
{
stream.getTracks().forEach((track) =>
{
this._connection.addTrack(track, stream);
});
}
})
// Set remote description.
.then(() =>
{
if (this._late_sdp)
{
return;
}
const e = { originator: 'remote', type: 'offer', sdp: request.body };
logger.debug('emit "sdp"');
this.emit('sdp', e);
const offer = new RTCSessionDescription({ type: 'offer', sdp: e.sdp });
this._connectionPromiseQueue = this._connectionPromiseQueue
.then(() => this._connection.setRemoteDescription(offer))
.catch((error) =>
{
request.reply(488);
this._failed('system', null, JsSIP_C.causes.WEBRTC_ERROR);
logger.warn('emit "peerconnection:setremotedescriptionfailed" [error:%o]', error);
this.emit('peerconnection:setremotedescriptionfailed', error);
throw new Error('peerconnection.setRemoteDescription() failed');
});
return this._connectionPromiseQueue;
})
// Create local description.
.then(() =>
{
if (this._status === C.STATUS_TERMINATED)
{
throw new Error('terminated');
}
// TODO: Is this event already useful?
this._connecting(request);
if (!this._late_sdp)
{
return this._createLocalDescription('answer', rtcAnswerConstraints)
.catch(() =>
{
request.reply(500);
throw new Error('_createLocalDescription() failed');
});
}
else
{
return this._createLocalDescription('offer', this._rtcOfferConstraints)
.catch(() =>
{
request.reply(500);
throw new Error('_createLocalDescription() failed');
});
}
})
// Send reply.
.then((desc) =>
{
if (this._status === C.STATUS_TERMINATED)
{
throw new Error('terminated');
}
this._handleSessionTimersInIncomingRequest(request, extraHeaders);
request.reply(200, null, extraHeaders,
desc,
() =>
{
this._status = C.STATUS_WAITING_FOR_ACK;
this._setInvite2xxTimer(request, desc);
this._setACKTimer();
this._accepted('local');
},
() =>
{
this._failed('system', null, JsSIP_C.causes.CONNECTION_ERROR);
}
);
})
.catch((error) =>
{
if (this._status === C.STATUS_TERMINATED)
{
return;
}
logger.warn(error);
});
}
/**
* Terminate the call.
*/
terminate(options = {})
{
logger.debug('terminate()');
const cause = options.cause || JsSIP_C.causes.BYE;
const extraHeaders = Utils.cloneArray(options.extraHeaders);
const body = options.body;
let cancel_reason;
let status_code = options.status_code;
let reason_phrase = options.reason_phrase;
// Check Session Status.
if (this._status === C.STATUS_TERMINATED)
{
throw new Exceptions.InvalidStateError(this._status);
}
switch (this._status)
{
// - UAC -
case C.STATUS_NULL:
case C.STATUS_INVITE_SENT:
case C.STATUS_1XX_RECEIVED:
logger.debug('canceling session');
if (status_code && (status_code < 200 || status_code >= 700))
{
throw new TypeError(`Invalid status_code: ${status_code}`);
}
else if (status_code)
{
reason_phrase = reason_phrase || JsSIP_C.REASON_PHRASE[status_code] || '';
cancel_reason = `SIP ;cause=${status_code} ;text="${reason_phrase}"`;
}
// Check Session Status.
if (this._status === C.STATUS_NULL || this._status === C.STATUS_INVITE_SENT)
{
this._is_canceled = true;
this._cancel_reason = cancel_reason;
}
else if (this._status === C.STATUS_1XX_RECEIVED)
{
this._request.cancel(cancel_reason);
}
this._status = C.STATUS_CANCELED;
this._failed('local', null, JsSIP_C.causes.CANCELED);
break;
// - UAS -
case C.STATUS_WAITING_FOR_ANSWER:
case C.STATUS_ANSWERED:
logger.debug('rejecting session');
status_code = status_code || 480;
if (status_code < 300 || status_code >= 700)
{
throw new TypeError(`Invalid status_code: ${status_code}`);
}
this._request.reply(status_code, reason_phrase, extraHeaders, body);
this._failed('local', null, JsSIP_C.causes.REJECTED);
break;
case C.STATUS_WAITING_FOR_ACK:
case C.STATUS_CONFIRMED:
logger.debug('terminating session');
reason_phrase = options.reason_phrase || JsSIP_C.REASON_PHRASE[status_code] || '';
if (status_code && (status_code < 200 || status_code >= 700))
{
throw new TypeError(`Invalid status_code: ${status_code}`);
}
else if (status_code)
{
extraHeaders.push(`Reason: SIP ;cause=${status_code}; text="${reason_phrase}"`);
}
/* RFC 3261 section 15 (Terminating a session):
*
* "...the callee's UA MUST NOT send a BYE on a confirmed dialog
* until it has received an ACK for its 2xx response or until the server
* transaction times out."
*/
if (this._status === C.STATUS_WAITING_FOR_ACK &&
this._direction === 'incoming' &&
this._request.server_transaction.state !== Transactions.C.STATUS_TERMINATED)
{
// Save the dialog for later restoration.
const dialog = this._dialog;
// Send the BYE as soon as the ACK is received...
this.receiveRequest = ({ method }) =>
{
if (method === JsSIP_C.ACK)
{
this.sendRequest(JsSIP_C.BYE, {
extraHeaders,
body
});
dialog.terminate();
}
};
// .., or when the INVITE transaction times out
this._request.server_transaction.on('stateChanged', () =>
{
if (this._request.server_transaction.state ===
Transactions.C.STATUS_TERMINATED)
{
this.sendRequest(JsSIP_C.BYE, {
extraHeaders,
body
});
dialog.terminate();
}
});
this._ended('local', null, cause);
// Restore the dialog into 'this' in order to be able to send the in-dialog BYE :-).
this._dialog = dialog;
// Restore the dialog into 'ua' so the ACK can reach 'this' session.
this._ua.newDialog(dialog);
}
else
{
this.sendRequest(JsSIP_C.BYE, {
extraHeaders,
body
});
this._ended('local', null, cause);
}
}
}
sendDTMF(tones, options = {})
{
logger.debug('sendDTMF() | tones: %s', tones);
let position = 0;
let duration = options.duration || null;
let interToneGap = options.interToneGap || null;
const transportType = options.transportType || JsSIP_C.DTMF_TRANSPORT.INFO;
if (tones === undefined)
{
throw new TypeError('Not enough arguments');
}
// Check Session Status.
if (
this._status !== C.STATUS_CONFIRMED &&
this._status !== C.STATUS_WAITING_FOR_ACK &&
this._status !== C.STATUS_1XX_RECEIVED
)
{
throw new Exceptions.InvalidStateError(this._status);
}
// Check Transport type.
if (
transportType !== JsSIP_C.DTMF_TRANSPORT.INFO &&
transportType !== JsSIP_C.DTMF_TRANSPORT.RFC2833
)
{
throw new TypeError(`invalid transportType: ${transportType}`);
}
// Convert to string.
if (typeof tones === 'number')
{
tones = tones.toString();
}
// Check tones.
if (!tones || typeof tones !== 'string' || !tones.match(/^[0-9A-DR#*,]+$/i))
{
throw new TypeError(`Invalid tones: ${tones}`);
}
// Check duration.
if (duration && !Utils.isDecimal(duration))
{
throw new TypeError(`Invalid tone duration: ${duration}`);
}
else if (!duration)
{
duration = RTCSession_DTMF.C.DEFAULT_DURATION;
}
else if (duration < RTCSession_DTMF.C.MIN_DURATION)
{
logger.debug(`"duration" value is lower than the minimum allowed, setting it to ${RTCSession_DTMF.C.MIN_DURATION} milliseconds`);
duration = RTCSession_DTMF.C.MIN_DURATION;
}
else if (duration > RTCSession_DTMF.C.MAX_DURATION)
{
logger.debug(`"duration" value is greater than the maximum allowed, setting it to ${RTCSession_DTMF.C.MAX_DURATION} milliseconds`);
duration = RTCSession_DTMF.C.MAX_DURATION;
}
else
{
duration = Math.abs(duration);
}
options.duration = duration;
// Check interToneGap.
if (interToneGap && !Utils.isDecimal(interToneGap))
{
throw new TypeError(`Invalid interToneGap: ${interToneGap}`);
}
else if (!interToneGap)
{
interToneGap = RTCSession_DTMF.C.DEFAULT_INTER_TONE_GAP;
}
else if (interToneGap < RTCSession_DTMF.C.MIN_INTER_TONE_GAP)
{
logger.debug(`"interToneGap" value is lower than the minimum allowed, setting it to ${RTCSession_DTMF.C.MIN_INTER_TONE_GAP} milliseconds`);
interToneGap = RTCSession_DTMF.C.MIN_INTER_TONE_GAP;
}
else
{
interToneGap = Math.abs(interToneGap);
}
// RFC2833. Let RTCDTMFSender enqueue the DTMFs.
if (transportType === JsSIP_C.DTMF_TRANSPORT.RFC2833)
{
// Send DTMF in current audio RTP stream.
const sender = this._getDTMFRTPSender();
if (sender)
{
// Add remaining buffered tones.
tones = sender.toneBuffer + tones;
// Insert tones.
sender.insertDTMF(tones, duration, interToneGap);
}
return;
}
if (this._tones)
{
// Tones are already queued, just add to the queue.
this._tones += tones;
return;
}
this._tones = tones;
// Send the first tone.
_sendDTMF.call(this);
function _sendDTMF()
{
let timeout;
if (this._status === C.STATUS_TERMINATED ||
!this._tones || position >= this._tones.length)
{
// Stop sending DTMF.
this._tones = null;
return;
}
const tone = this._tones[position];
position += 1;
if (tone === ',')
{
timeout = 2000;
}
else
{
// Send DTMF via SIP INFO messages.
const dtmf = new RTCSession_DTMF(this);
options.eventHandlers = {
onFailed : () => { this._tones = null; }
};
dtmf.send(tone, options);
timeout = duration + interToneGap;
}
// Set timeout for the next tone.
setTimeout(_sendDTMF.bind(this), timeout);
}
}
sendInfo(contentType, body, options = {})
{
logger.debug('sendInfo()');
// Check Session Status.
if (
this._status !== C.STATUS_CONFIRMED &&
this._status !== C.STATUS_WAITING_FOR_ACK &&
this._status !== C.STATUS_1XX_RECEIVED
)
{
throw new Exceptions.InvalidStateError(this._status);
}
const info = new RTCSession_Info(this);
info.send(contentType, body, options);
}
/**
* Mute
*/
mute(options = { audio: true, video: false })
{
logger.debug('mute()');
let audioMuted = false, videoMuted = false;
if (this._audioMuted === false && options.audio)
{
audioMuted = true;
this._audioMuted = true;
this._toggleMuteAudio(true);
}
if (this._videoMuted === false && options.video)
{
videoMuted = true;
this._videoMuted = true;
this._toggleMuteVideo(true);
}
if (audioMuted === true || videoMuted === true)
{
this._onmute({
audio : audioMuted,
video : videoMuted
});
}
}
/**
* Unmute
*/
unmute(options = { audio: true, video: true })
{
logger.debug('unmute()');
let audioUnMuted = false, videoUnMuted = false;
if (this._audioMuted === true && options.audio)
{
audioUnMuted = true;
this._audioMuted = false;
if (this._localHold === false)
{
this._toggleMuteAudio(false);
}
}
if (this._videoMuted === true && options.video)
{
videoUnMuted = true;
this._videoMuted = false;
if (this._localHold === false)
{
this._toggleMuteVideo(false);
}
}
if (audioUnMuted === true || videoUnMuted === true)
{
this._onunmute({
audio : audioUnMuted,
video : videoUnMuted
});
}
}
/**
* Hold
*/
hold(options = {}, done)
{
logger.debug('hold()');
if (this._status !== C.STATUS_WAITING_FOR_ACK && this._status !== C.STATUS_CONFIRMED)
{
return false;
}
if (this._localHold === true)
{
return false;
}
if (!this._isReadyToReOffer())
{
return false;
}
this._localHold = true;
this._onhold('local');
const eventHandlers = {
succeeded : () =>
{
if (done) { done(); }
},
failed : () =>
{
this.terminate({
cause : JsSIP_C.causes.WEBRTC_ERROR,
status_code : 500,
reason_phrase : 'Hold Failed'
});
}
};
if (options.useUpdate)
{
this._sendUpdate({
sdpOffer : true,
eventHandlers,
extraHeaders : options.extraHeaders
});
}
else
{
this._sendReinvite({
eventHandlers,
extraHeaders : options.extraHeaders
});
}
return true;
}
unhold(options = {}, done)
{
logger.debug('unhold()');
if (this._status !== C.STATUS_WAITING_FOR_ACK && this._status !== C.STATUS_CONFIRMED)
{
return false;
}
if (this._localHold === false)
{
return false;
}
if (!this._isReadyToReOffer())
{
return false;
}
this._localHold = false;
this._onunhold('local');
const eventHandlers = {
succeeded : () =>
{
if (done) { done(); }
},
failed : () =>
{
this.terminate({
cause : JsSIP_C.causes.WEBRTC_ERROR,
status_code : 500,
reason_phrase : 'Unhold Failed'
});
}
};
if (options.useUpdate)
{
this._sendUpdate({
sdpOffer : true,
eventHandlers,
extraHeaders : options.extraHeaders
});
}
else
{
this._sendReinvite({
eventHandlers,
extraHeaders : options.extraHeaders
});
}
return true;
}
renegotiate(options = {}, done)
{
logger.debug('renegotiate()');
const rtcOfferConstraints = options.rtcOfferConstraints || null;
if (this._status !== C.STATUS_WAITING_FOR_ACK && this._status !== C.STATUS_CONFIRMED)
{
return false;
}
if (!this._isReadyToReOffer())
{
return false;
}
const eventHandlers = {
succeeded : () =>
{
if (done) { done(); }
},
failed : () =>
{
this.terminate({
cause : JsSIP_C.causes.WEBRTC_ERROR,
status_code : 500,
reason_phrase : 'Media Renegotiation Failed'
});
}
};
this._setLocalMediaStatus();
if (options.useUpdate)
{
this._sendUpdate({
sdpOffer : true,
eventHandlers,
rtcOfferConstraints,
extraHeaders : options.extraHeaders
});
}
else
{
this._sendReinvite({
eventHandlers,
rtcOfferConstraints,
extraHeaders : options.extraHeaders
});
}
return true;
}
/**
* Refer
*/
refer(target, options)
{
logger.debug('refer()');
const originalTarget = target;
if (this._status !== C.STATUS_WAITING_FOR_ACK && this._status !== C.STATUS_CONFIRMED)
{
return false;
}
// Check target validity.
target = this._ua.normalizeTarget(target);
if (!target)
{
throw new TypeError(`Invalid target: ${originalTarget}`);
}
const referSubscriber = new RTCSession_ReferSubscriber(this);
referSubscriber.sendRefer(target, options);
// Store in the map.
const id = referSubscriber.id;
this._referSubscribers[id] = referSubscriber;
// Listen for ending events so we can remove it from the map.
referSubscriber.on('requestFailed', () =>
{
delete this._referSubscribers[id];
});
referSubscriber.on('accepted', () =>
{
delete this._referSubscribers[id];
});
referSubscriber.on('failed', () =>
{
delete this._referSubscribers[id];
});
return referSubscriber;
}
/**
* Send a generic in-dialog Request
*/
sendRequest(method, options)
{
logger.debug('sendRequest()');
return this._dialog.sendRequest(method, options);
}
/**
* In dialog Request Reception
*/
receiveRequest(request)
{
logger.debug('receiveRequest()');
if (request.method === JsSIP_C.CANCEL)
{
/* RFC3261 15 States that a UAS may have accepted an invitation while a CANCEL
* was in progress and that the UAC MAY continue with the session established by
* any 2xx response, or MAY terminate with BYE. JsSIP does continue with the
* established session. So the CANCEL is processed only if the session is not yet
* established.
*/
/*
* Terminate the whole session in case the user didn't accept (or yet send the answer)
* nor reject the request opening the session.
*/
if (this._status === C.STATUS_WAITING_FOR_ANSWER ||
this._status === C.STATUS_ANSWERED)
{
this._status = C.STATUS_CANCELED;
this._request.reply(487);
this._failed('remote', request, JsSIP_C.causes.CANCELED);
}
}
else
{
// Requests arriving here are in-dialog requests.
switch (request.method)
{
case JsSIP_C.ACK:
if (this._status !== C.STATUS_WAITING_FOR_ACK)
{
return;
}
// Update signaling status.
this._status = C.STATUS_CONFIRMED;
clearTimeout(this._timers.ackTimer);
clearTimeout(this._timers.invite2xxTimer);
if (this._late_sdp)
{
if (!request.body)
{
this.terminate({
cause : JsSIP_C.causes.MISSING_SDP,
status_code : 400
});
break;
}
const e = { originator: 'remote', type: 'answer', sdp: request.body };
logger.debug('emit "sdp"');
this.emit('sdp', e);
const answer = new RTCSessionDescription({ type: 'answer', sdp: e.sdp });
this._connectionPromiseQueue = this._connectionPromiseQueue
.then(() => this._connection.setRemoteDescription(answer))
.then(() =>
{
if (!this._is_confirmed)
{
this._confirmed('remote', request);
}
})
.catch((error) =>
{
this.terminate({
cause : JsSIP_C.causes.BAD_MEDIA_DESCRIPTION,
status_code : 488
});
logger.warn('emit "peerconnection:setremotedescriptionfailed" [error:%o]', error);
this.emit('peerconnection:setremotedescriptionfailed', error);
});
}
else
if (!this._is_confirmed)
{
this._confirmed('remote', request);
}
break;
case JsSIP_C.BYE:
if (this._status === C.STATUS_CONFIRMED ||
this._status === C.STATUS_WAITING_FOR_ACK)
{
request.reply(200);
this._ended('remote', request, JsSIP_C.causes.BYE);
}
else if (this._status === C.STATUS_INVITE_RECEIVED ||
this._status === C.STATUS_WAITING_FOR_ANSWER)
{
request.reply(200);
this._request.reply(487, 'BYE Received');
this._ended('remote', request, JsSIP_C.causes.BYE);
}
else
{
request.reply(403, 'Wrong Status');
}
break;
case JsSIP_C.INVITE:
if (this._status === C.STATUS_CONFIRMED)
{
if (request.hasHeader('replaces'))
{
this._receiveReplaces(request);
}
else
{
this._receiveReinvite(request);
}
}
else
{
request.reply(403, 'Wrong Status');
}
break;
case JsSIP_C.INFO:
if (this._status === C.STATUS_1XX_RECEIVED ||
this._status === C.STATUS_WAITING_FOR_ANSWER ||
this._status === C.STATUS_ANSWERED ||
this._status === C.STATUS_WAITING_FOR_ACK ||
this._status === C.STATUS_CONFIRMED)
{
const contentType = request.hasHeader('Content-Type') ?
request.getHeader('Content-Type').toLowerCase() : undefined;
if (contentType && (contentType.match(/^application\/dtmf-relay/i)))
{
new RTCSession_DTMF(this).init_incoming(request);
}
else if (contentType !== undefined)
{
new RTCSession_Info(this).init_incoming(request);
}
else
{
request.reply(415);
}
}
else
{
request.reply(403, 'Wrong Status');
}
break;
case JsSIP_C.UPDATE:
if (this._status === C.STATUS_CONFIRMED)
{
this._receiveUpdate(request);
}
else
{
request.reply(403, 'Wrong Status');
}
break;
case JsSIP_C.REFER:
if (this._status === C.STATUS_CONFIRMED)
{
this._receiveRefer(request);
}
else
{
request.reply(403, 'Wrong Status');
}
break;
case JsSIP_C.NOTIFY:
if (this._status === C.STATUS_CONFIRMED)
{
this._receiveNotify(request);
}
else
{
request.reply(403, 'Wrong Status');
}
break;
default:
request.reply(501);
}
}
}
/**
* Session Callbacks
*/
onTransportError()
{
logger.warn('onTransportError()');
if (this._status !== C.STATUS_TERMINATED)
{
this.terminate({
status_code : 500,
reason_phrase : JsSIP_C.causes.CONNECTION_ERROR,
cause : JsSIP_C.causes.CONNECTION_ERROR
});
}
}
onRequestTimeout()
{
logger.warn('onRequestTimeout()');
if (this._status !== C.STATUS_TERMINATED)
{
this.terminate({
status_code : 408,
reason_phrase : JsSIP_C.causes.REQUEST_TIMEOUT,
cause : JsSIP_C.causes.REQUEST_TIMEOUT
});
}
}
onDialogError()
{
logger.warn('onDialogError()');
if (this._status !== C.STATUS_TERMINATED)
{
this.terminate({
status_code : 500,
reason_phrase : JsSIP_C.causes.DIALOG_ERROR,
cause : JsSIP_C.causes.DIALOG_ERROR
});
}
}
// Called from DTMF handler.
newDTMF(data)
{
logger.debug('newDTMF()');
this.emit('newDTMF', data);
}
// Called from Info handler.
newInfo(data)
{
logger.debug('newInfo()');
this.emit('newInfo', data);
}
/**
* Check if RTCSession is ready for an outgoing re-INVITE or UPDATE with SDP.
*/
_isReadyToReOffer()
{
if (!this._rtcReady)
{
logger.debug('_isReadyToReOffer() | internal WebRTC status not ready');
return false;
}
// No established yet.
if (!this._dialog)
{
logger.debug('_isReadyToReOffer() | session not established yet');
return false;
}
// Another INVITE transaction is in progress.
if (this._dialog.uac_pending_reply === true ||
this._dialog.uas_pending_reply === true)
{
logger.debug('_isReadyToReOffer() | there is another INVITE/UPDATE transaction in progress');
return false;
}
return true;
}
_close()
{
logger.debug('close()');
// Close local MediaStream if it was not given by the user.
if (this._localMediaStream && this._localMediaStreamLocallyGenerated)
{
logger.debug('close() | closing local MediaStream');
Utils.closeMediaStream(this._localMediaStream);
}
if (this._status === C.STATUS_TERMINATED)
{
return;
}
this._status = C.STATUS_TERMINATED;
// Terminate RTC.
if (this._connection)
{
try
{
this._connection.close();
}
catch (error)
{
logger.warn('close() | error closing the RTCPeerConnection: %o', error);
}
}
// Terminate signaling.
// Clear SIP timers.
for (const timer in this._timers)
{
if (Object.prototype.hasOwnProperty.call(this._timers, timer))
{
clearTimeout(this._timers[timer]);
}
}
// Clear Session Timers.
clearTimeout(this._sessionTimers.timer);
// Terminate confirmed dialog.
if (this._dialog)
{
this._dialog.terminate();
delete this._dialog;
}
// Terminate early dialogs.
for (const dialog in this._earlyDialogs)
{
if (Object.prototype.hasOwnProperty.call(this._earlyDialogs, dialog))
{
this._earlyDialogs[dialog].terminate();
delete this._earlyDialogs[dialog];
}
}
// Terminate REFER subscribers.
for (const subscriber in this._referSubscribers)
{
if (Object.prototype.hasOwnProperty.call(this._referSubscribers, subscriber))
{
delete this._referSubscribers[subscriber];
}
}
this._ua.destroyRTCSession(this);
}
/**
* Private API.
*/
/**
* RFC3261 13.3.1.4
* Response retransmissions cannot be accomplished by transaction layer
* since it is destroyed when receiving the first 2xx answer
*/
_setInvite2xxTimer(request, body)
{
let timeout = Timers.T1;
function invite2xxRetransmission()
{
if (this._status !== C.STATUS_WAITING_FOR_ACK)
{
return;
}
request.reply(200, null, [ `Contact: ${this._contact}` ], body);
if (timeout < Timers.T2)
{
timeout = timeout * 2;
if (timeout > Timers.T2)
{
timeout = Timers.T2;
}
}
this._timers.invite2xxTimer = setTimeout(
invite2xxRetransmission.bind(this), timeout);
}
this._timers.invite2xxTimer = setTimeout(
invite2xxRetransmission.bind(this), timeout);
}
/**
* RFC3261 14.2
* If a UAS generates a 2xx response and never receives an ACK,
* it SHOULD generate a BYE to terminate the dialog.
*/
_setACKTimer()
{
this._timers.ackTimer = setTimeout(() =>
{
if (this._status === C.STATUS_WAITING_FOR_ACK)
{
logger.debug('no ACK received, terminating the session');
clearTimeout(this._timers.invite2xxTimer);
this.sendRequest(JsSIP_C.BYE);
this._ended('remote', null, JsSIP_C.causes.NO_ACK);
}
}, Timers.TIMER_H);
}
_createRTCConnection(pcConfig, rtcConstraints)
{
this._connection = new RTCPeerConnection(pcConfig, rtcConstraints);
this._connection.addEventListener('iceconnectionstatechange', () =>
{
const state = this._connection.iceConnectionState;
// TODO: Do more with different states.
if (state === 'failed')
{
this.terminate({
cause : JsSIP_C.causes.RTP_TIMEOUT,
status_code : 408,
reason_phrase : JsSIP_C.causes.RTP_TIMEOUT
});
}
});
logger.debug('emit "peerconnection"');
this.emit('peerconnection', {
peerconnection : this._connection
});
}
_createLocalDescription(type, constraints)
{
logger.debug('createLocalDescription()');
if (type !== 'offer' && type !== 'answer')
throw new Error(`createLocalDescription() | invalid type "${type}"`);
const connection = this._connection;
this._rtcReady = false;
return Promise.resolve()
// Create Offer or Answer.
.then(() =>
{
if (type === 'offer')
{
return connection.createOffer(constraints)
.catch((error) =>
{
logger.warn('emit "peerconnection:createofferfailed" [error:%o]', error);
this.emit('peerconnection:createofferfailed', error);
return Promise.reject(error);
});
}
else
{
return connection.createAnswer(constraints)
.catch((error) =>
{
logger.warn('emit "peerconnection:createanswerfailed" [error:%o]', error);
this.emit('peerconnection:createanswerfailed', error);
return Promise.reject(error);
});
}
})
// Set local description.
.then((desc) =>
{
return connection.setLocalDescription(desc)
.catch((error) =>
{
this._rtcReady = true;
logger.warn('emit "peerconnection:setlocaldescriptionfailed" [error:%o]', error);
this.emit('peerconnection:setlocaldescriptionfailed', error);
return Promise.reject(error);
});
})
.then(() =>
{
// Resolve right away if 'pc.iceGatheringState' is 'complete'.
/**
* Resolve right away if:
* - 'connection.iceGatheringState' is 'complete' and no 'iceRestart' constraint is set.
* - 'connection.iceGatheringState' is 'gathering' and 'iceReady' is true.
*/
const iceRestart = constraints && constraints.iceRestart;
if ((connection.iceGatheringState === 'complete' && !iceRestart) ||
(connection.iceGatheringState === 'gathering' && this._iceReady))
{
this._rtcReady = true;
const e = { originator: 'local', type: type, sdp: connection.localDescription.sdp };
logger.debug('emit "sdp"');
this.emit('sdp', e);
return Promise.resolve(e.sdp);
}
// Add 'pc.onicencandidate' event handler to resolve on last candidate.
return new Promise((resolve) =>
{
let finished = false;
let iceCandidateListener;
let iceGatheringStateListener;
this._iceReady = false;
const ready = () =>
{
if (finished)
{
return;
}
connection.removeEventListener('icecandidate', iceCandidateListener);
connection.removeEventListener('icegatheringstatechange', iceGatheringStateListener);
finished = true;
this._rtcReady = true;
// connection.iceGatheringState will still indicate 'gathering' and thus be blocking.
this._iceReady = true;
const e = { originator: 'local', type: type, sdp: connection.localDescription.sdp };
logger.debug('emit "sdp"');
this.emit('sdp', e);
resolve(e.sdp);
};
connection.addEventListener('icecandidate', iceCandidateListener = (event) =>
{
const candidate = event.candidate;
if (candidate)
{
this.emit('icecandidate', {
candidate,
ready
});
}