UNPKG

jssip

Version:

the Javascript SIP library

1,996 lines (1,670 loc) 89 kB
/* 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 }); }