diff --git a/Gruntfile.js b/Gruntfile.js index 869cb7ad6..20087705b 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -22,6 +22,7 @@ module.exports = function(grunt) { 'src/ClientContext.js', 'src/ServerContext.js', 'src/Session.js', + 'src/Subscription.js', 'src/UA.js', 'src/Utils.js', 'src/SanityCheck.js', diff --git a/src/Dialogs.js b/src/Dialogs.js index a00dfc6d1..ced96f338 100644 --- a/src/Dialogs.js +++ b/src/Dialogs.js @@ -211,7 +211,7 @@ Dialog.prototype = { } break; case SIP.C.NOTIFY: - // RFC6655 3.2 Replace the dialog`s remote target URI if the request is accepted + // RFC6665 3.2 Replace the dialog`s remote target URI if the request is accepted if(request.hasHeader('contact')) { request.server_transaction.on('stateChanged', function(){ if (this.state === SIP.Transactions.C.STATUS_COMPLETED) { diff --git a/src/Subscriber.js b/src/Subscriber.js deleted file mode 100644 index 59ecc2c2c..000000000 --- a/src/Subscriber.js +++ /dev/null @@ -1,455 +0,0 @@ - -/** - * @fileoverview SIP Subscriber (SIP-Specific Event Notifications RFC6665) - */ - - -/** - * @augments SIP - * @class Class creating a SIP Subscriber. - */ - -SIP.Subscriber = function(ua) { - this.logger = ua.getLogger('sip.subscriber') -}; - -SIP.Subscriber.prototype = { - /** - * @private - */ - initSubscriber: function(){ - this.N = null; - this.subscriptions = {}; - }, - - /** - * @private - */ - timer_N: function(){ - this.close(); - }, - - /** - * @private - */ - close: function() { - var subscription; - - if (this.state !== 'terminated') { - this.logger.log('terminating Subscriber'); - - this.state = 'terminated'; - window.clearTimeout(this.N); - - for (subscription in this.subscriptions) { - this.subscriptions[subscription].unsubscribe(); - } - - //Delete subscriber from ua.sessions - delete this.ua.sessions[this.id]; - - this.onTerminate(); - } - }, - - /** - * @private - */ - onSubscriptionTerminate: function(subscription) { - - delete this.subscriptions[subscription.id]; - - if (Object.keys(this.subscriptions).length === 0) { - this.close(); - } - }, - - subscribe: function() { - var subscriber, from_tag, expires; - - if (['notify_wait', 'pending', 'active', 'terminated'].indexOf(this.state) !== -1) { - this.logger.error('subscription is already on'); - return; - } - - subscriber = this; - from_tag = SIP.Utils.newTag(); - - new function() { - this.request = subscriber.createSubscribeRequest(null,{from_tag:from_tag}); - var request_sender = new SIP.RequestSender(this, subscriber.ua); - - this.receiveResponse = function(response) { - switch(true) { - case /^1[0-9]{2}$/.test(response.status_code): // Ignore provisional responses. - break; - case /^2[0-9]{2}$/.test(response.status_code): - expires = response.s('Expires'); - - if (expires && expires <= subscriber.expires) { - window.clearTimeout(subscriber.N); - subscriber.N = window.setTimeout( - function() {subscriber.timer_N();}, - (expires * 1000) - ); - // Save route set and to tag for backwards compatibility (3265) - subscriber.route_set_2xx = response.getHeaderAll('record-route').reverse(); - subscriber.to_tag_2xx = response.s('to').tag; - subscriber.initial_local_seqnum = parseInt(response.s('cseq').value,10); - } - else { - subscriber.close(); - - if (!expires) { - this.logger.warn('Expires header missing in a 200-class response to SUBSCRIBE'); - subscriber.onFailure(null, SIP.C.EXPIRES_HEADER_MISSING); - } else { - this.logger.warn('Expires header in a 200-class response to SUBSCRIBE with a higher value than the indicated in the request'); - subscriber.onFailure(null, SIP.C.INVALID_EXPIRES_HEADER); - } - } - break; - default: - subscriber.close(); - subscriber.onFailure(response,null); - break; - } - }; - - this.onRequestTimeout = function() { - subscriber.onFailure(null, SIP.C.causes.REQUEST_TIMEOUT); - }; - - this.onTransportError = function() { - subscriber.onFailure(null, SIP.C.causes.CONNECTION_ERROR); - }; - - this.send = function() { - subscriber.id = this.request.headers['Call-ID'] + from_tag; - subscriber.ua.sessions[subscriber.id] = subscriber; - subscriber.state = 'notify_wait'; - subscriber.N = window.setTimeout( - function() {subscriber.timer_N();}, - (SIP.Timers.T1 * 64) - ); - request_sender.send(); - }; - this.send(); - }; - - }, - - unsubscribe: function() { - this.close(); - }, - - /** - * Every Session needs a 'terminate' method in order to be called by SIP.UA - * when user fires SIP.UA.close() - * @private - */ - terminate: function() { - this.unsubscribe(); - }, - - refresh: function() { - var subscription; - - for (subscription in this.subscriptions) { - this.subscriptions[subscription].subscribe(); - } - }, - - /** - * @private - */ - receiveRequest: function(request) { - var subscription_state, expires; - - if (!this.matchEvent(request)) { - return; - } - - subscription_state = request.s('Subscription-State'); - expires = subscription_state.expires || this.expires; - - switch (subscription_state.state) { - case 'pending': - case 'active': - //create the subscription. - window.clearTimeout(this.N); - new SIP.Subscription(this, request, subscription_state.state, expires); - break; - case 'terminated': - if (subscription_state.reason) { - this.logger.log('terminating subscription with reason '+ subscription_state.reason); - } - window.clearTimeout(this.N); - this.close(); - break; - } - }, - - /** - * @private - */ - matchEvent: function(request) { - var event; - - // Check mandatory header Event - if (!request.hasHeader('Event')) { - this.logger.warn('missing Event header'); - return false; - } - // Check mandatory header Subscription-State - if (!request.hasHeader('Subscription-State')) { - this.logger.warn('missing Subscription-State header'); - return false; - } - - // Check whether the event in NOTIFY matches the event in SUBSCRIBE - event = request.s('event').event; - - if (this.event !== event) { - this.logger.warn('event match failed'); - request.reply(481, 'Event Match Failed'); - return false; - } else { - return true; - } - } -}; - -/** - * @augments SIP - * @class Class creating a SIP Subscription. - */ -SIP.Subscription = function (subscriber, request, state, expires) { - - this.id = null; - this.subscriber = subscriber; - this.ua = subscriber.ua; - this.state = state; - this.expires = expires; - this.dialog = null; - this.N = null; - this.error_codes = [404,405,410,416,480,481,482,483,484,485,489,501,604]; - - //Create dialog and pass the request to receiveRequest method. - if (this.createConfirmedDialog(request,'UAS')) { - this.id = this.dialog.id.toString(); - this.subscriber.subscriptions[this.id] = this; - - /* Update the route_set - * If the endpoint responded with a 2XX to the initial subscribe - */ - if (request.from_tag === this.subscriber.to_tag_2xx) { - this.dialog.route_set = this.subscriber.route_set_2xx; - } - - this.dialog.local_seqnum = this.subscriber.initial_local_seqnum; - - this.receiveRequest(request, true); - } -}; - -SIP.Subscription.prototype = { - /** - * @private - */ - timer_N: function(){ - if (this.state === 'terminated') { - this.close(); - } else if (this.state === 'pending') { - this.state = 'terminated'; - this.close(); - } else { - this.subscribe(); - } - }, - - /** - * @private - */ - close: function() { - this.state = 'terminated'; - this.terminateDialog(); - window.clearTimeout(this.N); - this.subscriber.onSubscriptionTerminate(this); - }, - - /** - * @private - */ - createConfirmedDialog: function(message, type) { - var local_tag, remote_tag, id, dialog; - - // Create a confirmed dialog given a message and type ('UAC' or 'UAS') - local_tag = (type === 'UAS') ? message.to_tag : message.from_tag; - remote_tag = (type === 'UAS') ? message.from_tag : message.to_tag; - id = message.call_id + local_tag + remote_tag; - - dialog = new SIP.Dialog(this, message, type); - - if(dialog) { - this.dialog = dialog; - return true; - } - // Dialog not created due to an error - else { - return false; - } - }, - - /** - * @private - */ - terminateDialog: function() { - if(this.dialog) { - this.dialog.terminate(); - delete this.dialog; - } - }, - - /** - * @private - */ - receiveRequest: function(request, initial) { - var subscription_state, - subscription = this; - - if (!initial && !this.subscriber.matchEvent(request)) { - this.logger.warn('NOTIFY request does not match event'); - return; - } - - request.reply(200, SIP.C.REASON_200, [ - 'Contact: <'+ this.subscriber.contact +'>' - ]); - - subscription_state = request.s('Subscription-State'); - - switch (subscription_state.state) { - case 'active': - this.state = 'active'; - this.subscriber.receiveInfo(request); - /* falls through */ - case 'pending': - this.expires = subscription_state.expires || this.expires; - window.clearTimeout(subscription.N); - subscription.N = window.setTimeout( - function() {subscription.timer_N();}, - (this.expires * 1000) - ); - break; - case 'terminated': - if (subscription_state.reason) { - this.logger.log('terminating subscription with reason '+ subscription_state.reason); - } - this.close(); - this.subscriber.receiveInfo(request); - break; - } - }, - - subscribe: function() { - var expires, - subscription = this; - - new function() { - this.request = subscription.subscriber.createSubscribeRequest(subscription.dialog); - - var request_sender = new SIP.RequestSender(this, subscription.subscriber.ua); - - this.receiveResponse = function(response) { - if (subscription.error_codes.indexOf(response.status_code) !== -1) { - subscription.close(); - subscription.subscriber.onFailure(response, null); - } else { - switch(true) { - case /^1[0-9]{2}$/.test(response.status_code): // Ignore provisional responses. - break; - case /^2[0-9]{2}$/.test(response.status_code): - expires = response.s('Expires'); - - if (expires && expires <= subscription.expires) { - window.clearTimeout(subscription.N); - subscription.N = window.setTimeout( - function() {subscription.timer_N();}, - (expires * 1000) - ); - }else { - subscription.close(); - - if (!expires) { - this.logger.warn('Expires header missing in a 200-class response to SUBSCRIBE'); - subscription.subscriber.onFailure(null, SIP.C.EXPIRES_HEADER_MISSING); - } else { - this.logger.warn('Expires header in a 200-class response to SUBSCRIBE with a higher value than the indicated in the request'); - subscription.subscriber.onFailure(null, SIP.C.INVALID_EXPIRES_HEADER); - } - } - break; - default: - subscription.close(); - subscription.subscriber.onFailure(response,null); - break; - } - } - }; - - this.send = function() { - window.clearTimeout(subscription.N); - subscription.N = window.setTimeout( - function() {subscription.timer_N();}, - (SIP.Timers.T1 * 64) - ); - request_sender.send(); - }; - - this.onRequestTimeout = function() { - subscription.subscriber.onFailure(null, SIP.C.causes.REQUEST_TIMEOUT); - }; - - this.onTransportError = function() { - subscription.subscriber.onFailure(null, SIP.C.causes.CONNECTION_ERROR); - }; - - this.send(); - }; - }, - - unsubscribe: function() { - var subscription = this; - - this.state = 'terminated'; - - new function() { - this.request = subscription.subscriber.createSubscribeRequest(subscription.dialog); - this.request.setHeader('Expires', 0); - - var request_sender = new SIP.RequestSender(this, subscription.subscriber.ua); - - //Don't care about response. - this.receiveResponse = function(){}; - - this.send = function() { - window.clearTimeout(subscription.N); - subscription.N = window.setTimeout( - function() {subscription.timer_N();}, - (SIP.Timers.T1 * 64) - ); - request_sender.send(); - }; - - this.onRequestTimeout = function() { - subscription.subscriber.onFailure(null, SIP.C.causes.REQUEST_TIMEOUT); - }; - this.onTransportError = function() { - subscription.subscriber.onFailure(null, SIP.C.causes.CONNECTION_ERROR); - }; - - this.send(); - }; - } -}; diff --git a/src/Subscription.js b/src/Subscription.js new file mode 100644 index 000000000..62f302234 --- /dev/null +++ b/src/Subscription.js @@ -0,0 +1,310 @@ + +/** + * @fileoverview SIP Subscriber (SIP-Specific Event Notifications RFC6665) + */ + +/** + * @augments SIP + * @class Class creating a SIP Subscription. + */ +SIP.Subscription = function (ua, target, event, options) { + var events; + + options = options || {}; + options.extraHeaders = options.extraHeaders || []; + + events = ['notify']; + this.id = null; + this.ua = ua; + this.state = 'init'; + + if (!event) { + throw new TypeError('Event necessary to create a subscription.'); + } else { + //TODO: check for valid events here probably make a list in SIP.C; or leave it up to app to check? + //The check may need to/should probably occur on the other side, + this.event = event; + } + + if (!options.expires || options.expires < 3600) { + this.expires = 3600; //1 hour (this is minimum by RFC 6665) + } else if(typeof options.expires !== 'number'){ + ua.logger.warn('expires must be a number. Using default of 3600.'); + this.expires = 3600; + } else { + this.expires = options.expires; + } + + options.extraHeaders.push('Event: ' + this.event); + options.extraHeaders.push('Expires: ' + this.expires); + + if (options.body) { + this.body = options.body; + } + + this.contact = ua.contact.toString(); + + options.extraHeaders.push('Contact: '+ this.contact); + options.extraHeaders.push('Allow: '+ SIP.Utils.getAllowedMethods(ua)); + + SIP.Utils.augment(this, SIP.ClientContext, [ua, SIP.C.SUBSCRIBE, target, options]); + + this.logger = ua.getLogger('sip.subscription'); + + this.dialog = null; + this.timers = {N: null, sub_duration: null}; + this.error_codes = [404,405,410,416,480,481,482,483,484,485,489,501,604]; + + this.initMoreEvents(events); +}; + +SIP.Subscription.prototype = { + failed: function(response, cause) { + var code = response ? response.status_code : null; + + return this.emit('failed', { + response: response || null, + cause: cause, + code: code + }); + }, + + subscribe: function() { + var sub = this; + + if (['notify_wait', 'pending', 'active', 'terminated'].indexOf(this.state) !== -1) { + this.logger.error('subscription is already on'); + return; + } + + window.clearTimeout(this.timers.sub_duration); + window.clearTimeout(this.timers.N); + this.timers.N = window.setTimeout(function(){sub.timer_fire();}, SIP.Timers.TIMER_N); + + this.send(); + + this.state = 'notify_wait'; + + return this; + }, + + receiveResponse: function(response) { + var expires, sub = this; + + if (this.error_codes.indexOf(response.status_code) !== -1) { + this.close(); + this.failed(response, null); + } else if (/^2[0-9]{2}$/.test(response.status_code)){ + expires = response.getHeader('Expires'); + window.clearTimeout(this.timers.N); + + if (this.createConfirmedDialog(response,'UAC')) { + this.id = this.dialog.id.toString(); + this.ua.subscriptions[this.id] = this; + // UPDATE ROUTE SET TO BE BACKWARDS COMPATIBLE? + } + + if (expires && expires <= this.expires) { + this.timers.sub_duration = window.setTimeout(function(){sub.subscribe();}, expires * 1000); + } else { + this.close(); + + if (!expires) { + this.logger.warn('Expires header missing in a 200-class response to SUBSCRIBE'); + this.failed(response, SIP.C.EXPIRES_HEADER_MISSING); + } else { + this.logger.warn('Expires header in a 200-class response to SUBSCRIBE with a higher value than the one in the request'); + this.failed(response, SIP.C.INVALID_EXPIRES_HEADER); + } + } + } //Used to just ignore provisional responses; now ignores everything except error_codes and 2xx + }, + + unsubscribe: function() { + var extraHeaders = [], sub = this; + + this.state = 'terminated'; + + extraHeaders.push('Event: ' + this.event); + extraHeaders.push('Expires: 0'); + + extraHeaders.push('Contact: '+ this.contact); + extraHeaders.push('Allow: '+ SIP.Utils.getAllowedMethods(this.ua)); + + this.request = new SIP.OutgoingRequest(this.method, this.request.to.uri.toString(), this.ua, null, extraHeaders); + + //MAYBE, may want to see state + this.receiveResponse = function(){}; + + window.clearTimeout(this.timers.sub_duration); + window.clearTimeout(this.timers.N); + this.timers.N = window.setTimeout(function(){sub.timer_fire();}, SIP.Timers.TIMER_N); + + this.send(); + }, + + onRequestTimeout: function() { + this.failed(null, SIP.C.causes.REQUEST_TIMEOUT); + }, + + onTransportError: function() { + this.failed(null, SIP.C.causes.CONNECTION_ERROR); + }, + + /** + * @private + */ + timer_fire: function(){ + if (this.state === 'terminated') { + this.close(); + } else if (this.state === 'pending' || this.state === 'notify_wait') { + this.state = 'terminated'; + this.close(); + } else { + this.subscribe(); + } + }, + + /** + * @private + */ + close: function() { + this.terminateDialog(); + window.clearTimeout(this.timers.N); + window.clearTimeout(this.timers.sub_duration); + + delete this.ua.subscriptions[this.id]; + }, + + /** + * @private + */ + createConfirmedDialog: function(message, type) { + var dialog; + + dialog = new SIP.Dialog(this, message, type); + + if(!dialog.error) { + this.dialog = dialog; + return true; + } + // Dialog not created due to an error + else { + return false; + } + }, + + /** + * @private + */ + terminateDialog: function() { + if(this.dialog) { + this.dialog.terminate(); + delete this.dialog; + } + }, + + /** + * @private + */ + receiveRequest: function(request) { + var sub_state, sub = this; + + if (!this.matchEvent(request)) { //checks event and subscription_state headers + request.reply(489); + return; + } + + sub_state = request.parseHeader('Subscription-State'); + + request.reply(200, SIP.C.REASON_200); + + window.clearTimeout(this.timers.N); + window.clearTimeout(this.timers.sub_duration); + + this.emit('notify', { + request: request + }); + + switch (sub_state.state) { + case 'active': + this.state = 'active'; + + if (sub_state.expires) { + if (sub_state.expires < 3600) { + sub_state.expires = 3600; + } else if (sub_state.expires > this.expires) { + sub_state.expires = this.expires; + } + this.timers.sub_duration = window.setTimeout(function(){sub.subscribe();}, (sub_state.expires * 1000)); + } + break; + case 'pending': + if (this.state === 'notify_wait') { + if (sub_state.expires) { + if (sub_state.expires < 3600) { + sub_state.expires = 3600; + } else if (sub_state.expires > this.expires) { + sub_state.expires = this.expires; + } + this.timers.sub_duration = window.setTimeout(function(){sub.subscribe();}, (sub_state.expires * 1000)); + } + } + this.state = 'pending'; + break; + case 'terminated': + if (sub_state.reason) { + this.logger.log('terminating subscription with reason '+ sub_state.reason); + switch (sub_state.reason) { + case 'deactivated': + case 'timeout': + this.subscribe(); + return; + case 'probation': + case 'giveup': + if(sub_state.params && sub_state.params['retry-after']) { + this.timers.sub_duration = window.setTimeout(function(){sub.subscribe();}, sub_state.params['retry-after']); + } else { + this.subscribe(); + } + return; + case 'rejected': + case 'noresource': + case 'invariant': + break; + } + } + this.close(); + break; + } + }, + + /** + * @private + */ + matchEvent: function(request) { + var event; + + // Check mandatory header Event + if (!request.hasHeader('Event')) { + this.logger.warn('missing Event header'); + return false; + } + // Check mandatory header Subscription-State + if (!request.hasHeader('Subscription-State')) { + this.logger.warn('missing Subscription-State header'); + return false; + } + + // Check whether the event in NOTIFY matches the event in SUBSCRIBE + event = request.parseHeader('event').event; + + if (this.event !== event) { + this.logger.warn('event match failed'); + request.reply(481, 'Event Match Failed'); + return false; + } else { + return true; + } + } +}; diff --git a/src/Timers.js b/src/Timers.js index 75a66998a..500d67652 100644 --- a/src/Timers.js +++ b/src/Timers.js @@ -24,6 +24,7 @@ Timers = { TIMER_K: 0 * T4, TIMER_L: 64 * T1, TIMER_M: 64 * T1, + TIMER_N: 64 * T1, PROVISIONAL_RESPONSE_INTERVAL: 60000 // See RFC 3261 Section 13.3.1.1 }; diff --git a/src/UA.js b/src/UA.js index 4043b811e..4f813109a 100644 --- a/src/UA.js +++ b/src/UA.js @@ -89,6 +89,7 @@ UA = function(configuration) { this.data = {}; this.sessions = {}; + this.subscriptions = {}; this.transport = null; this.contact = null; this.status = C.STATUS_INIT; @@ -249,6 +250,10 @@ UA.prototype.invite = function(target, options) { return new SIP.InviteClientContext(this, target, options).invite(); }; +UA.prototype.subscribe = function(target, event, options) { + return new SIP.Subscription(this, target, event, options).subscribe(); +}; + /** * Send a message. * diff --git a/test/spec/SpecUA.js b/test/spec/SpecUA.js index fcbd9f710..76fd68e5f 100644 --- a/test/spec/SpecUA.js +++ b/test/spec/SpecUA.js @@ -395,12 +395,45 @@ describe('UA', function() { expect(inviteSpy).toHaveBeenCalledWith(); }); - it('returns the result of calling the invite context invite mehtod', function() { + it('returns the result of calling the invite context invite method', function() { var options = { option : 'things' }; expect(UA.invite(target,options)).toEqual('Invite Client Context Invite'); }); }); + describe('.subscribe', function() { + var subscribeSpy; + var target; + var event; + + beforeEach(function() { + target = 'target'; + event = 'event' + subscribeSpy = jasmine.createSpy('subscribe').andReturn('Subscription'); + + spyOn(SIP, 'Subscription').andReturn({ + subscribe: subscribeSpy + }); + }); + + it('creates a Subscription with itself, target, and options as parameters', function() { + var options = {}; + UA.subscribe(target, event, options); + expect(SIP.Subscription).toHaveBeenCalledWith(UA,target, event, options); + }); + + it('calls the Subscription method with no arguments', function() { + var options = { option : 'things' }; + UA.subscribe(target, event, options); + expect(subscribeSpy).toHaveBeenCalledWith(); + }); + + it('returns the result of calling the Subscribe method', function() { + var options = { option : 'things' }; + expect(UA.subscribe(target, event, options)).toEqual('Subscription'); + }); + }); + describe('.request', function() { var method; var target;