jssip
Version:
the Javascript SIP library
1,120 lines (962 loc) • 24.5 kB
JavaScript
const EventEmitter = require('events').EventEmitter;
const Logger = require('./Logger');
const JsSIP_C = require('./Constants');
const Registrator = require('./Registrator');
const RTCSession = require('./RTCSession');
const Message = require('./Message');
const Options = require('./Options');
const Transactions = require('./Transactions');
const Transport = require('./Transport');
const Utils = require('./Utils');
const Exceptions = require('./Exceptions');
const URI = require('./URI');
const Parser = require('./Parser');
const SIPMessage = require('./SIPMessage');
const sanityCheck = require('./sanityCheck');
const config = require('./Config');
const logger = new Logger('UA');
const C = {
// UA status codes.
STATUS_INIT : 0,
STATUS_READY : 1,
STATUS_USER_CLOSED : 2,
STATUS_NOT_READY : 3,
// UA error codes.
CONFIGURATION_ERROR : 1,
NETWORK_ERROR : 2
};
/**
* The User-Agent class.
* @class JsSIP.UA
* @param {Object} configuration Configuration parameters.
* @throws {JsSIP.Exceptions.ConfigurationError} If a configuration parameter is invalid.
* @throws {TypeError} If no configuration is given.
*/
module.exports = class UA extends EventEmitter
{
// Expose C object.
static get C()
{
return C;
}
constructor(configuration)
{
logger.debug('new() [configuration:%o]', configuration);
super();
this._cache = {
credentials : {}
};
this._configuration = Object.assign({}, config.settings);
this._dynConfiguration = {};
this._dialogs = {};
// User actions outside any session/dialog (MESSAGE/OPTIONS).
this._applicants = {};
this._sessions = {};
this._transport = null;
this._contact = null;
this._status = C.STATUS_INIT;
this._error = null;
this._transactions = {
nist : {},
nict : {},
ist : {},
ict : {}
};
// Custom UA empty object for high level use.
this._data = {};
this._closeTimer = null;
// Check configuration argument.
if (configuration === undefined)
{
throw new TypeError('Not enough arguments');
}
// Load configuration.
try
{
this._loadConfig(configuration);
}
catch (e)
{
this._status = C.STATUS_NOT_READY;
this._error = C.CONFIGURATION_ERROR;
throw e;
}
// Initialize registrator.
this._registrator = new Registrator(this);
}
get C()
{
return C;
}
get status()
{
return this._status;
}
get contact()
{
return this._contact;
}
get configuration()
{
return this._configuration;
}
get transport()
{
return this._transport;
}
// =================
// High Level API
// =================
/**
* Connect to the server if status = STATUS_INIT.
* Resume UA after being closed.
*/
start()
{
logger.debug('start()');
if (this._status === C.STATUS_INIT)
{
this._transport.connect();
}
else if (this._status === C.STATUS_USER_CLOSED)
{
logger.debug('restarting UA');
// Disconnect.
if (this._closeTimer !== null)
{
clearTimeout(this._closeTimer);
this._closeTimer = null;
this._transport.disconnect();
}
// Reconnect.
this._status = C.STATUS_INIT;
this._transport.connect();
}
else if (this._status === C.STATUS_READY)
{
logger.debug('UA is in READY status, not restarted');
}
else
{
logger.debug('ERROR: connection is down, Auto-Recovery system is trying to reconnect');
}
// Set dynamic configuration.
this._dynConfiguration.register = this._configuration.register;
}
/**
* Register.
*/
register()
{
logger.debug('register()');
this._dynConfiguration.register = true;
this._registrator.register();
}
/**
* Unregister.
*/
unregister(options)
{
logger.debug('unregister()');
this._dynConfiguration.register = false;
this._registrator.unregister(options);
}
/**
* Get the Registrator instance.
*/
registrator()
{
return this._registrator;
}
/**
* Registration state.
*/
isRegistered()
{
return this._registrator.registered;
}
/**
* Connection state.
*/
isConnected()
{
return this._transport.isConnected();
}
/**
* Make an outgoing call.
*
* -param {String} target
* -param {Object} [options]
*
* -throws {TypeError}
*
*/
call(target, options)
{
logger.debug('call()');
const session = new RTCSession(this);
session.connect(target, options);
return session;
}
/**
* Send a message.
*
* -param {String} target
* -param {String} body
* -param {Object} [options]
*
* -throws {TypeError}
*
*/
sendMessage(target, body, options)
{
logger.debug('sendMessage()');
const message = new Message(this);
message.send(target, body, options);
return message;
}
/**
* Send a SIP OPTIONS.
*
* -param {String} target
* -param {String} [body]
* -param {Object} [options]
*
* -throws {TypeError}
*
*/
sendOptions(target, body, options)
{
logger.debug('sendOptions()');
const message = new Options(this);
message.send(target, body, options);
return message;
}
/**
* Terminate ongoing sessions.
*/
terminateSessions(options)
{
logger.debug('terminateSessions()');
for (const idx in this._sessions)
{
if (!this._sessions[idx].isEnded())
{
this._sessions[idx].terminate(options);
}
}
}
/**
* Gracefully close.
*
*/
stop()
{
logger.debug('stop()');
// Remove dynamic settings.
this._dynConfiguration = {};
if (this._status === C.STATUS_USER_CLOSED)
{
logger.debug('UA already closed');
return;
}
// Close registrator.
this._registrator.close();
// If there are session wait a bit so CANCEL/BYE can be sent and their responses received.
const num_sessions = Object.keys(this._sessions).length;
// Run _terminate_ on every Session.
for (const session in this._sessions)
{
if (Object.prototype.hasOwnProperty.call(this._sessions, session))
{
logger.debug(`closing session ${session}`);
try { this._sessions[session].terminate(); }
catch (error) {}
}
}
// Run _close_ on every applicant.
for (const applicant in this._applicants)
{
if (Object.prototype.hasOwnProperty.call(this._applicants, applicant))
try { this._applicants[applicant].close(); }
catch (error) {}
}
this._status = C.STATUS_USER_CLOSED;
const num_transactions =
Object.keys(this._transactions.nict).length +
Object.keys(this._transactions.nist).length +
Object.keys(this._transactions.ict).length +
Object.keys(this._transactions.ist).length;
if (num_transactions === 0 && num_sessions === 0)
{
this._transport.disconnect();
}
else
{
this._closeTimer = setTimeout(() =>
{
this._closeTimer = null;
this._transport.disconnect();
}, 2000);
}
}
/**
* Normalice a string into a valid SIP request URI
* -param {String} target
* -returns {JsSIP.URI|undefined}
*/
normalizeTarget(target)
{
return Utils.normalizeTarget(target, this._configuration.hostport_params);
}
/**
* Allow retrieving configuration and autogenerated fields in runtime.
*/
get(parameter)
{
switch (parameter)
{
case 'authorization_user':
return this._configuration.authorization_user;
case 'realm':
return this._configuration.realm;
case 'ha1':
return this._configuration.ha1;
case 'authorization_jwt':
return this._configuration.authorization_jwt;
default:
logger.warn('get() | cannot get "%s" parameter in runtime', parameter);
return undefined;
}
}
/**
* Allow configuration changes in runtime.
* Returns true if the parameter could be set.
*/
set(parameter, value)
{
switch (parameter)
{
case 'authorization_user': {
this._configuration.authorization_user = String(value);
break;
}
case 'password': {
this._configuration.password = String(value);
break;
}
case 'realm': {
this._configuration.realm = String(value);
break;
}
case 'ha1': {
this._configuration.ha1 = String(value);
// Delete the plain SIP password.
this._configuration.password = null;
break;
}
case 'authorization_jwt': {
this._configuration.authorization_jwt = String(value);
break;
}
case 'display_name': {
this._configuration.display_name = value;
break;
}
case 'extra_headers': {
this._configuration.extra_headers = value;
break;
}
default:
logger.warn('set() | cannot set "%s" parameter in runtime', parameter);
return false;
}
return true;
}
// ==========================
// Event Handlers.
// ==========================
/**
* new Transaction
*/
newTransaction(transaction)
{
this._transactions[transaction.type][transaction.id] = transaction;
this.emit('newTransaction', {
transaction
});
}
/**
* Transaction destroyed.
*/
destroyTransaction(transaction)
{
delete this._transactions[transaction.type][transaction.id];
this.emit('transactionDestroyed', {
transaction
});
}
/**
* new Dialog
*/
newDialog(dialog)
{
this._dialogs[dialog.id] = dialog;
}
/**
* Dialog destroyed.
*/
destroyDialog(dialog)
{
delete this._dialogs[dialog.id];
}
/**
* new Message
*/
newMessage(message, data)
{
this._applicants[message] = message;
this.emit('newMessage', data);
}
/**
* new Options
*/
newOptions(message, data)
{
this._applicants[message] = message;
this.emit('newOptions', data);
}
/**
* Message destroyed.
*/
destroyMessage(message)
{
delete this._applicants[message];
}
/**
* new RTCSession
*/
newRTCSession(session, data)
{
this._sessions[session.id] = session;
this.emit('newRTCSession', data);
}
/**
* RTCSession destroyed.
*/
destroyRTCSession(session)
{
delete this._sessions[session.id];
}
/**
* Registered
*/
registered(data)
{
this.emit('registered', data);
}
/**
* Unregistered
*/
unregistered(data)
{
this.emit('unregistered', data);
}
/**
* Registration Failed
*/
registrationFailed(data)
{
this.emit('registrationFailed', data);
}
// =========================
// ReceiveRequest.
// =========================
/**
* Request reception
*/
receiveRequest(request)
{
const method = request.method;
// Check that request URI points to us.
if (request.ruri.user !== this._configuration.uri.user &&
request.ruri.user !== this._contact.uri.user)
{
logger.debug('Request-URI does not point to us');
if (request.method !== JsSIP_C.ACK)
{
request.reply_sl(404);
}
return;
}
// Check request URI scheme.
if (request.ruri.scheme === JsSIP_C.SIPS)
{
request.reply_sl(416);
return;
}
// Check transaction.
if (Transactions.checkTransaction(this, request))
{
return;
}
// Create the server transaction.
if (method === JsSIP_C.INVITE)
{
/* eslint-disable no-new */
new Transactions.InviteServerTransaction(this, this._transport, request);
/* eslint-enable no-new */
}
else if (method !== JsSIP_C.ACK && method !== JsSIP_C.CANCEL)
{
/* eslint-disable no-new */
new Transactions.NonInviteServerTransaction(this, this._transport, request);
/* eslint-enable no-new */
}
/* RFC3261 12.2.2
* Requests that do not change in any way the state of a dialog may be
* received within a dialog (for example, an OPTIONS request).
* They are processed as if they had been received outside the dialog.
*/
if (method === JsSIP_C.OPTIONS)
{
if (this.listeners('newOptions').length === 0)
{
request.reply(200);
return;
}
const message = new Options(this);
message.init_incoming(request);
}
else if (method === JsSIP_C.MESSAGE)
{
if (this.listeners('newMessage').length === 0)
{
request.reply(405);
return;
}
const message = new Message(this);
message.init_incoming(request);
}
else if (method === JsSIP_C.INVITE)
{
// Initial INVITE.
if (!request.to_tag && this.listeners('newRTCSession').length === 0)
{
request.reply(405);
return;
}
}
let dialog;
let session;
// Initial Request.
if (!request.to_tag)
{
switch (method)
{
case JsSIP_C.INVITE:
if (window.RTCPeerConnection)
{ // TODO
if (request.hasHeader('replaces'))
{
const replaces = request.replaces;
dialog = this._findDialog(
replaces.call_id, replaces.from_tag, replaces.to_tag);
if (dialog)
{
session = dialog.owner;
if (!session.isEnded())
{
session.receiveRequest(request);
}
else
{
request.reply(603);
}
}
else
{
request.reply(481);
}
}
else
{
session = new RTCSession(this);
session.init_incoming(request);
}
}
else
{
logger.warn('INVITE received but WebRTC is not supported');
request.reply(488);
}
break;
case JsSIP_C.BYE:
// Out of dialog BYE received.
request.reply(481);
break;
case JsSIP_C.CANCEL:
session = this._findSession(request);
if (session)
{
session.receiveRequest(request);
}
else
{
logger.debug('received CANCEL request for a non existent session');
}
break;
case JsSIP_C.ACK:
/* Absorb it.
* ACK request without a corresponding Invite Transaction
* and without To tag.
*/
break;
case JsSIP_C.NOTIFY:
// Receive new sip event.
this.emit('sipEvent', {
event : request.event,
request
});
request.reply(200);
break;
default:
request.reply(405);
break;
}
}
// In-dialog request.
else
{
dialog = this._findDialog(request.call_id, request.from_tag, request.to_tag);
if (dialog)
{
dialog.receiveRequest(request);
}
else if (method === JsSIP_C.NOTIFY)
{
session = this._findSession(request);
if (session)
{
session.receiveRequest(request);
}
else
{
logger.debug('received NOTIFY request for a non existent subscription');
request.reply(481, 'Subscription does not exist');
}
}
/* RFC3261 12.2.2
* Request with to tag, but no matching dialog found.
* Exception: ACK for an Invite request for which a dialog has not
* been created.
*/
else
if (method !== JsSIP_C.ACK)
{
request.reply(481);
}
}
}
// =================
// Utils.
// =================
/**
* Get the session to which the request belongs to, if any.
*/
_findSession({ call_id, from_tag, to_tag })
{
const sessionIDa = call_id + from_tag;
const sessionA = this._sessions[sessionIDa];
const sessionIDb = call_id + to_tag;
const sessionB = this._sessions[sessionIDb];
if (sessionA)
{
return sessionA;
}
else if (sessionB)
{
return sessionB;
}
else
{
return null;
}
}
/**
* Get the dialog to which the request belongs to, if any.
*/
_findDialog(call_id, from_tag, to_tag)
{
let id = call_id + from_tag + to_tag;
let dialog = this._dialogs[id];
if (dialog)
{
return dialog;
}
else
{
id = call_id + to_tag + from_tag;
dialog = this._dialogs[id];
if (dialog)
{
return dialog;
}
else
{
return null;
}
}
}
_loadConfig(configuration)
{
// Check and load the given configuration.
try
{
config.load(this._configuration, configuration);
}
catch (e)
{
throw e;
}
// Post Configuration Process.
// Allow passing 0 number as display_name.
if (this._configuration.display_name === 0)
{
this._configuration.display_name = '0';
}
// Instance-id for GRUU.
if (!this._configuration.instance_id)
{
this._configuration.instance_id = Utils.newUUID();
}
// Jssip_id instance parameter. Static random tag of length 5.
this._configuration.jssip_id = Utils.createRandomToken(5);
// String containing this._configuration.uri without scheme and user.
const hostport_params = this._configuration.uri.clone();
hostport_params.user = null;
this._configuration.hostport_params = hostport_params.toString().replace(/^sip:/i, '');
// Transport.
try
{
this._transport = new Transport(this._configuration.sockets, {
// Recovery options.
max_interval : this._configuration.connection_recovery_max_interval,
min_interval : this._configuration.connection_recovery_min_interval
});
// Transport event callbacks.
this._transport.onconnecting = onTransportConnecting.bind(this);
this._transport.onconnect = onTransportConnect.bind(this);
this._transport.ondisconnect = onTransportDisconnect.bind(this);
this._transport.ondata = onTransportData.bind(this);
}
catch (e)
{
logger.warn(e);
throw new Exceptions.ConfigurationError('sockets', this._configuration.sockets);
}
// Remove sockets instance from configuration object.
delete this._configuration.sockets;
// Check whether authorization_user is explicitly defined.
// Take 'this._configuration.uri.user' value if not.
if (!this._configuration.authorization_user)
{
this._configuration.authorization_user = this._configuration.uri.user;
}
// If no 'registrar_server' is set use the 'uri' value without user portion and
// without URI params/headers.
if (!this._configuration.registrar_server)
{
const registrar_server = this._configuration.uri.clone();
registrar_server.user = null;
registrar_server.clearParams();
registrar_server.clearHeaders();
this._configuration.registrar_server = registrar_server;
}
// User no_answer_timeout.
this._configuration.no_answer_timeout *= 1000;
// Via Host.
if (this._configuration.contact_uri)
{
this._configuration.via_host = this._configuration.contact_uri.host;
}
// Contact URI.
else
{
this._configuration.contact_uri = new URI('sip', Utils.createRandomToken(8), this._configuration.via_host, null, { transport: 'ws' });
}
this._contact = {
pub_gruu : null,
temp_gruu : null,
uri : this._configuration.contact_uri,
toString(options = {})
{
const anonymous = options.anonymous || null;
const outbound = options.outbound || null;
let contact = '<';
if (anonymous)
{
contact += this.temp_gruu || 'sip:anonymous@anonymous.invalid;transport=ws';
}
else
{
contact += this.pub_gruu || this.uri.toString();
}
if (outbound && (anonymous ? !this.temp_gruu : !this.pub_gruu))
{
contact += ';ob';
}
contact += '>';
return contact;
}
};
// Seal the configuration.
const writable_parameters = [
'authorization_user', 'password', 'realm', 'ha1', 'authorization_jwt', 'display_name', 'register', 'extra_headers'
];
for (const parameter in this._configuration)
{
if (Object.prototype.hasOwnProperty.call(this._configuration, parameter))
{
if (writable_parameters.indexOf(parameter) !== -1)
{
Object.defineProperty(this._configuration, parameter, {
writable : true,
configurable : false
});
}
else
{
Object.defineProperty(this._configuration, parameter, {
writable : false,
configurable : false
});
}
}
}
logger.debug('configuration parameters after validation:');
for (const parameter in this._configuration)
{
// Only show the user user configurable parameters.
if (Object.prototype.hasOwnProperty.call(config.settings, parameter))
{
switch (parameter)
{
case 'uri':
case 'registrar_server':
logger.debug(`- ${parameter}: ${this._configuration[parameter]}`);
break;
case 'password':
case 'ha1':
case 'authorization_jwt':
logger.debug(`- ${parameter}: NOT SHOWN`);
break;
default:
logger.debug(`- ${parameter}: ${JSON.stringify(this._configuration[parameter])}`);
}
}
}
return;
}
};
/**
* Transport event handlers
*/
// Transport connecting event.
function onTransportConnecting(data)
{
this.emit('connecting', data);
}
// Transport connected event.
function onTransportConnect(data)
{
if (this._status === C.STATUS_USER_CLOSED)
{
return;
}
this._status = C.STATUS_READY;
this._error = null;
this.emit('connected', data);
if (this._dynConfiguration.register)
{
this._registrator.register();
}
}
// Transport disconnected event.
function onTransportDisconnect(data)
{
// Run _onTransportError_ callback on every client transaction using _transport_.
const client_transactions = [ 'nict', 'ict', 'nist', 'ist' ];
for (const type of client_transactions)
{
for (const id in this._transactions[type])
{
if (Object.prototype.hasOwnProperty.call(this._transactions[type], id))
{
this._transactions[type][id].onTransportError();
}
}
}
this.emit('disconnected', data);
// Call registrator _onTransportClosed_.
this._registrator.onTransportClosed();
if (this._status !== C.STATUS_USER_CLOSED)
{
this._status = C.STATUS_NOT_READY;
this._error = C.NETWORK_ERROR;
}
}
// Transport data event.
function onTransportData(data)
{
const transport = data.transport;
let message = data.message;
message = Parser.parseMessage(message, this);
if (!message)
{
return;
}
if (this._status === C.STATUS_USER_CLOSED &&
message instanceof SIPMessage.IncomingRequest)
{
return;
}
// Do some sanity check.
if (!sanityCheck(message, this, transport))
{
return;
}
if (message instanceof SIPMessage.IncomingRequest)
{
message.transport = transport;
this.receiveRequest(message);
}
else if (message instanceof SIPMessage.IncomingResponse)
{
/* Unike stated in 18.1.2, if a response does not match
* any transaction, it is discarded here and no passed to the core
* in order to be discarded there.
*/
let transaction;
switch (message.method)
{
case JsSIP_C.INVITE:
transaction = this._transactions.ict[message.via_branch];
if (transaction)
{
transaction.receiveResponse(message);
}
break;
case JsSIP_C.ACK:
// Just in case ;-).
break;
default:
transaction = this._transactions.nict[message.via_branch];
if (transaction)
{
transaction.receiveResponse(message);
}
break;
}
}
}