From 8ab99f10a6021a2a35f4e9b2ba1aeca344dd32ba Mon Sep 17 00:00:00 2001 From: James Criscuolo Date: Fri, 13 Dec 2013 15:30:32 -0500 Subject: [PATCH] JsSIP commits through Dec.10 hold/mute, extensive testing and bug fixes --- Gruntfile.js | 51 +- package.json | 4 +- src/Constants.js | 1 + src/Dialog/RequestSender.js | 18 +- src/Dialogs.js | 46 +- src/Exceptions.js | 11 + src/InviteContext.js | 667 ++++++++++++++++++++++++--- src/InviteContext/RTCMediaHandler.js | 60 ++- src/InviteContext/Request.js | 9 +- src/LoggerFactory.js | 2 +- src/MessageContext.js | 4 +- src/Registrator.js | 15 +- src/SDP/main.js | 18 + src/SIPMessage.js | 84 +++- src/Transactions.js | 15 +- src/Transport.js | 2 + src/UA.js | 58 ++- src/tail.js | 22 + 18 files changed, 931 insertions(+), 156 deletions(-) create mode 100644 src/SDP/main.js create mode 100644 src/tail.js diff --git a/Gruntfile.js b/Gruntfile.js index 7cec00a4b..a41ebef5a 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -26,7 +26,8 @@ module.exports = function(grunt) { 'src/Utils.js', 'src/SanityCheck.js', 'src/DigestAuthentication.js', - 'src/WebRTC.js' + 'src/WebRTC.js', + 'src/tail.js' ]; // Project configuration. @@ -39,10 +40,7 @@ module.exports = function(grunt) { * Copyright (c) 2012-<%= grunt.template.today("yyyy") %> José Luis Millán - Versatica \n\ * Homepage: http://jssip.net\n\ * License: http://jssip.net/license\n\ - */\n\n\n', - footer: '\ -\n\n\nwindow.SIP = SIP;\n\ -}(window));\n\n' + */\n\n\n' }, concat: { dist: { @@ -50,8 +48,7 @@ module.exports = function(grunt) { dest: 'dist/<%= pkg.name %>-<%= pkg.version %>.js', options: { banner: '<%= meta.banner %>', - separator: '\n\n\n', - footer: '<%= meta.footer %>', + separator: '\n\n', process: true }, nonull: true @@ -59,7 +56,8 @@ module.exports = function(grunt) { post_dist: { src: [ 'dist/<%= pkg.name %>-<%= pkg.version %>.js', - 'src/Grammar/dist/Grammar.js' + 'src/Grammar/dist/Grammar.js', + 'src/SDP/dist/SDP.js' ], dest: 'dist/<%= pkg.name %>-<%= pkg.version %>.js', nonull: true @@ -69,8 +67,7 @@ module.exports = function(grunt) { dest: 'dist/<%= pkg.name %>-devel.js', options: { banner: '<%= meta.banner %>', - separator: '\n\n\n', - footer: '<%= meta.footer %>', + separator: '\n\n', process: true }, nonull: true @@ -78,7 +75,8 @@ module.exports = function(grunt) { post_devel: { src: [ 'dist/<%= pkg.name %>-devel.js', - 'src/Grammar/dist/Grammar.js' + 'src/Grammar/dist/Grammar.js', + 'src/SDP/dist/SDP.js' ], dest: 'dist/<%= pkg.name %>-devel.js', nonull: true @@ -111,11 +109,14 @@ module.exports = function(grunt) { undef: true, boss: true, eqnull: true, - onecase:true, - unused:true, - supernew: true - }, - globals: {} + onecase: true, + unused: true, + supernew: true, + globals: { + module: true, + define: true + } + } }, uglify: { dist: { @@ -169,6 +170,24 @@ module.exports = function(grunt) { }); }); + // Task for building JsSIP SDP.js and SDP.min.js files. + grunt.registerTask('sdp', function(){ + var done = this.async(); // This is an async task. + var sys = require('sys'); + var exec = require('child_process').exec; + var child; + + // Build a bundle of 'sdp-transform' for the browser. + console.log('"sdp" task: getting JsSIP parser from "sdp-transform" ...'); + child = exec('browserify src/SDP/main.js -o src/SDP/dist/SDP.js', function(error, stdout, stderr) { + if (error) { + sys.print('ERROR: ' + stderr); + done(false); // Tell grunt that async task has failed. + } + console.log('OK'); + done(); // Tell grunt that async task has succeeded. + }); + }); // Task for building jssip-devel.js (uncompressed), jssip-X.Y.Z.js (uncompressed) // and jssip-X.Y.Z.min.js (minified). diff --git a/package.json b/package.json index ba7ac42d0..073bc736f 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "grunt-contrib-jshint": "~0.3.0", "grunt-contrib-qunit": "~0.2.0", "pegjs": "0.7.0", - "node-minify": "~0.7.2" + "node-minify": "~0.7.2", + "browserify": "~2.36.0", + "sdp-transform": "~0.3.3" }, "engines": { "node": ">=0.8" diff --git a/src/Constants.js b/src/Constants.js index da065c41e..66514a7d0 100644 --- a/src/Constants.js +++ b/src/Constants.js @@ -12,6 +12,7 @@ SIP.C= { // SIP scheme SIP: 'sip', + SIPS: 'sips', // End and Failure causes causes: { diff --git a/src/Dialog/RequestSender.js b/src/Dialog/RequestSender.js index 6a5194747..67cf429e8 100644 --- a/src/Dialog/RequestSender.js +++ b/src/Dialog/RequestSender.js @@ -30,8 +30,22 @@ RequestSender = function(dialog, applicant, request) { RequestSender.prototype = { send: function() { - var request_sender = new SIP.RequestSender(this, this.dialog.owner.ua); - request_sender.send(); + var self = this, + request_sender = new SIP.RequestSender(this, this.dialog.owner.ua); + + request_sender.send(); + + // RFC3261 14.2 Modifying an Existing Session -UAC BEHAVIOR- + if (this.request.method === SIP.C.INVITE && request_sender.clientTransaction.state !== SIP.Transactions.C.STATUS_TERMINATED) { + this.dialog.uac_pending_reply = true; + request_sender.clientTransaction.on('stateChanged', function(e){ + if (e.sender.state === SIP.Transactions.C.STATUS_ACCEPTED || + e.sender.state === SIP.Transactions.C.STATUS_COMPLETED || + e.sender.state === SIP.Transactions.C.STATUS_TERMINATED) { + self.dialog.uac_pending_reply = false; + } + }); + } }, onRequestTimeout: function() { diff --git a/src/Dialogs.js b/src/Dialogs.js index c3fa47208..f8d0df1cd 100644 --- a/src/Dialogs.js +++ b/src/Dialogs.js @@ -27,6 +27,9 @@ var Dialog, Dialog = function(owner, message, type, state) { var contact; + this.uac_pending_reply = false; + this.uas_pending_reply = false; + if(!message.hasHeader('contact')) { return { error: 'unable to create a Dialog without Contact header field' @@ -154,9 +157,11 @@ Dialog.prototype = { // RFC 3261 12.2.2 checkInDialogRequest: function(request) { + var self = this; + if(!this.remote_seqnum) { this.remote_seqnum = request.cseq; - } else if(request.method !== SIP.C.INVITE && request.cseq < this.remote_seqnum) { + } else if(request.cseq < this.remote_seqnum) { //Do not try to reply to an ACK request. if (request.method !== SIP.C.ACK) { request.reply(500); @@ -172,29 +177,38 @@ Dialog.prototype = { switch(request.method) { // RFC3261 14.2 Modifying an Existing Session -UAS BEHAVIOR- case SIP.C.INVITE: - if(request.cseq < this.remote_seqnum) { - if(this.state === C.STATUS_EARLY) { - var retryAfter = (Math.random() * 10 | 0) + 1; - request.reply(500, null, ['Retry-After:'+ retryAfter]); - } else { - request.reply(500); - } - return false; - } - // RFC3261 14.2 - if(this.state === C.STATUS_EARLY) { + if (this.uac_pending_reply === true) { request.reply(491); + } else if (this.uas_pending_reply === true) { return false; + } else { + this.uas_pending_reply = true; + request.server_transaction.on('stateChanged', function(e){ + if (e.sender.state === SIP.Transactions.C.STATUS_ACCEPTED || + e.sender.state === SIP.Transactions.C.STATUS_COMPLETED || + e.sender.state === SIP.Transactions.C.STATUS_TERMINATED) { + self.uas_pending_reply = false; + } + }); } - // RFC3261 12.2.2 Replace the dialog`s remote target URI + + // RFC3261 12.2.2 Replace the dialog`s remote target URI if the request is accepted if(request.hasHeader('contact')) { - this.remote_target = request.parseHeader('contact').uri; + request.server_transaction.on('stateChanged', function(e){ + if (e.sender.state === SIP.Transactions.C.STATUS_ACCEPTED) { + self.remote_target = request.parseHeader('contact').uri; + } + }); } break; case SIP.C.NOTIFY: - // RFC6655 3.2 Replace the dialog`s remote target URI + // RFC6655 3.2 Replace the dialog`s remote target URI if the request is accepted if(request.hasHeader('contact')) { - this.remote_target = request.parseHeader('contact').uri; + request.server_transaction.on('stateChanged', function(e){ + if (e.sender.state === SIP.Transactions.C.STATUS_COMPLETED) { + self.remote_target = request.parseHeader('contact').uri; + } + }); } break; } diff --git a/src/Exceptions.js b/src/Exceptions.js index dc14fe12a..50ee9a64f 100644 --- a/src/Exceptions.js +++ b/src/Exceptions.js @@ -27,6 +27,7 @@ Exceptions= { this.code = 2; this.name = 'INVALID_STATE_ERROR'; this.status = status; + this.message = 'Invalid status: ' + status; }; exception.prototype = new Error(); return exception; @@ -40,6 +41,16 @@ Exceptions= { }; exception.prototype = new Error(); return exception; + }()), + + NotReadyError: (function(){ + var exception = function(message) { + this.code = 4; + this.name = 'NOT_READY_ERROR'; + this.message = message; + }; + exception.prototype = new Error(); + return exception; }()) }; diff --git a/src/InviteContext.js b/src/InviteContext.js index 1f094be0a..74319aadd 100644 --- a/src/InviteContext.js +++ b/src/InviteContext.js @@ -25,13 +25,18 @@ var InviteContext, InviteServerContext, InviteClientContext, InviteContext = function() { var events = [ + 'connecting', 'terminated', 'dtmf', 'invite', 'preaccepted', 'canceled', 'referred', - 'bye' + 'bye', + 'hold', + 'unhold', + 'muted', + 'unmuted' ]; this.status = C.STATUS_NULL; @@ -54,6 +59,13 @@ InviteContext = function() { this.start_time = null; this.end_time = null; this.tones = null; + + // Mute/Hold state + this.audioMuted = false; + this.videoMuted = false; + this.local_hold = false; + this.remote_hold = false; + this.media_constraints = {'audio':true, 'video':true}; this.early_sdp = null; this.rel100 = SIP.C.supported.UNSUPPORTED; @@ -358,6 +370,360 @@ InviteContext.prototype = { } }, + /** + * Check if RTCSession is ready for a re-INVITE + * + * @returns {Boolean} + */ + isReadyForReinvite: function() { + if (this.status !== C.STATUS_WAITING_FOR_ACK && this.status !== C.STATUS_CONFIRMED) { + return false; + } + + // Another INVITE transaction is in progress + if (this.dialog.uac_pending_reply === true || this.dialog.uas_pending_reply === true) { + return false; + } + + return true; + }, + + /** + * Mute + */ + mute: function(options) { + options = options || {audio:true, video:false}; + + var audioMuted = false, + videoMuted = false; + + if (this.audioMuted === false && options.audio) { + audioMuted = true; + this.audioMuted = true; + this.toogleMuteAudio(true); + } + + if (this.videoMuted === false && options.video) { + videoMuted = true; + this.videoMuted = true; + this.toogleMuteVideo(true); + } + + if (audioMuted === true || videoMuted === true) { + this.onmute({ + audio: audioMuted, + video: videoMuted + }); + } + }, + + /** + * Unmute + */ + unmute: function(options) { + options = options || {audio:true, video:true}; + + var audioUnMuted = false, + videoUnMuted = false; + + if (this.audioMuted === true && options.audio) { + this.audioMuted = false; + this.toogleMuteAudio(false); + } + + if (this.videoMuted === true && options.video) { + videoUnMuted = true; + this.videoMuted = false; + this.toogleMuteVideo(false); + } + + if (audioUnMuted === true || videoUnMuted === true) { + this.onunmute({ + audio: audioUnMuted, + video: videoUnMuted + }); + } + }, + + /** + * isMuted + */ + isMuted: function() { + return { + audio: this.audioMuted, + video: this.videoMuted + }; + }, + + /* + * @private + */ + toogleMuteAudio: function(mute) { + var streamIdx, trackIdx, tracks, + localStreams = this.getLocalStreams(); + + for (streamIdx in localStreams) { + tracks = localStreams[streamIdx].getAudioTracks(); + for (trackIdx in tracks) { + tracks[trackIdx].enabled = !mute; + } + } + }, + + /* + * @private + */ + toogleMuteVideo: function(mute) { + var streamIdx, trackIdx, tracks, + localStreams = this.getLocalStreams(); + + for (streamIdx in localStreams) { + tracks = localStreams[streamIdx].getVideoTracks(); + for (trackIdx in tracks) { + tracks[trackIdx].enabled = !mute; + } + } + }, + + /** + * Hold + */ + hold: function() { + + // Check if RTCSession is ready to send a reINVITE + if (!this.isReadyForReinvite()) { + throw new SIP.Exceptions.NotReadyError('Not ready for re-INVITE'); + } + + if (this.local_hold === true) { + return; + } + + this.toogleMuteAudio(true); + this.toogleMuteVideo(true); + + this.onhold('local'); + + this.sendReinvite({ + mangle: function(body){ + var idx, length; + + body = SIP.Parser.parseSDP(body); + + length = body.media.length; + for (idx=0; idx SIP.Timers.T2) { + timeout = SIP.Timers.T2; + } + } + self.timers.invite2xxTimer = window.setTimeout( + invite2xxRetransmission, timeout + ); + }, 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: function() { + var self = this; + + this.timers.ackTimer = window.setTimeout(function() { + if(self.status === C.STATUS_WAITING_FOR_ACK) { + self.logger.log('no ACK received, terminating the call'); + window.clearTimeout(self.timers.invite2xxTimer); + self.sendRequest(SIP.C.BYE); + self.terminated(null, SIP.C.causes.NO_ACK); + } + }, SIP.Timers.TIMER_H); + }, + onTransportError: function() { if(this.status !== C.STATUS_TERMINATED) { if (this.status === C.STATUS_CONFIRMED) { @@ -407,6 +819,56 @@ InviteContext.prototype = { } }, + /** + * @private + */ + onhold: function(originator) { + if (originator === 'local') { + this.local_hold = true; + } else { + this.remote_hold = true; + } + + this.emit('hold', this, { + originator: originator + }); + }, + + /** + * @private + */ + onunhold: function(originator) { + if (originator === 'local') { + this.local_hold = false; + } else { + this.remote_hold = false; + } + + this.emit('unhold', this, { + originator: originator + }); + }, + + /* + * @private + */ + onmute: function(options) { + this.emit('muted', this, { + audio: options.audio, + video: options.video + }); + }, + + /* + * @private + */ + onunmute: function(options) { + this.emit('unmuted', this, { + audio: options.audio, + video: options.video + }); + }, + failed: function(response, cause) { var code = response ? response.status_code : null; @@ -476,6 +938,12 @@ InviteContext.prototype = { }); return this; + }, + + connecting: function(request) { + this.emit('connecting', this, { + request: request + }); } }; @@ -543,9 +1011,9 @@ InviteServerContext = function(ua, request) { } //Initialize Media Session - this.rtcMediaHandler = new RTCMediaHandler(this, - {"optional": [{'DtlsSrtpKeyAgreement': 'true'}]} - ); + this.rtcMediaHandler = new RTCMediaHandler(this, { + RTCConstraints: {"optional": [{'DtlsSrtpKeyAgreement': 'true'}]} + }); function fireNewSession() { var options = {extraHeaders: ['Contact: ' + self.contact]}; @@ -636,7 +1104,51 @@ InviteServerContext.prototype = { terminate: function(options) { options = options || {}; - if (this.status === C.STATUS_WAITING_FOR_ACK || this.status === C.STATUS_CONFIRMED) { + var + status_code = options.status_code, + reason_phrase = options.reason_phrase, + extraHeaders = options.extraHeaders || [], + body = options.body, + dialog, + self = this; + + if (this.status === C.STATUS_WAITING_FOR_ACK && + this.request.server_transaction.state !== SIP.Transactions.C.STATUS_TERMINATED) { + dialog = this.dialog; + + this.receiveRequest = function(request) { + if (request.method === SIP.C.ACK) { + this.request(SIP.C.BYE, { + extraHeaders: extraHeaders, + body: body + }); + dialog.terminate(); + } + }; + + this.request.server_transaction.on('stateChanged', function(e){ + if (e.sender.state === SIP.Transactions.C.STATUS_TERMINATED) { + self.sendRequest(SIP.C.BYE, { + extraHeaders: extraHeaders, + body: body + }); + dialog.terminate(); + } + }); + + this.emit('bye', this, { + cause: reason_phrase, + code: status_code + }); + this.terminated(); + + // 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.dialogs[dialog.id.toString()] = dialog; + + } else if (this.status === C.STATUS_CONFIRMED) { this.bye(options); } else { this.reject(options); @@ -697,7 +1209,10 @@ InviteServerContext.prototype = { // rtcMediaHandler.addStream successfully added streamAdditionSucceeded = function() { - if (self.request.body && self.contentDisp !== 'render') { + self.connecting(); + if (self.status === C.STATUS_TERMINATED) { + return; + } else if (self.request.body && self.contentDisp !== 'render') { self.rtcMediaHandler.createAnswer( sdpCreationSucceeded, sdpCreationFailed @@ -768,7 +1283,10 @@ InviteServerContext.prototype = { // rtcMediaHandler.addStream successfully added streamAdditionSucceeded = function() { - if (request.body && self.contentDisp !== 'render') { + self.connecting(request); + if (self.status === C.STATUS_TERMINATED) { + return; + } else if (request.body && self.contentDisp !== 'render') { self.rtcMediaHandler.createAnswer( sdpCreationSucceeded, sdpCreationFailed @@ -795,51 +1313,10 @@ InviteServerContext.prototype = { var // run for reply success callback replySucceeded = function() { - var timeout = SIP.Timers.T1; - self.status = C.STATUS_WAITING_FOR_ACK; - /** - * RFC3261 13.3.1.4 - * Response retransmissions cannot be accomplished by transaction layer - * since it is destroyed when receiving the first 2xx answer - */ - self.timers.invite2xxTimer = window.setTimeout(function invite2xxRetransmission() { - if (self.status !== C.STATUS_WAITING_FOR_ACK) { - return; - } - - request.reply(200, null, ['Contact: '+ self.contact], body); - - if (timeout < SIP.Timers.T2) { - timeout = timeout * 2; - if (timeout > SIP.Timers.T2) { - timeout = SIP.Timers.T2; - } - } - self.timers.invite2xxTimer = window.setTimeout( - invite2xxRetransmission, timeout - ); - }, - 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. - */ - self.timers.ackTimer = window.setTimeout(function() { - if(self.status === C.STATUS_WAITING_FOR_ACK) { - self.logger.log('no ACK received, terminating the call'); - window.clearTimeout(self.timers.invite2xxTimer); - self.sendRequest(SIP.C.BYE); - self.terminated(null, SIP.C.causes.NO_ACK); - } - }, - SIP.Timers.TIMER_H - ); - + self.setInvite2xxTimer(request, body); + self.setACKTimer(); if (self.request.body && self.contentDisp !== 'render') { self.accepted(); } @@ -891,6 +1368,9 @@ InviteServerContext.prototype = { } window.clearTimeout(this.timers.userNoAnswerTimer); + + extraHeaders.unshift('Contact: ' + self.contact); + if (this.status === C.STATUS_EARLY_MEDIA) { sdpCreationSucceeded(self.early_sdp); } else { @@ -948,8 +1428,8 @@ InviteServerContext.prototype = { } this.status = C.STATUS_CANCELED; this.request.reply(487); - this.failed(request, SIP.C.causes.CANCELED); this.canceled(request); + this.failed(request, SIP.C.causes.CANCELED); this.terminated(request); } } else { @@ -1011,7 +1491,7 @@ InviteServerContext.prototype = { session.status = C.STATUS_EARLY_MEDIA; } else if (session.status === C.STATUS_ANSWERED_WAITING_FOR_PRACK) { session.status = C.STATUS_EARLY_MEDIA; - session.answer(); + session.accept(); } if (session.status === C.STATUS_EARLY_MEDIA) { if (localMedia.getAudioTracks().length > 0) { @@ -1046,7 +1526,7 @@ InviteServerContext.prototype = { this.status = C.STATUS_EARLY_MEDIA; } else if (this.status === C.STATUS_ANSWERED_WAITING_FOR_PRACK) { this.status = C.STATUS_EARLY_MEDIA; - this.answer(); + this.accept(); } if (session.status === C.STATUS_EARLY_MEDIA) { localMedia = session.rtcMediaHandler.localMedia; @@ -1069,6 +1549,7 @@ InviteServerContext.prototype = { case SIP.C.INVITE: if(this.status === C.STATUS_CONFIRMED) { this.logger.log('re-INVITE received'); + this.receiveReinvite(request); } break; case SIP.C.INFO: @@ -1160,10 +1641,24 @@ InviteClientContext.prototype = { extraHeaders = options.extraHeaders || [], mediaConstraints = options.mediaConstraints || {audio: true, video: true}, RTCConstraints = options.RTCConstraints || {}, + stun_servers = options.stun_servers || null, + turn_servers = options.turn_servers || null, inviteWithoutSdp = options.inviteWithoutSdp || false; + if (stun_servers) { + if (!SIP.UA.configuration_check.optional['stun_servers'](stun_servers)) { + throw new TypeError('Invalid stun_servers: '+ stun_servers); + } + } + + if (turn_servers) { + if (!SIP.UA.configuration_check.optional['turn_servers'](turn_servers)) { + throw new TypeError('Invalid turn_servers: '+ turn_servers); + } + } + // Set anonymous property - this.anonymous = options.anonymous; + this.anonymous = options.anonymous || false; //Custom data to be sent either in INVITE or in ACK this.renderbody = options.renderbody || null; @@ -1173,9 +1668,12 @@ InviteClientContext.prototype = { this.contact = this.ua.contact.toString({ anonymous: this.anonymous, - outbound: true + outbound: ((this.anonymous === false && this.ua.contact.pub_gruu) || (this.anonymous === true && this.ua.contact.temp_gruu)) ? false : true }); + /* Do not add ;ob in initial forming dialog requests if the registration over + * the current connection got a GRUU URI. + */ if (this.anonymous) { requestParams.from_display_name = 'Anonymous'; requestParams.from_uri = 'sip:anonymous@anonymous.invalid'; @@ -1205,16 +1703,17 @@ InviteClientContext.prototype = { //Extra lines if we don't call send this.ua.applicants[this] = this; this.request = new SIP.OutgoingRequest(SIP.C.INVITE, this.target, this.ua, requestParams, extraHeaders); - if (options.body) { - this.request.body = options.body; - } this.local_identity = this.request.from; this.remote_identity = this.request.to; this.id = this.request.call_id + this.from_tag; this.logger = this.ua.getLogger('sip.inviteclientcontext', this.id); //End of extra lines - this.rtcMediaHandler = new RTCMediaHandler(this, RTCConstraints); + this.rtcMediaHandler = new RTCMediaHandler(this, { + RTCConstraints: RTCConstraints, + stun_servers: stun_servers, + turn_servers: turn_servers + }); //Save the session into the ua sessions collection. this.ua.sessions[this.id] = this; @@ -1241,12 +1740,15 @@ InviteClientContext.prototype = { // rtcMediaHandler.addStream successfully added streamAdditionSucceeded = function() { - if (inviteWithoutSdp) { + if (self.status === C.STATUS_TERMINATED) { + return; + } else if (inviteWithoutSdp) { //just send an invite with no sdp... self.request.body = self.renderbody; self.status = C.STATUS_INVITE_SENT; request_sender.send(); } else { + self.connecting(this.request); self.rtcMediaHandler.createOffer( offerCreationSucceeded, offerCreationFailed @@ -1299,11 +1801,27 @@ InviteClientContext.prototype = { extraHeaders = [], options = null; - if(this.status !== C.STATUS_INVITE_SENT && this.status !== C.STATUS_1XX_RECEIVED && this.status !== C.STATUS_EARLY_MEDIA) { - if (response.status_code!==200) { + if (this.dialog && (response.status_code >= 200 && response.status <= 299)) { + if (id !== this.dialog.id.toString() ) { + if (!this.createDialog(response, 'UAC', true)) { + return; + } + this.earlyDialogs[id].sendRequest(this, SIP.C.ACK); + this.earlyDialogs[id].sendRequest(this, SIP.C.BYE); + //session.failed(response, SIP.C.causes.WEBRTC_ERROR); + return; + } else if (this.status === C.STATUS_CONFIRMED) { + this.sendRequest(SIP.C.ACK); + return; + } + } + + /* if (this.status !== C.STATUS_INVITE_SENT && this.status !== C.STATUS_1XX_RECEIVED && this.status !== C.STATUS_EARLY_MEDIA) { + if (response.status_code !== 200) { return; } - } else if (this.status === C.STATUS_EARLY_MEDIA && response.status_code !== 200) { + } else */if (this.status === C.STATUS_EARLY_MEDIA && response.status_code !== 200) { + //Early media has been set up with at least one other different branch, but a final 2xx response hasn't been received if (!this.earlyDialogs[id]) { this.createDialog(response, 'UAC', true); } @@ -1341,7 +1859,9 @@ InviteClientContext.prototype = { // Create Early Dialog if 1XX comes with contact if(response.hasHeader('contact')) { // An error on dialog creation will fire 'failed' event - this.createDialog(response, 'UAC', true); + if (!this.createDialog(response, 'UAC', true)) { + break; + } } this.status = C.STATUS_1XX_RECEIVED; @@ -1413,6 +1933,10 @@ InviteClientContext.prototype = { } else { // rtcMediaHandler.addStream successfully added var streamAdditionSucceeded = function() { + session.connecting(response); + if (session.status === C.STATUS_TERMINATED) { + return; + } session.earlyDialogs[id].rtcMediaHandler.createAnswer( sdpCreationSucceeded, sdpCreationFailed @@ -1483,16 +2007,6 @@ InviteClientContext.prototype = { } if (this.status === C.STATUS_EARLY_MEDIA) { - if (id !== this.dialog.id.toString()) { - if (!this.createDialog(response, 'UAC', true)) { - break; - } - this.earlyDialogs[id].sendRequest(this, SIP.C.ACK); - this.earlyDialogs[id].sendRequest(this, SIP.C.BYE); - session.failed(response, SIP.C.causes.WEBRTC_ERROR); - break; - } - this.status = C.STATUS_CONFIRMED; localMedia = this.rtcMediaHandler.localMedia; if (localMedia.getAudioTracks().length > 0) { @@ -1710,8 +2224,8 @@ InviteClientContext.prototype = { this.request.cancel(cancel_reason); } - this.failed(null, SIP.C.causes.CANCELED); this.canceled(null); + this.failed(null, SIP.C.causes.CANCELED); this.terminated(); return this; @@ -1748,8 +2262,8 @@ InviteClientContext.prototype = { if(this.status === C.STATUS_EARLY_MEDIA) { this.status = C.STATUS_CANCELED; this.request.reply(487); - this.failed(request, SIP.C.causes.CANCELED); this.canceled(request); + this.failed(request, SIP.C.causes.CANCELED); } } else if (C.STATUS_CONFIRMED) { // Requests arriving here are in-dialog requests. @@ -1760,6 +2274,7 @@ InviteClientContext.prototype = { break; case SIP.C.INVITE: this.logger.log('re-INVITE received'); + this.receiveReinvite(request); break; case SIP.C.INFO: contentType = request.getHeader('content-type'); diff --git a/src/InviteContext/RTCMediaHandler.js b/src/InviteContext/RTCMediaHandler.js index fb40234f7..be1a39c67 100644 --- a/src/InviteContext/RTCMediaHandler.js +++ b/src/InviteContext/RTCMediaHandler.js @@ -17,7 +17,7 @@ var RTCMediaHandler = function(session, constraints) { this.localMedia = null; this.peerConnection = null; - this.init(); + this.init(constraints); }; RTCMediaHandler.prototype = { @@ -47,6 +47,12 @@ RTCMediaHandler.prototype = { onFailure(); } ); + + if (this.peerConnection.iceGatheringState === 'complete' && this.peerConnection.iceConnectionState === 'connected') { + window.setTimeout(function(){ + self.onIceCompleted(); + },0); + } }, createAnswer: function(onSuccess, onFailure) { @@ -73,8 +79,14 @@ RTCMediaHandler.prototype = { self.logger.error(e); onFailure(); }, - this.constraints + this.constraints.RTCConstraints ); + + if (this.peerConnection.iceGatheringState === 'complete' && this.peerConnection.iceConnectionState === 'connected') { + window.setTimeout(function(){ + self.onIceCompleted(); + },0); + } }, setLocalDescription: function(sessionDescription, onFailure) { @@ -108,30 +120,38 @@ RTCMediaHandler.prototype = { * peerConnection creation. * @param {Function} onSuccess Fired when there are no more ICE candidates */ - init: function() { - var idx, length, server, scheme, url, + init: function(options) { + options = options || {}; + + var idx, length, server, self = this, servers = [], + constraints = options.RTCConstraints || {}, + stun_servers = options.stun_servers || null, + turn_servers = options.turn_servers || null, config = this.session.ua.configuration; - length = config.stun_servers.length; - for (idx = 0; idx < length; idx++) { - server = config.stun_servers[idx]; - servers.push({'url': server}); + if (!stun_servers) { + stun_servers = config.stun_servers; + } + + if(!turn_servers) { + turn_servers = config.turn_servers; } - length = config.turn_servers.length; + servers.push({'url': stun_servers}); + + length = turn_servers.length; for (idx = 0; idx < length; idx++) { - server = config.turn_servers[idx]; - url = server.server; - scheme = url.substr(0, url.indexOf(':')); + server = turn_servers[idx]; servers.push({ - 'url': scheme + ':' + server.username + '@' + url.substr(scheme.length+1), + 'url': server.urls, + 'username': server.username, 'credential': server.password }); } - this.peerConnection = new SIP.WebRTC.RTCPeerConnection({'iceServers': servers}, this.constraints); + this.peerConnection = new SIP.WebRTC.RTCPeerConnection({'iceServers': servers}, constraints); this.peerConnection.onaddstream = function(e) { self.logger.log('stream added: '+ e.stream.id); @@ -151,15 +171,17 @@ RTCMediaHandler.prototype = { this.peerConnection.oniceconnectionstatechange = function(e) { self.logger.log('ICE connection state changed to "'+ this.iceConnectionState +'"'); - if (e.currentTarget.iceGatheringState === 'complete' && this.iceConnectionState !== 'closed') { + if (this.iceConnectionState === 'disconnected') { + self.session.terminate({ + cause: SIP.C.causes.RTP_TIMEOUT, + status_code: 200, + reason_phrase: SIP.C.causes.RTP_TIMEOUT + }); + } else if (e.currentTarget.iceGatheringState === 'complete' && this.iceConnectionState !== 'closed') { self.onIceCompleted(); } }; - this.peerConnection.onicechange = function() { - self.logger.log('ICE connection state changed to "'+ this.iceConnectionState +'"'); - }; - this.peerConnection.onstatechange = function() { self.logger.log('PeerConnection state changed to "'+ this.readyState +'"'); }; diff --git a/src/InviteContext/Request.js b/src/InviteContext/Request.js index 14ecabf06..1b4d6275a 100644 --- a/src/InviteContext/Request.js +++ b/src/InviteContext/Request.js @@ -39,10 +39,17 @@ Request.prototype.send = function(method, options) { if (this.owner.status !== SIP.InviteContext.C.STATUS_1XX_RECEIVED && this.owner.status !== SIP.InviteContext.C.STATUS_WAITING_FOR_ANSWER && this.owner.status !== SIP.InviteContext.C.STATUS_WAITING_FOR_ACK && - this.owner.status !== SIP.InviteContext.C.STATUS_CONFIRMED) { + this.owner.status !== SIP.InviteContext.C.STATUS_CONFIRMED && + this.owner.status !== SIP.InviteContext.C.STATUS_TERMINATED) { throw new SIP.Exceptions.InvalidStateError(this.owner.status); } + /* Allow sending BYE in TERMINATED status (only if invitecontext is terminated before ACK arrives + * RFC3261 Section 15, Paragraph 2 + */ + else if (this.owner.status === C.STATUS_TERMINATED && method !== SIP.C.BYE) { + throw new SIP.Exceptions.InvalidStateError(this.owner.status); + } // Set event handlers for (event in eventHandlers) { this.on(event, eventHandlers[event]); diff --git a/src/LoggerFactory.js b/src/LoggerFactory.js index dfe379722..08c4b513a 100644 --- a/src/LoggerFactory.js +++ b/src/LoggerFactory.js @@ -40,7 +40,7 @@ var LoggerFactory = function() { level = value; } else if (value > 3) { level = 3; - } else if (levels.hasOwnProperty(level)) { + } else if (levels.hasOwnProperty(value)) { level = levels[value]; } else { logger.error('invalid "level" parameter value: '+ window.JSON.stringify(value)); diff --git a/src/MessageContext.js b/src/MessageContext.js index c03f7ca3d..7f8bb961f 100644 --- a/src/MessageContext.js +++ b/src/MessageContext.js @@ -6,7 +6,7 @@ MessageServerContext = function(ua, request) { SIP.Utils.augment(this, SIP.ServerContext, [ua, request]); - this.logger = ua.getLogger('sip.messageserver'); + this.logger = ua.getLogger('sip.messageservercontext'); }; SIP.MessageServerContext = MessageServerContext; @@ -19,7 +19,7 @@ MessageClientContext = function(ua, target, body, contentType) { SIP.Utils.augment(this, SIP.ClientContext, [ua, 'MESSAGE', target]); - this.logger = ua.getLogger('sip.messageclient'); + this.logger = ua.getLogger('sip.messageclientcontext'); this.body = body; this.contentType = contentType || 'text/plain'; }; diff --git a/src/Registrator.js b/src/Registrator.js index aa582cb9a..398eb328d 100644 --- a/src/Registrator.js +++ b/src/Registrator.js @@ -40,6 +40,8 @@ Registrator = function(ua, transport) { // Contact header this.contact = this.ua.contact.toString(); + this.extraHeaders = []; + if(reg_id) { this.contact += ';reg-id='+ reg_id; this.contact += ';+sip.instance=""'; @@ -55,7 +57,12 @@ Registrator.prototype = { self = this; options = options || {}; - extraHeaders = options.extraHeaders || []; + + if (options.extraHeaders && Object.keys(options.extraHeaders).length !== 0) { + this.extraheaders = options.extraHeaders; + } + + extraHeaders = this.extraHeaders.slice(); extraHeaders.push('Contact: '+ this.contact + ';expires=' + this.expires); extraHeaders.push('Allow: '+ SIP.Utils.getAllowedMethods(this.ua)); @@ -186,8 +193,12 @@ Registrator.prototype = { } options = options || {}; - extraHeaders = options.extraHeaders || []; + if (options.extraHeaders && Object.keys(options.extraHeaders).length !== 0) { + this.extraheaders = options.extraHeaders; + } + + extraHeaders = this.extraHeaders.slice(); this.registered = false; // Clear the registration timer. diff --git a/src/SDP/main.js b/src/SDP/main.js new file mode 100644 index 000000000..0fec0b8e9 --- /dev/null +++ b/src/SDP/main.js @@ -0,0 +1,18 @@ +/** + * @fileoverview SDP Parser + * + * https://github.com/clux/sdp-transform + * + */ + +(function(SIP) { + + var parser = require('sdp-transform'); + + SIP.Parser.parseSDP = parser.parse; + SIP.Parser.writeSDP = parser.write; + SIP.Parser.parseFmtpConfig = parser.parseFmtpConfig; + SIP.Parser.parsePayloads = parser.parsePayloads; + SIP.Parser.parseRemoteCandidates = parser.parseRemoteCandidates; + +}(SIP)); \ No newline at end of file diff --git a/src/SIPMessage.js b/src/SIPMessage.js index a59a36124..5e1f42c0b 100644 --- a/src/SIPMessage.js +++ b/src/SIPMessage.js @@ -36,6 +36,7 @@ OutgoingRequest = function(method, ruri, ua, params, extraHeaders, body) { } this.logger = ua.getLogger('sip.sipmessage'); + this.ua = ua; this.headers = {}; this.method = method; this.ruri = ruri; @@ -98,8 +99,57 @@ OutgoingRequest.prototype = { setHeader: function(name, value) { this.headers[SIP.Utils.headerize(name)] = (value instanceof Array) ? value : [value]; }, + + /** + * Get the value of the given header name at the given position. + * @param {String} name header name + * @returns {String|undefined} Returns the specified header, null if header doesn't exist. + */ + getHeader: function(name) { + var header = this.headers[SIP.Utils.headerize(name)]; + + if(header) { + if(header[0]) { + return header[0].raw; + } + } else { + return; + } + }, + + /** + * Get the header/s of the given name. + * @param {String} name header name + * @returns {Array} Array with all the headers of the specified name. + */ + getHeaders: function(name) { + var idx, length, + header = this.headers[SIP.Utils.headerize(name)], + result = []; + + if(!header) { + return []; + } + + length = header.length; + for (idx = 0; idx < length; idx++) { + result.push(header[idx].raw); + } + + return result; + }, + + /** + * Verify the existence of the given header. + * @param {String} name header name + * @returns {boolean} true if header with given name exists, false otherwise + */ + hasHeader: function(name) { + return(this.headers[SIP.Utils.headerize(name)]) ? true : false; + }, + toString: function() { - var msg = '', header, length, idx; + var msg = '', header, length, idx, supported = []; msg += this.method + ' ' + this.ruri + ' SIP/2.0\r\n'; @@ -115,7 +165,21 @@ OutgoingRequest.prototype = { msg += this.extraHeaders[idx] +'\r\n'; } - msg += 'Supported: ' + SIP.UA.C.SUPPORTED +'\r\n'; + //Supported + if (this.method === SIP.C.REGISTER) { + supported.push('path', 'gruu'); + } else if (this.method === SIP.C.INVITE && + (this.ua.contact.pub_guu || this.ua.contact.temp_gruu)) { + supported.push('gruu'); + } + + if (this.ua.configuration.reliable === 'supported') { + supported.push('100rel'); + } + + supported.push('outbound'); + + msg += 'Supported: ' + supported +'\r\n'; msg += 'User-Agent: ' + SIP.C.USER_AGENT +'\r\n'; if(this.body) { @@ -291,6 +355,7 @@ IncomingMessage.prototype = { */ IncomingRequest = function(ua) { this.logger = ua.getLogger('sip.sipmessage'); + this.ua = ua; this.headers = {}; this.ruri = null; this.transport = null; @@ -309,6 +374,7 @@ IncomingRequest.prototype = new IncomingMessage(); */ IncomingRequest.prototype.reply = function(code, reason, extraHeaders, body, onSuccess, onFailure) { var rr, vias, length, idx, response, + supported = [], to = this.getHeader('To'), r = 0, v = 0; @@ -360,6 +426,20 @@ IncomingRequest.prototype.reply = function(code, reason, extraHeaders, body, onS response += extraHeaders[idx] +'\r\n'; } + //Supported + if (this.method === SIP.C.INVITE && + (this.ua.contact.pub_guu || this.ua.contact.temp_gruu)) { + supported.push('gruu'); + } + + if (this.ua.configuration.reliable === 'supported') { + supported.push('100rel'); + } + + supported.push('outbound'); + + response += 'Supported: ' + supported + '\r\n'; + if(body) { length = SIP.Utils.str_utf8_length(body); response += 'Content-Type: application/sdp\r\n'; diff --git a/src/Transactions.js b/src/Transactions.js index 0828c0d52..2f68acf1c 100644 --- a/src/Transactions.js +++ b/src/Transactions.js @@ -75,6 +75,7 @@ NonInviteClientTransaction.prototype.onTransportError = function() { this.logger.log('transport error occurred, deleting non-INVITE client transaction ' + this.id); window.clearTimeout(this.F); window.clearTimeout(this.K); + this.stateChanged(C.STATUS_TERMINATED); this.request_sender.ua.destroyTransaction(this); this.request_sender.onTransportError(); }; @@ -82,8 +83,8 @@ NonInviteClientTransaction.prototype.onTransportError = function() { NonInviteClientTransaction.prototype.timer_F = function() { this.logger.log('Timer F expired for non-INVITE client transaction ' + this.id); this.stateChanged(C.STATUS_TERMINATED); - this.request_sender.onRequestTimeout(); this.request_sender.ua.destroyTransaction(this); + this.request_sender.onRequestTimeout(); }; NonInviteClientTransaction.prototype.timer_K = function() { @@ -186,6 +187,7 @@ InviteClientTransaction.prototype.onTransportError = function() { window.clearTimeout(this.B); window.clearTimeout(this.D); window.clearTimeout(this.M); + this.stateChanged(C.STATUS_TERMINATED); this.request_sender.ua.destroyTransaction(this); if (this.state !== C.STATUS_ACCEPTED) { @@ -198,8 +200,8 @@ InviteClientTransaction.prototype.timer_M = function() { this.logger.log('Timer M expired for INVITE client transaction ' + this.id); if(this.state === C.STATUS_ACCEPTED) { - this.stateChanged(C.STATUS_TERMINATED); window.clearTimeout(this.B); + this.stateChanged(C.STATUS_TERMINATED); this.request_sender.ua.destroyTransaction(this); } }; @@ -209,15 +211,15 @@ InviteClientTransaction.prototype.timer_B = function() { this.logger.log('Timer B expired for INVITE client transaction ' + this.id); if(this.state === C.STATUS_CALLING) { this.stateChanged(C.STATUS_TERMINATED); - this.request_sender.onRequestTimeout(); this.request_sender.ua.destroyTransaction(this); + this.request_sender.onRequestTimeout(); } }; InviteClientTransaction.prototype.timer_D = function() { this.logger.log('Timer D expired for INVITE client transaction ' + this.id); - this.stateChanged(C.STATUS_TERMINATED); window.clearTimeout(this.B); + this.stateChanged(C.STATUS_TERMINATED); this.request_sender.ua.destroyTransaction(this); }; @@ -399,6 +401,7 @@ NonInviteServerTransaction.prototype.onTransportError = function() { this.logger.log('transport error occurred, deleting non-INVITE server transaction ' + this.id); window.clearTimeout(this.J); + this.stateChanged(C.STATUS_TERMINATED); this.ua.destroyTransaction(this); } }; @@ -496,9 +499,9 @@ InviteServerTransaction.prototype.timer_H = function() { if(this.state === C.STATUS_COMPLETED) { this.logger.warn('transactions', 'ACK for INVITE server transaction was never received, call will be terminated'); - this.stateChanged(C.STATUS_TERMINATED); } + this.stateChanged(C.STATUS_TERMINATED); this.ua.destroyTransaction(this); }; @@ -531,6 +534,8 @@ InviteServerTransaction.prototype.onTransportError = function() { window.clearTimeout(this.L); window.clearTimeout(this.H); window.clearTimeout(this.I); + + this.stateChanged(C.STATUS_TERMINATED); this.ua.destroyTransaction(this); } }; diff --git a/src/Transport.js b/src/Transport.js index c421913f0..73a8b0087 100644 --- a/src/Transport.js +++ b/src/Transport.js @@ -96,6 +96,8 @@ Transport.prototype = { } this.logger.log('connecting to WebSocket ' + this.server.ws_uri); + this.ua.onTransportConnecting(this, + (this.reconnection_attempts === 0)?1:this.reconnection_attempts); try { this.ws = new WebSocket(this.server.ws_uri, 'sip'); diff --git a/src/UA.js b/src/UA.js index 010ed4a95..5d78fc68b 100644 --- a/src/UA.js +++ b/src/UA.js @@ -41,8 +41,6 @@ var UA, 'application/dtmf-relay' ], - SUPPORTED: 'path, outbound, gruu', - MAX_FORWARDS: 69, TAG_LENGTH: 10 }; @@ -50,6 +48,7 @@ var UA, UA = function(configuration) { var self = this, events = [ + 'connecting', 'connected', 'disconnected', 'newTransaction', @@ -496,6 +495,20 @@ UA.prototype.onTransportConnected = function(transport) { }; +/** + * Transport connecting event + * @private + * @param {SIP.Transport} transport. + * #param {Integer} attempts. + */ + UA.prototype.onTransportConnecting = function(transport, attempts) { + this.emit('connecting', this, { + transport: transport, + attempts: attempts + }); + }; + + /** * new Transaction * @private @@ -566,6 +579,7 @@ UA.prototype.receiveRequest = function(request) { * They are processed as if they had been received outside the dialog. */ if(method === SIP.C.OPTIONS) { + new SIP.Transactions.NonInviteServerTransaction(request, this); request.reply(200, null, [ 'Allow: '+ SIP.Utils.getAllowedMethods(this), 'Accept: '+ C.ACCEPTED_BODY_TYPES @@ -573,10 +587,12 @@ UA.prototype.receiveRequest = function(request) { } else if (method === SIP.C.MESSAGE) { if (!this.checkEvent(methodLower) || this.listeners(methodLower).length === 0) { // UA is not listening for this. Reject immediately. + new SIP.Transactions.NonInviteServerTransaction(request, this); request.reply(405, null, ['Allow: '+ SIP.Utils.getAllowedMethods(this)]); return; } message = new SIP.MessageServerContext(this, request); + request.reply(200, null); this.emit('message', this, message); } else if (method !== SIP.C.INVITE && method !== SIP.C.ACK) { @@ -862,8 +878,8 @@ UA.prototype.loadConfig = function(configuration) { if(configuration.hasOwnProperty(parameter)) { value = configuration[parameter]; - // If the parameter value is null, empty string or undefined then apply its default value. - if(value === null || value === "" || value === undefined) { continue; } + // If the parameter value is null, empty string,undefined, or empty array then apply its default value. + if(value === null || value === "" || value === undefined || (value instanceof Array && value.length === 0)) { continue; } // If it's a number with NaN value then also apply its default value. // NOTE: JS does not allow "value === NaN", the following does the work: else if(typeof(value) === 'number' && window.isNaN(value)) { continue; } @@ -1180,11 +1196,11 @@ UA.configuration_check = { }, instance_id: function(instance_id) { - if (!(/^uuid?:/.test(instance_id))) { - instance_id = 'uuid:' + instance_id; + if ((/^uuid:/i.test(instance_id))) { + instance_id = instance_id.substr(5); } - if(SIP.Grammar.parse(instance_id, 'uuid_URI') === -1) { + if(SIP.Grammar.parse(instance_id, 'uuid') === -1) { return; } else { return instance_id; @@ -1281,7 +1297,7 @@ UA.configuration_check = { }, turn_servers: function(turn_servers) { - var idx, length, turn_server; + var idx, length, turn_server, url; if (turn_servers instanceof Array) { // Do nothing @@ -1292,14 +1308,30 @@ UA.configuration_check = { length = turn_servers.length; for (idx = 0; idx < length; idx++) { turn_server = turn_servers[idx]; - if (!turn_server.server || !turn_server.username || !turn_server.password) { + //Backwards compatibility: Allow defining the turn_server url with the 'server' property. + if (turn_server.server) { + turn_server.urls = [turn_server.server]; + } + + if (!turn_server.urls || !turn_server.username || !turn_server.password) { return; - } else if (!(/^turns?:/.test(turn_server.server))) { - turn_server.server = 'turn:' + turn_server.server; } - if(SIP.Grammar.parse(turn_server.server, 'turn_URI') === -1) { - return; + if (!turn_server.urls instanceof Array) { + turn_server.urls = [turn_server.urls]; + } + + length = turn_server.urls.length; + for (idx = 0; idx < length; idx++) { + url = turn_server.urls[idx]; + + if (!(/^turns?:/.test(url))) { + url = 'turn:' + url; + } + + if(SIP.Grammar.parse(url, 'turn_URI') === -1) { + return; + } } } return turn_servers; diff --git a/src/tail.js b/src/tail.js new file mode 100644 index 000000000..9d7ddc587 --- /dev/null +++ b/src/tail.js @@ -0,0 +1,22 @@ +if (typeof module === "object" && module && typeof module.exports === "object") { + // Expose SIP as module.exports in loaders that implement the Node + // module pattern (including browserify). Do not create the global, since + // the user will be storing it themselves locally, and globals are frowned + // upon in the Node module world. + module.exports = SIP; +} else { + // Otherwise expose SIP to the global object as usual. + window.SIP = SIP; + + // Register as a named AMD module, since SIP can be concatenated with other + // files that may use define, but not via a proper concatenation script that + // understands anonymous AMD modules. A named AMD is safest and most robust + // way to register. Lowercase sip is used because AMD module names are + // derived from file names, and SIP is normally delivered in a lowercase + // file name. + if (typeof define === "function" && define.amd) { + define("sip", [], function () { return SIP; }); + } +} + +})(window); \ No newline at end of file