sip.js
Version:
A SIP library for JavaScript
906 lines (905 loc) • 42.1 kB
JavaScript
import { Grammar } from "../grammar/grammar.js";
import { URI } from "../grammar/uri.js";
import { DigestAuthentication } from "../core/messages/digest-authentication.js";
import { IncomingRequestMessage } from "../core/messages/incoming-request-message.js";
import { IncomingResponseMessage } from "../core/messages/incoming-response-message.js";
import { Levels } from "../core/log/levels.js";
import { LoggerFactory } from "../core/log/logger-factory.js";
import { Parser } from "../core/messages/parser.js";
import { UserAgentCore } from "../core/user-agent-core/user-agent-core.js";
import { createRandomToken, utf8Length } from "../core/messages/utils.js";
import { defaultSessionDescriptionHandlerFactory } from "../platform/web/session-description-handler/session-description-handler-factory-default.js";
import { Transport as WebTransport } from "../platform/web/transport/transport.js";
import { LIBRARY_VERSION } from "../version.js";
import { EmitterImpl } from "./emitter.js";
import { Invitation } from "./invitation.js";
import { Inviter } from "./inviter.js";
import { Message } from "./message.js";
import { Notification } from "./notification.js";
import { SIPExtension, UserAgentRegisteredOptionTags } from "./user-agent-options.js";
import { UserAgentState } from "./user-agent-state.js";
/**
* A user agent sends and receives requests using a `Transport`.
*
* @remarks
* A user agent (UA) is associated with a user via the user's SIP address of record (AOR)
* and acts on behalf of that user to send and receive SIP requests. The user agent can
* register to receive incoming requests, as well as create and send outbound messages.
* The user agent also maintains the Transport over which its signaling travels.
*
* @public
*/
export class UserAgent {
/**
* Constructs a new instance of the `UserAgent` class.
* @param options - Options bucket. See {@link UserAgentOptions} for details.
*/
constructor(options = {}) {
/** @internal */
this._publishers = {};
/** @internal */
this._registerers = {};
/** @internal */
this._sessions = {};
/** @internal */
this._subscriptions = {};
this._state = UserAgentState.Stopped;
// state emitter
this._stateEventEmitter = new EmitterImpl();
// initialize delegate
this.delegate = options.delegate;
// initialize configuration
this.options = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, UserAgent.defaultOptions()), { sipjsId: createRandomToken(5) }), { uri: new URI("sip", "anonymous." + createRandomToken(6), "anonymous.invalid") }), { viaHost: createRandomToken(12) + ".invalid" }), UserAgent.stripUndefinedProperties(options));
// viaHost is hack
if (this.options.hackIpInContact) {
if (typeof this.options.hackIpInContact === "boolean" && this.options.hackIpInContact) {
const from = 1;
const to = 254;
const octet = Math.floor(Math.random() * (to - from + 1) + from);
// random Test-Net IP (http://tools.ietf.org/html/rfc5735)
this.options.viaHost = "192.0.2." + octet;
}
else if (this.options.hackIpInContact) {
this.options.viaHost = this.options.hackIpInContact;
}
}
// initialize logger & logger factory
this.loggerFactory = new LoggerFactory();
this.logger = this.loggerFactory.getLogger("sip.UserAgent");
this.loggerFactory.builtinEnabled = this.options.logBuiltinEnabled;
this.loggerFactory.connector = this.options.logConnector;
switch (this.options.logLevel) {
case "error":
this.loggerFactory.level = Levels.error;
break;
case "warn":
this.loggerFactory.level = Levels.warn;
break;
case "log":
this.loggerFactory.level = Levels.log;
break;
case "debug":
this.loggerFactory.level = Levels.debug;
break;
default:
break;
}
if (this.options.logConfiguration) {
this.logger.log("Configuration:");
Object.keys(this.options).forEach((key) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const value = this.options[key];
switch (key) {
case "uri":
case "sessionDescriptionHandlerFactory":
this.logger.log("· " + key + ": " + value);
break;
case "authorizationPassword":
this.logger.log("· " + key + ": " + "NOT SHOWN");
break;
case "transportConstructor":
this.logger.log("· " + key + ": " + value.name);
break;
default:
this.logger.log("· " + key + ": " + JSON.stringify(value));
}
});
}
// guard deprecated transport options (remove this in version 16.x)
if (this.options.transportOptions) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const optionsDeprecated = this.options.transportOptions;
const maxReconnectionAttemptsDeprecated = optionsDeprecated.maxReconnectionAttempts;
const reconnectionTimeoutDeprecated = optionsDeprecated.reconnectionTimeout;
if (maxReconnectionAttemptsDeprecated !== undefined) {
const deprecatedMessage = `The transport option "maxReconnectionAttempts" as has apparently been specified and has been deprecated. ` +
"It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.";
this.logger.warn(deprecatedMessage);
}
if (reconnectionTimeoutDeprecated !== undefined) {
const deprecatedMessage = `The transport option "reconnectionTimeout" as has apparently been specified and has been deprecated. ` +
"It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.";
this.logger.warn(deprecatedMessage);
}
// hack
if (options.reconnectionDelay === undefined && reconnectionTimeoutDeprecated !== undefined) {
this.options.reconnectionDelay = reconnectionTimeoutDeprecated;
}
if (options.reconnectionAttempts === undefined && maxReconnectionAttemptsDeprecated !== undefined) {
this.options.reconnectionAttempts = maxReconnectionAttemptsDeprecated;
}
}
// guard deprecated user agent options (remove this in version 16.x)
if (options.reconnectionDelay !== undefined) {
const deprecatedMessage = `The user agent option "reconnectionDelay" as has apparently been specified and has been deprecated. ` +
"It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.";
this.logger.warn(deprecatedMessage);
}
if (options.reconnectionAttempts !== undefined) {
const deprecatedMessage = `The user agent option "reconnectionAttempts" as has apparently been specified and has been deprecated. ` +
"It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.";
this.logger.warn(deprecatedMessage);
}
// Initialize Transport
this._transport = new this.options.transportConstructor(this.getLogger("sip.Transport"), this.options.transportOptions);
this.initTransportCallbacks();
// Initialize Contact
this._contact = this.initContact();
// Set instance id
this._instanceId = this.options.instanceId ? this.options.instanceId : UserAgent.newUUID();
if (Grammar.parse(this._instanceId, "uuid") === -1) {
throw new Error("Invalid instanceId.");
}
// Initialize UserAgentCore
this._userAgentCore = this.initCore();
}
/**
* Create a URI instance from a string.
* @param uri - The string to parse.
*
* @remarks
* Returns undefined if the syntax of the URI is invalid.
* The syntax must conform to a SIP URI as defined in the RFC.
* 25 Augmented BNF for the SIP Protocol
* https://tools.ietf.org/html/rfc3261#section-25
*
* @example
* ```ts
* const uri = UserAgent.makeURI("sip:edgar@example.com");
* ```
*/
static makeURI(uri) {
return Grammar.URIParse(uri);
}
/** Default user agent options. */
static defaultOptions() {
return {
allowLegacyNotifications: false,
authorizationHa1: "",
authorizationPassword: "",
authorizationUsername: "",
delegate: {},
contactName: "",
contactParams: { transport: "ws" },
displayName: "",
forceRport: false,
gracefulShutdown: true,
hackAllowUnregisteredOptionTags: false,
hackIpInContact: false,
hackViaTcp: false,
instanceId: "",
instanceIdAlwaysAdded: false,
logBuiltinEnabled: true,
logConfiguration: true,
logConnector: () => {
/* noop */
},
logLevel: "log",
noAnswerTimeout: 60,
preloadedRouteSet: [],
reconnectionAttempts: 0,
reconnectionDelay: 4,
sendInitialProvisionalResponse: true,
sessionDescriptionHandlerFactory: defaultSessionDescriptionHandlerFactory(),
sessionDescriptionHandlerFactoryOptions: {},
sipExtension100rel: SIPExtension.Unsupported,
sipExtensionReplaces: SIPExtension.Unsupported,
sipExtensionExtraSupported: [],
sipjsId: "",
transportConstructor: WebTransport,
transportOptions: {},
uri: new URI("sip", "anonymous", "anonymous.invalid"),
userAgentString: "SIP.js/" + LIBRARY_VERSION,
viaHost: ""
};
}
// http://stackoverflow.com/users/109538/broofa
static newUUID() {
const UUID = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = Math.floor(Math.random() * 16);
const v = c === "x" ? r : (r % 4) + 8;
return v.toString(16);
});
return UUID;
}
/**
* Strip properties with undefined values from options.
* This is a work around while waiting for missing vs undefined to be addressed (or not)...
* https://github.com/Microsoft/TypeScript/issues/13195
* @param options - Options to reduce
*/
static stripUndefinedProperties(options) {
return Object.keys(options).reduce((object, key) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (options[key] !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
object[key] = options[key];
}
return object;
}, {});
}
/**
* User agent configuration.
*/
get configuration() {
return this.options;
}
/**
* User agent contact.
*/
get contact() {
return this._contact;
}
/**
* User agent instance id.
*/
get instanceId() {
return this._instanceId;
}
/**
* User agent state.
*/
get state() {
return this._state;
}
/**
* User agent state change emitter.
*/
get stateChange() {
return this._stateEventEmitter;
}
/**
* User agent transport.
*/
get transport() {
return this._transport;
}
/**
* User agent core.
*/
get userAgentCore() {
return this._userAgentCore;
}
/**
* The logger.
*/
getLogger(category, label) {
return this.loggerFactory.getLogger(category, label);
}
/**
* The logger factory.
*/
getLoggerFactory() {
return this.loggerFactory;
}
/**
* True if transport is connected.
*/
isConnected() {
return this.transport.isConnected();
}
/**
* Reconnect the transport.
*/
reconnect() {
if (this.state === UserAgentState.Stopped) {
return Promise.reject(new Error("User agent stopped."));
}
// Make sure we don't call synchronously
return Promise.resolve().then(() => this.transport.connect());
}
/**
* Start the user agent.
*
* @remarks
* Resolves if transport connects, otherwise rejects.
* Calling `start()` after calling `stop()` will fail if `stop()` has yet to resolve.
*
* @example
* ```ts
* userAgent.start()
* .then(() => {
* // userAgent.isConnected() === true
* })
* .catch((error: Error) => {
* // userAgent.isConnected() === false
* });
* ```
*/
start() {
if (this.state === UserAgentState.Started) {
this.logger.warn(`User agent already started`);
return Promise.resolve();
}
this.logger.log(`Starting ${this.configuration.uri}`);
// Transition state
this.transitionState(UserAgentState.Started);
return this.transport.connect();
}
/**
* Stop the user agent.
*
* @remarks
* Resolves when the user agent has completed a graceful shutdown.
* ```txt
* 1) Sessions terminate.
* 2) Registerers unregister.
* 3) Subscribers unsubscribe.
* 4) Publishers unpublish.
* 5) Transport disconnects.
* 6) User Agent Core resets.
* ```
* The user agent state transistions to stopped once these steps have been completed.
* Calling `start()` after calling `stop()` will fail if `stop()` has yet to resolve.
*
* NOTE: While this is a "graceful shutdown", it can also be very slow one if you
* are waiting for the returned Promise to resolve. The disposal of the clients and
* dialogs is done serially - waiting on one to finish before moving on to the next.
* This can be slow if there are lot of subscriptions to unsubscribe for example.
*
* THE SLOW PACE IS INTENTIONAL!
* While one could spin them all down in parallel, this could slam the remote server.
* It is bad practice to denial of service attack (DoS attack) servers!!!
* Moreover, production servers will automatically blacklist clients which send too
* many requests in too short a period of time - dropping any additional requests.
*
* If a different approach to disposing is needed, one can implement whatever is
* needed and execute that prior to calling `stop()`. Alternatively one may simply
* not wait for the Promise returned by `stop()` to complete.
*/
async stop() {
if (this.state === UserAgentState.Stopped) {
this.logger.warn(`User agent already stopped`);
return Promise.resolve();
}
this.logger.log(`Stopping ${this.configuration.uri}`);
// The default behavior is to cleanup dialogs and registrations. This is not that...
if (!this.options.gracefulShutdown) {
// Dispose of the transport (disconnecting)
this.logger.log(`Dispose of transport`);
this.transport.dispose().catch((error) => {
this.logger.error(error.message);
throw error;
});
// Dispose of the user agent core (resetting)
this.logger.log(`Dispose of core`);
this.userAgentCore.dispose();
// Reset dialogs and registrations
this._publishers = {};
this._registerers = {};
this._sessions = {};
this._subscriptions = {};
this.transitionState(UserAgentState.Stopped);
return Promise.resolve();
}
// Be careful here to use a local references as start() can be called
// again before we complete and we don't want to touch new clients
// and we don't want to step on the new instances (or vice versa).
const publishers = Object.assign({}, this._publishers);
const registerers = Object.assign({}, this._registerers);
const sessions = Object.assign({}, this._sessions);
const subscriptions = Object.assign({}, this._subscriptions);
const transport = this.transport;
const userAgentCore = this.userAgentCore;
//
// At this point we have completed the state transition and everything
// following will effectively run async and MUST NOT cause any issues
// if UserAgent.start() is called while the following code continues.
//
// TODO: Minor optimization.
// The disposal in all cases involves, in part, sending messages which
// is not worth doing if the transport is not connected as we know attempting
// to send messages will be futile. But none of these disposal methods check
// if that's is the case and it would be easy for them to do so at this point.
// Dispose of Registerers
this.logger.log(`Dispose of registerers`);
for (const id in registerers) {
if (registerers[id]) {
await registerers[id].dispose().catch((error) => {
this.logger.error(error.message);
delete this._registerers[id];
throw error;
});
}
}
// Dispose of Sessions
this.logger.log(`Dispose of sessions`);
for (const id in sessions) {
if (sessions[id]) {
await sessions[id].dispose().catch((error) => {
this.logger.error(error.message);
delete this._sessions[id];
throw error;
});
}
}
// Dispose of Subscriptions
this.logger.log(`Dispose of subscriptions`);
for (const id in subscriptions) {
if (subscriptions[id]) {
await subscriptions[id].dispose().catch((error) => {
this.logger.error(error.message);
delete this._subscriptions[id];
throw error;
});
}
}
// Dispose of Publishers
this.logger.log(`Dispose of publishers`);
for (const id in publishers) {
if (publishers[id]) {
await publishers[id].dispose().catch((error) => {
this.logger.error(error.message);
delete this._publishers[id];
throw error;
});
}
}
// Dispose of the transport (disconnecting)
this.logger.log(`Dispose of transport`);
await transport.dispose().catch((error) => {
this.logger.error(error.message);
throw error;
});
// Dispose of the user agent core (resetting)
this.logger.log(`Dispose of core`);
userAgentCore.dispose();
// Transition state
this.transitionState(UserAgentState.Stopped);
}
/**
* Used to avoid circular references.
* @internal
*/
_makeInviter(targetURI, options) {
return new Inviter(this, targetURI, options);
}
/**
* Attempt reconnection up to `maxReconnectionAttempts` times.
* @param reconnectionAttempt - Current attempt number.
*/
attemptReconnection(reconnectionAttempt = 1) {
const reconnectionAttempts = this.options.reconnectionAttempts;
const reconnectionDelay = this.options.reconnectionDelay;
if (reconnectionAttempt > reconnectionAttempts) {
this.logger.log(`Maximum reconnection attempts reached`);
return;
}
this.logger.log(`Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - trying`);
setTimeout(() => {
this.reconnect()
.then(() => {
this.logger.log(`Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - succeeded`);
})
.catch((error) => {
this.logger.error(error.message);
this.logger.log(`Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - failed`);
this.attemptReconnection(++reconnectionAttempt);
});
}, reconnectionAttempt === 1 ? 0 : reconnectionDelay * 1000);
}
/**
* Initialize contact.
*/
initContact() {
const contactName = this.options.contactName !== "" ? this.options.contactName : createRandomToken(8);
const contactParams = this.options.contactParams;
const contact = {
pubGruu: undefined,
tempGruu: undefined,
uri: new URI("sip", contactName, this.options.viaHost, undefined, contactParams),
toString: (contactToStringOptions = {}) => {
const anonymous = contactToStringOptions.anonymous || false;
const outbound = contactToStringOptions.outbound || false;
const register = contactToStringOptions.register || false;
let contactString = "<";
// 3.3. Using a GRUU
// Once a user agent obtains GRUUs from the registrar, it uses them in
// several ways. First, it uses them as the contents of the Contact
// header field in non-REGISTER requests and responses that it emits
// (for example, an INVITE request and 200 OK response).
// https://datatracker.ietf.org/doc/html/rfc5627#section-3.3
if (anonymous) {
contactString +=
this.contact.tempGruu ||
`sip:anonymous@anonymous.invalid;transport=${contactParams.transport ? contactParams.transport : "ws"}`;
}
else if (register) {
contactString += this.contact.uri;
}
else {
contactString += this.contact.pubGruu || this.contact.uri;
}
if (outbound) {
contactString += ";ob";
}
contactString += ">";
if (this.options.instanceIdAlwaysAdded) {
contactString += ';+sip.instance="<urn:uuid:' + this._instanceId + '>"';
}
return contactString;
}
};
return contact;
}
/**
* Initialize user agent core.
*/
initCore() {
// supported options
let supportedOptionTags = [];
supportedOptionTags.push("outbound"); // TODO: is this really supported?
if (this.options.sipExtension100rel === SIPExtension.Supported) {
supportedOptionTags.push("100rel");
}
if (this.options.sipExtensionReplaces === SIPExtension.Supported) {
supportedOptionTags.push("replaces");
}
if (this.options.sipExtensionExtraSupported) {
supportedOptionTags.push(...this.options.sipExtensionExtraSupported);
}
if (!this.options.hackAllowUnregisteredOptionTags) {
supportedOptionTags = supportedOptionTags.filter((optionTag) => UserAgentRegisteredOptionTags[optionTag]);
}
supportedOptionTags = Array.from(new Set(supportedOptionTags)); // array of unique values
// FIXME: TODO: This was ported, but this is and was just plain broken.
const supportedOptionTagsResponse = supportedOptionTags.slice();
if (this.contact.pubGruu || this.contact.tempGruu) {
supportedOptionTagsResponse.push("gruu");
}
// core configuration
const userAgentCoreConfiguration = {
aor: this.options.uri,
contact: this.contact,
displayName: this.options.displayName,
loggerFactory: this.loggerFactory,
hackViaTcp: this.options.hackViaTcp,
routeSet: this.options.preloadedRouteSet,
supportedOptionTags,
supportedOptionTagsResponse,
sipjsId: this.options.sipjsId,
userAgentHeaderFieldValue: this.options.userAgentString,
viaForceRport: this.options.forceRport,
viaHost: this.options.viaHost,
authenticationFactory: () => {
const username = this.options.authorizationUsername
? this.options.authorizationUsername
: this.options.uri.user; // if authorization username not provided, use uri user as username
const password = this.options.authorizationPassword ? this.options.authorizationPassword : undefined;
const ha1 = this.options.authorizationHa1 ? this.options.authorizationHa1 : undefined;
return new DigestAuthentication(this.getLoggerFactory(), ha1, username, password);
},
transportAccessor: () => this.transport
};
const userAgentCoreDelegate = {
onInvite: (incomingInviteRequest) => {
var _a;
const invitation = new Invitation(this, incomingInviteRequest);
incomingInviteRequest.delegate = {
onCancel: (cancel) => {
invitation._onCancel(cancel);
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onTransportError: (error) => {
// A server transaction MUST NOT discard transaction state based only on
// encountering a non-recoverable transport error when sending a
// response. Instead, the associated INVITE server transaction state
// machine MUST remain in its current state. (Timers will eventually
// cause it to transition to the "Terminated" state).
// https://tools.ietf.org/html/rfc6026#section-7.1
// As noted in the comment above, we are to leaving it to the transaction
// timers to eventually cause the transaction to sort itself out in the case
// of a transport failure in an invite server transaction. This delegate method
// is here simply here for completeness and to make it clear that it provides
// nothing more than informational hook into the core. That is, if you think
// you should be trying to deal with a transport error here, you are likely wrong.
this.logger.error("A transport error has occurred while handling an incoming INVITE request.");
}
};
// FIXME: Ported - 100 Trying send should be configurable.
// Only required if TU will not respond in 200ms.
// https://tools.ietf.org/html/rfc3261#section-17.2.1
incomingInviteRequest.trying();
// The Replaces header contains information used to match an existing
// SIP dialog (call-id, to-tag, and from-tag). Upon receiving an INVITE
// with a Replaces header, the User Agent (UA) attempts to match this
// information with a confirmed or early dialog.
// https://tools.ietf.org/html/rfc3891#section-3
if (this.options.sipExtensionReplaces !== SIPExtension.Unsupported) {
const message = incomingInviteRequest.message;
const replaces = message.parseHeader("replaces");
if (replaces) {
const callId = replaces.call_id;
if (typeof callId !== "string") {
throw new Error("Type of call id is not string");
}
const toTag = replaces.replaces_to_tag;
if (typeof toTag !== "string") {
throw new Error("Type of to tag is not string");
}
const fromTag = replaces.replaces_from_tag;
if (typeof fromTag !== "string") {
throw new Error("type of from tag is not string");
}
const targetDialogId = callId + toTag + fromTag;
const targetDialog = this.userAgentCore.dialogs.get(targetDialogId);
// If no match is found, the UAS rejects the INVITE and returns a 481
// Call/Transaction Does Not Exist response. Likewise, if the Replaces
// header field matches a dialog which was not created with an INVITE,
// the UAS MUST reject the request with a 481 response.
// https://tools.ietf.org/html/rfc3891#section-3
if (!targetDialog) {
invitation.reject({ statusCode: 481 });
return;
}
// If the Replaces header field matches a confirmed dialog, it checks
// for the presence of the "early-only" flag in the Replaces header
// field. (This flag allows the UAC to prevent a potentially
// undesirable race condition described in Section 7.1.) If the flag is
// present, the UA rejects the request with a 486 Busy response.
// https://tools.ietf.org/html/rfc3891#section-3
if (!targetDialog.early && replaces.early_only === true) {
invitation.reject({ statusCode: 486 });
return;
}
// Provide a handle on the session being replaced.
const targetSession = this._sessions[callId + fromTag] || this._sessions[callId + toTag] || undefined;
if (!targetSession) {
throw new Error("Session does not exist.");
}
invitation._replacee = targetSession;
}
}
// Delegate invitation handling.
if ((_a = this.delegate) === null || _a === void 0 ? void 0 : _a.onInvite) {
if (invitation.autoSendAnInitialProvisionalResponse) {
invitation.progress().then(() => {
var _a;
if (((_a = this.delegate) === null || _a === void 0 ? void 0 : _a.onInvite) === undefined) {
throw new Error("onInvite undefined.");
}
this.delegate.onInvite(invitation);
});
return;
}
this.delegate.onInvite(invitation);
return;
}
// A common scenario occurs when the callee is currently not willing or
// able to take additional calls at this end system. A 486 (Busy Here)
// SHOULD be returned in such a scenario.
// https://tools.ietf.org/html/rfc3261#section-13.3.1.3
invitation.reject({ statusCode: 486 });
},
onMessage: (incomingMessageRequest) => {
if (this.delegate && this.delegate.onMessage) {
const message = new Message(incomingMessageRequest);
this.delegate.onMessage(message);
}
else {
// Accept the MESSAGE request, but do nothing with it.
incomingMessageRequest.accept();
}
},
onNotify: (incomingNotifyRequest) => {
// NOTIFY requests are sent to inform subscribers of changes in state to
// which the subscriber has a subscription. Subscriptions are created
// using the SUBSCRIBE method. In legacy implementations, it is
// possible that other means of subscription creation have been used.
// However, this specification does not allow the creation of
// subscriptions except through SUBSCRIBE requests and (for backwards-
// compatibility) REFER requests [RFC3515].
// https://tools.ietf.org/html/rfc6665#section-3.2
if (this.delegate && this.delegate.onNotify) {
const notification = new Notification(incomingNotifyRequest);
this.delegate.onNotify(notification);
}
else {
// Per the above which obsoletes https://tools.ietf.org/html/rfc3265,
// the use of out of dialog NOTIFY is obsolete, but...
if (this.options.allowLegacyNotifications) {
incomingNotifyRequest.accept(); // Accept the NOTIFY request, but do nothing with it.
}
else {
incomingNotifyRequest.reject({ statusCode: 481 });
}
}
},
onRefer: (incomingReferRequest) => {
this.logger.warn("Received an out of dialog REFER request");
// TOOD: this.delegate.onRefer(...)
if (this.delegate && this.delegate.onReferRequest) {
this.delegate.onReferRequest(incomingReferRequest);
}
else {
incomingReferRequest.reject({ statusCode: 405 });
}
},
onRegister: (incomingRegisterRequest) => {
this.logger.warn("Received an out of dialog REGISTER request");
// TOOD: this.delegate.onRegister(...)
if (this.delegate && this.delegate.onRegisterRequest) {
this.delegate.onRegisterRequest(incomingRegisterRequest);
}
else {
incomingRegisterRequest.reject({ statusCode: 405 });
}
},
onSubscribe: (incomingSubscribeRequest) => {
this.logger.warn("Received an out of dialog SUBSCRIBE request");
// TOOD: this.delegate.onSubscribe(...)
if (this.delegate && this.delegate.onSubscribeRequest) {
this.delegate.onSubscribeRequest(incomingSubscribeRequest);
}
else {
incomingSubscribeRequest.reject({ statusCode: 405 });
}
}
};
return new UserAgentCore(userAgentCoreConfiguration, userAgentCoreDelegate);
}
initTransportCallbacks() {
this.transport.onConnect = () => this.onTransportConnect();
this.transport.onDisconnect = (error) => this.onTransportDisconnect(error);
this.transport.onMessage = (message) => this.onTransportMessage(message);
}
onTransportConnect() {
if (this.state === UserAgentState.Stopped) {
return;
}
if (this.delegate && this.delegate.onConnect) {
this.delegate.onConnect();
}
}
onTransportDisconnect(error) {
if (this.state === UserAgentState.Stopped) {
return;
}
if (this.delegate && this.delegate.onDisconnect) {
this.delegate.onDisconnect(error);
}
// Only attempt to reconnect if network/server dropped the connection.
if (error && this.options.reconnectionAttempts > 0) {
this.attemptReconnection();
}
}
onTransportMessage(messageString) {
const message = Parser.parseMessage(messageString, this.getLogger("sip.Parser"));
if (!message) {
this.logger.warn("Failed to parse incoming message. Dropping.");
return;
}
if (this.state === UserAgentState.Stopped && message instanceof IncomingRequestMessage) {
this.logger.warn(`Received ${message.method} request while stopped. Dropping.`);
return;
}
// A valid SIP request formulated by a UAC MUST, at a minimum, contain
// the following header fields: To, From, CSeq, Call-ID, Max-Forwards,
// and Via; all of these header fields are mandatory in all SIP
// requests.
// https://tools.ietf.org/html/rfc3261#section-8.1.1
const hasMinimumHeaders = () => {
const mandatoryHeaders = ["from", "to", "call_id", "cseq", "via"];
for (const header of mandatoryHeaders) {
if (!message.hasHeader(header)) {
this.logger.warn(`Missing mandatory header field : ${header}.`);
return false;
}
}
return true;
};
// Request Checks
if (message instanceof IncomingRequestMessage) {
// This is port of SanityCheck.minimumHeaders().
if (!hasMinimumHeaders()) {
this.logger.warn(`Request missing mandatory header field. Dropping.`);
return;
}
// FIXME: This is non-standard and should be a configurable behavior (desirable regardless).
// Custom SIP.js check to reject request from ourself (this instance of SIP.js).
// This is port of SanityCheck.rfc3261_16_3_4().
if (!message.toTag && message.callId.substr(0, 5) === this.options.sipjsId) {
this.userAgentCore.replyStateless(message, { statusCode: 482 });
return;
}
// FIXME: This should be Transport check before we get here (Section 18).
// Custom SIP.js check to reject requests if body length wrong.
// This is port of SanityCheck.rfc3261_18_3_request().
const len = utf8Length(message.body);
const contentLength = message.getHeader("content-length");
if (contentLength && len < Number(contentLength)) {
this.userAgentCore.replyStateless(message, { statusCode: 400 });
return;
}
}
// Response Checks
if (message instanceof IncomingResponseMessage) {
// This is port of SanityCheck.minimumHeaders().
if (!hasMinimumHeaders()) {
this.logger.warn(`Response missing mandatory header field. Dropping.`);
return;
}
// Custom SIP.js check to drop responses if multiple Via headers.
// This is port of SanityCheck.rfc3261_8_1_3_3().
if (message.getHeaders("via").length > 1) {
this.logger.warn("More than one Via header field present in the response. Dropping.");
return;
}
// FIXME: This should be Transport check before we get here (Section 18).
// Custom SIP.js check to drop responses if bad Via header.
// This is port of SanityCheck.rfc3261_18_1_2().
if (message.via.host !== this.options.viaHost || message.via.port !== undefined) {
this.logger.warn("Via sent-by in the response does not match UA Via host value. Dropping.");
return;
}
// FIXME: This should be Transport check before we get here (Section 18).
// Custom SIP.js check to reject requests if body length wrong.
// This is port of SanityCheck.rfc3261_18_3_response().
const len = utf8Length(message.body);
const contentLength = message.getHeader("content-length");
if (contentLength && len < Number(contentLength)) {
this.logger.warn("Message body length is lower than the value in Content-Length header field. Dropping.");
return;
}
}
// Handle Request
if (message instanceof IncomingRequestMessage) {
this.userAgentCore.receiveIncomingRequestFromTransport(message);
return;
}
// Handle Response
if (message instanceof IncomingResponseMessage) {
this.userAgentCore.receiveIncomingResponseFromTransport(message);
return;
}
throw new Error("Invalid message type.");
}
/**
* Transition state.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
transitionState(newState, error) {
const invalidTransition = () => {
throw new Error(`Invalid state transition from ${this._state} to ${newState}`);
};
// Validate state transition
switch (this._state) {
case UserAgentState.Started:
if (newState !== UserAgentState.Stopped) {
invalidTransition();
}
break;
case UserAgentState.Stopped:
if (newState !== UserAgentState.Started) {
invalidTransition();
}
break;
default:
throw new Error("Unknown state.");
}
// Update state
this.logger.log(`Transitioned from ${this._state} to ${newState}`);
this._state = newState;
this._stateEventEmitter.emit(this._state);
}
}