UNPKG

jssip

Version:

The Javascript SIP library

335 lines (334 loc) 11.7 kB
"use strict"; const EventEmitter = require('events').EventEmitter; const Exceptions = require('./Exceptions'); const Logger = require('./Logger'); const JsSIP_C = require('./Constants'); const Utils = require('./Utils'); const Dialog = require('./Dialog'); const logger = new Logger('Notifier'); /** * Termination codes. */ const C = { // Termination codes. NOTIFY_RESPONSE_TIMEOUT: 0, NOTIFY_TRANSPORT_ERROR: 1, NOTIFY_NON_OK_RESPONSE: 2, NOTIFY_AUTHENTICATION_FAILED: 3, FINAL_NOTIFY_SENT: 4, UNSUBSCRIBE_RECEIVED: 5, SUBSCRIPTION_EXPIRED: 6, // Notifer states. STATE_PENDING: 0, STATE_ACTIVE: 1, STATE_TERMINATED: 2, // RFC 6665 3.1.1, default expires value. DEFAULT_EXPIRES_SEC: 900, }; /** * RFC 6665 Notifier implementation. */ module.exports = class Notifier extends EventEmitter { /** * Expose C object. */ static get C() { return C; } static init_incoming(request, callback) { try { Notifier.checkSubscribe(request); } catch (error) { logger.warn('Notifier.init_incoming: invalid request. Error: ', error.message); request.reply(405); return; } callback(); } static checkSubscribe(subscribe) { if (!subscribe) { throw new TypeError('Not enough arguments. Missing subscribe request'); } if (subscribe.method !== JsSIP_C.SUBSCRIBE) { throw new TypeError('Invalid method for Subscribe request'); } if (!subscribe.hasHeader('contact')) { throw new TypeError('Missing Contact header in subscribe request'); } if (!subscribe.hasHeader('event')) { throw new TypeError('Missing Event header in subscribe request'); } const expires = subscribe.getHeader('expires'); if (expires) { const parsed_expires = parseInt(expires); if (!Utils.isDecimal(parsed_expires) || parsed_expires < 0) { throw new TypeError('Invalid Expires header field in subscribe request'); } } } /** * @param {UA} ua - JsSIP User Agent instance. * @param {IncomingRequest} subscribe - Subscribe request. * @param {string} contentType - Content-Type header value. * @param {NotifierOptions} options - Optional parameters. * @param {Array<string>} extraHeaders - Additional SIP headers. * @param {string} allowEvents - Allow-Events header value. * @param {boolean} pending - Set initial dialog state as "pending". * @param {number} defaultExpires - Default expires value (seconds). */ constructor(ua, subscribe, contentType, { extraHeaders, allowEvents, pending, defaultExpires }) { logger.debug('new'); super(); if (!contentType) { throw new TypeError('Not enough arguments. Missing contentType'); } Notifier.checkSubscribe(subscribe); const eventName = subscribe.getHeader('event'); this._ua = ua; this._initial_subscribe = subscribe; this._expires_timestamp = null; this._expires_timer = null; this._defaultExpires = defaultExpires || C.DEFAULT_EXPIRES_SEC; // Notifier state: pending, active, terminated. this._state = pending ? C.STATE_PENDING : C.STATE_ACTIVE; this._content_type = contentType; this._headers = Utils.cloneArray(extraHeaders); this._headers.push(`Event: ${eventName}`); // Use contact from extraHeaders or create it. this._contact = this._headers.find(header => header.startsWith('Contact')); if (!this._contact) { this._contact = `Contact: ${this._ua._contact.toString()}`; this._headers.push(this._contact); } if (allowEvents) { this._headers.push(`Allow-Events: ${allowEvents}`); } this._target = subscribe.from.uri.user; subscribe.to_tag = Utils.newTag(); // Custom session empty object for high level use. this._data = {}; } // Expose Notifier constants as a property of the Notifier instance. get C() { return C; } /** * Get dialog state. */ get state() { return this._state; } /** * Get dialog id. */ get id() { return this._dialog ? this._dialog.id : null; } get data() { return this._data; } set data(_data) { this._data = _data; } /** * Dialog callback. * Called also for initial subscribe. * Supported RFC 6665 4.4.3: initial fetch subscribe (with expires: 0). */ receiveRequest(request) { if (request.method !== JsSIP_C.SUBSCRIBE) { request.reply(405); return; } this._setExpires(request); // Create dialog for normal and fetch-subscribe. if (!this._dialog) { this._dialog = new Dialog(this, request, 'UAS'); } request.reply(200, null, [`Expires: ${this._expires}`, `${this._contact}`]); const body = request.body; const content_type = request.getHeader('content-type'); const is_unsubscribe = this._expires === 0; if (!is_unsubscribe) { this._setExpiresTimer(); } logger.debug('emit "subscribe"'); this.emit('subscribe', is_unsubscribe, request, body, content_type); if (is_unsubscribe) { this._terminateDialog(C.UNSUBSCRIBE_RECEIVED); } } /** * User API */ /** * Call this method after creating the Notifier instance and setting the event handlers. */ start() { logger.debug('start()'); if (this._state === C.STATE_TERMINATED) { throw new Exceptions.InvalidStateError(this._state); } this.receiveRequest(this._initial_subscribe); } /** * Switch pending dialog state to active. */ setActiveState() { logger.debug('setActiveState()'); if (this._state === C.STATE_TERMINATED) { throw new Exceptions.InvalidStateError(this._state); } if (this._state === C.STATE_PENDING) { this._state = C.STATE_ACTIVE; } } /** * Send the initial and subsequent notify request. * @param {string} body - notify request body. */ notify(body = null) { logger.debug('notify()'); if (this._state === C.STATE_TERMINATED) { throw new Exceptions.InvalidStateError(this._state); } const expires = Math.floor((this._expires_timestamp - new Date().getTime()) / 1000); // expires_timer is about to trigger. Clean up the timer and terminate. if (expires <= 0) { if (!this._expires_timer) { logger.error('expires timer is not set'); } clearTimeout(this._expires_timer); this.terminate(body, 'timeout'); } else { this._sendNotify([`;expires=${expires}`], body); } } /** * Terminate. (Send the final NOTIFY request). * * @param {string} body - Notify message body. * @param {string} reason - Set Subscription-State reason parameter. * @param {number} retryAfter - Set Subscription-State retry-after parameter. */ terminate(body = null, reason = null, retryAfter = null) { logger.debug('terminate()'); if (this._state === C.STATE_TERMINATED) { return; } const subsStateParameters = []; if (reason) { subsStateParameters.push(`;reason=${reason}`); } if (retryAfter !== null) { subsStateParameters.push(`;retry-after=${retryAfter}`); } this._sendNotify(subsStateParameters, body, null, 'terminated'); this._terminateDialog(reason === 'timeout' ? C.SUBSCRIPTION_EXPIRED : C.FINAL_NOTIFY_SENT); } /** * Private API */ _terminateDialog(termination_code) { if (this._state === C.STATE_TERMINATED) { return; } this._state = C.STATE_TERMINATED; clearTimeout(this._expires_timer); if (this._dialog) { this._dialog.terminate(); this._dialog = null; } logger.debug(`emit "terminated" code=${termination_code}`); this.emit('terminated', termination_code); } _setExpires(request) { if (request.hasHeader('expires')) { this._expires = parseInt(request.getHeader('expires')); } else { this._expires = this._defaultExpires; logger.debug(`missing Expires header field, default value set: ${this._expires}`); } } /** * @param {Array<string>} subsStateParams subscription state parameters. * @param {String} body Notify body * @param {Array<string>} extraHeaders */ _sendNotify(subsStateParameters, body = null, extraHeaders = null, state = null) { // Prevent send notify after final notify. if (this._state === C.STATE_TERMINATED) { logger.warn('final notify already sent'); return; } // Build Subscription-State header with parameters. let subsState = `Subscription-State: ${state || this._parseState()}`; for (const param of subsStateParameters) { subsState += param; } let headers = Utils.cloneArray(this._headers); headers.push(subsState); if (extraHeaders) { headers = headers.concat(extraHeaders); } if (body) { headers.push(`Content-Type: ${this._content_type}`); } this._dialog.sendRequest(JsSIP_C.NOTIFY, { body, extraHeaders: headers, eventHandlers: { onRequestTimeout: () => { this._terminateDialog(C.NOTIFY_RESPONSE_TIMEOUT); }, onTransportError: () => { this._terminateDialog(C.NOTIFY_TRANSPORT_ERROR); }, onErrorResponse: response => { if (response.status_code === 401 || response.status_code === 407) { this._terminateDialog(C.NOTIFY_AUTHENTICATION_FAILED); } else { this._terminateDialog(C.NOTIFY_NON_OK_RESPONSE); } }, onDialogError: () => { this._terminateDialog(C.NOTIFY_NON_OK_RESPONSE); }, }, }); } _setExpiresTimer() { this._expires_timestamp = new Date().getTime() + this._expires * 1000; clearTimeout(this._expires_timer); this._expires_timer = setTimeout(() => { if (this._state === C.STATE_TERMINATED) { return; } logger.debug('emit "expired"'); // Client can hook into the 'expired' event and call terminate to send a custom notify. this.emit('expired'); // This will be no-op if the client already called `terminate()`. this.terminate(null, 'timeout'); }, this._expires * 1000); } _parseState() { switch (this._state) { case C.STATE_PENDING: { return 'pending'; } case C.STATE_ACTIVE: { return 'active'; } case C.STATE_TERMINATED: { return 'terminated'; } default: { throw new TypeError('wrong state value'); } } } };