sip.js
Version:
A SIP library for JavaScript
1,085 lines (1,084 loc) • 56.8 kB
JavaScript
import { Grammar } from "../grammar/grammar.js";
import { URI } from "../grammar/uri.js";
import { fromBodyLegacy, getBody } from "../core/messages/body.js";
import { SessionState as SessionDialogState, SignalingState } from "../core/session/session.js";
import { getReasonPhrase } from "../core/messages/utils.js";
import { AllowedMethods } from "../core/user-agent-core/allowed-methods.js";
import { Ack } from "./ack.js";
import { Bye } from "./bye.js";
import { EmitterImpl } from "./emitter.js";
import { ContentTypeUnsupportedError } from "./exceptions/content-type-unsupported.js";
import { RequestPendingError } from "./exceptions/request-pending.js";
import { Info } from "./info.js";
import { Message } from "./message.js";
import { Notification } from "./notification.js";
import { Referral } from "./referral.js";
import { SessionState } from "./session-state.js";
/**
* A session provides real time communication between one or more participants.
*
* @remarks
* The transport behaves in a deterministic manner according to the
* the state defined in {@link SessionState}.
* @public
*/
export class Session {
/**
* Constructor.
* @param userAgent - User agent. See {@link UserAgent} for details.
* @internal
*/
constructor(userAgent, options = {}) {
/** True if there is an outgoing re-INVITE request outstanding. */
this.pendingReinvite = false;
/** True if there is an incoming re-INVITE ACK request outstanding. */
this.pendingReinviteAck = false;
/** Session state. */
this._state = SessionState.Initial;
this.delegate = options.delegate;
this._stateEventEmitter = new EmitterImpl();
this._userAgent = userAgent;
}
/**
* Destructor.
*/
dispose() {
this.logger.log(`Session ${this.id} in state ${this._state} is being disposed`);
// Remove from the user agent's session collection
delete this.userAgent._sessions[this.id];
// Dispose of dialog media
if (this._sessionDescriptionHandler) {
this._sessionDescriptionHandler.close();
// TODO: The SDH needs to remain defined as it will be called after it is closed in cases
// where an answer/offer arrives while the session is being torn down. There are a variety
// of circumstances where this can happen - sending a BYE during a re-INVITE for example.
// The code is currently written such that it lazily makes a new SDH when it needs one
// and one is not yet defined. Thus if we undefined it here, it will currently make a
// new one which is out of sync and then never gets cleaned up.
//
// The downside of leaving it defined are that calls this closed SDH will continue to be
// made (think setDescription) and those should/will fail. These failures are handled, but
// it would be nice to have it all coded up in a way where having an undefined SDH where
// one is expected throws an error.
//
// this._sessionDescriptionHandler = undefined;
}
switch (this.state) {
case SessionState.Initial:
break; // the Inviter/Invitation sub class dispose method handles this case
case SessionState.Establishing:
break; // the Inviter/Invitation sub class dispose method handles this case
case SessionState.Established:
return new Promise((resolve) => {
this._bye({
// wait for the response to the BYE before resolving
onAccept: () => resolve(),
onRedirect: () => resolve(),
onReject: () => resolve()
});
});
case SessionState.Terminating:
break; // nothing to be done
case SessionState.Terminated:
break; // nothing to be done
default:
throw new Error("Unknown state.");
}
return Promise.resolve();
}
/**
* The asserted identity of the remote user.
*/
get assertedIdentity() {
return this._assertedIdentity;
}
/**
* The confirmed session dialog.
*/
get dialog() {
return this._dialog;
}
/**
* A unique identifier for this session.
*/
get id() {
return this._id;
}
/**
* The session being replace by this one.
*/
get replacee() {
return this._replacee;
}
/**
* Session description handler.
* @remarks
* If `this` is an instance of `Invitation`,
* `sessionDescriptionHandler` will be defined when the session state changes to "established".
* If `this` is an instance of `Inviter` and an offer was sent in the INVITE,
* `sessionDescriptionHandler` will be defined when the session state changes to "establishing".
* If `this` is an instance of `Inviter` and an offer was not sent in the INVITE,
* `sessionDescriptionHandler` will be defined when the session state changes to "established".
* Otherwise `undefined`.
*/
get sessionDescriptionHandler() {
return this._sessionDescriptionHandler;
}
/**
* Session description handler factory.
*/
get sessionDescriptionHandlerFactory() {
return this.userAgent.configuration.sessionDescriptionHandlerFactory;
}
/**
* SDH modifiers for the initial INVITE transaction.
* @remarks
* Used in all cases when handling the initial INVITE transaction as either UAC or UAS.
* May be set directly at anytime.
* May optionally be set via constructor option.
* May optionally be set via options passed to Inviter.invite() or Invitation.accept().
*/
get sessionDescriptionHandlerModifiers() {
return this._sessionDescriptionHandlerModifiers || [];
}
set sessionDescriptionHandlerModifiers(modifiers) {
this._sessionDescriptionHandlerModifiers = modifiers.slice();
}
/**
* SDH options for the initial INVITE transaction.
* @remarks
* Used in all cases when handling the initial INVITE transaction as either UAC or UAS.
* May be set directly at anytime.
* May optionally be set via constructor option.
* May optionally be set via options passed to Inviter.invite() or Invitation.accept().
*/
get sessionDescriptionHandlerOptions() {
return this._sessionDescriptionHandlerOptions || {};
}
set sessionDescriptionHandlerOptions(options) {
this._sessionDescriptionHandlerOptions = Object.assign({}, options);
}
/**
* SDH modifiers for re-INVITE transactions.
* @remarks
* Used in all cases when handling a re-INVITE transaction as either UAC or UAS.
* May be set directly at anytime.
* May optionally be set via constructor option.
* May optionally be set via options passed to Session.invite().
*/
get sessionDescriptionHandlerModifiersReInvite() {
return this._sessionDescriptionHandlerModifiersReInvite || [];
}
set sessionDescriptionHandlerModifiersReInvite(modifiers) {
this._sessionDescriptionHandlerModifiersReInvite = modifiers.slice();
}
/**
* SDH options for re-INVITE transactions.
* @remarks
* Used in all cases when handling a re-INVITE transaction as either UAC or UAS.
* May be set directly at anytime.
* May optionally be set via constructor option.
* May optionally be set via options passed to Session.invite().
*/
get sessionDescriptionHandlerOptionsReInvite() {
return this._sessionDescriptionHandlerOptionsReInvite || {};
}
set sessionDescriptionHandlerOptionsReInvite(options) {
this._sessionDescriptionHandlerOptionsReInvite = Object.assign({}, options);
}
/**
* Session state.
*/
get state() {
return this._state;
}
/**
* Session state change emitter.
*/
get stateChange() {
return this._stateEventEmitter;
}
/**
* The user agent.
*/
get userAgent() {
return this._userAgent;
}
/**
* End the {@link Session}. Sends a BYE.
* @param options - Options bucket. See {@link SessionByeOptions} for details.
*/
bye(options = {}) {
let message = "Session.bye() may only be called if established session.";
switch (this.state) {
case SessionState.Initial:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (typeof this.cancel === "function") {
message += " However Inviter.invite() has not yet been called.";
message += " Perhaps you should have called Inviter.cancel()?";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
else if (typeof this.reject === "function") {
message += " However Invitation.accept() has not yet been called.";
message += " Perhaps you should have called Invitation.reject()?";
}
break;
case SessionState.Establishing:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (typeof this.cancel === "function") {
message += " However a dialog does not yet exist.";
message += " Perhaps you should have called Inviter.cancel()?";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
else if (typeof this.reject === "function") {
message += " However Invitation.accept() has not yet been called (or not yet resolved).";
message += " Perhaps you should have called Invitation.reject()?";
}
break;
case SessionState.Established: {
const requestDelegate = options.requestDelegate;
const requestOptions = this.copyRequestOptions(options.requestOptions);
return this._bye(requestDelegate, requestOptions);
}
case SessionState.Terminating:
message += " However this session is already terminating.";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (typeof this.cancel === "function") {
message += " Perhaps you have already called Inviter.cancel()?";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
else if (typeof this.reject === "function") {
message += " Perhaps you have already called Session.bye()?";
}
break;
case SessionState.Terminated:
message += " However this session is already terminated.";
break;
default:
throw new Error("Unknown state");
}
this.logger.error(message);
return Promise.reject(new Error(`Invalid session state ${this.state}`));
}
/**
* Share {@link Info} with peer. Sends an INFO.
* @param options - Options bucket. See {@link SessionInfoOptions} for details.
*/
info(options = {}) {
// guard session state
if (this.state !== SessionState.Established) {
const message = "Session.info() may only be called if established session.";
this.logger.error(message);
return Promise.reject(new Error(`Invalid session state ${this.state}`));
}
const requestDelegate = options.requestDelegate;
const requestOptions = this.copyRequestOptions(options.requestOptions);
return this._info(requestDelegate, requestOptions);
}
/**
* Renegotiate the session. Sends a re-INVITE.
* @param options - Options bucket. See {@link SessionInviteOptions} for details.
*/
invite(options = {}) {
this.logger.log("Session.invite");
if (this.state !== SessionState.Established) {
return Promise.reject(new Error(`Invalid session state ${this.state}`));
}
if (this.pendingReinvite) {
return Promise.reject(new RequestPendingError("Reinvite in progress. Please wait until complete, then try again."));
}
this.pendingReinvite = true;
// Modifiers and options for initial INVITE transaction
if (options.sessionDescriptionHandlerModifiers) {
this.sessionDescriptionHandlerModifiersReInvite = options.sessionDescriptionHandlerModifiers;
}
if (options.sessionDescriptionHandlerOptions) {
this.sessionDescriptionHandlerOptionsReInvite = options.sessionDescriptionHandlerOptions;
}
const delegate = {
onAccept: (response) => {
// A re-INVITE transaction has an offer/answer [RFC3264] exchange
// associated with it. The UAC (User Agent Client) generating a given
// re-INVITE can act as the offerer or as the answerer. A UAC willing
// to act as the offerer includes an offer in the re-INVITE. The UAS
// (User Agent Server) then provides an answer in a response to the
// re-INVITE. A UAC willing to act as answerer does not include an
// offer in the re-INVITE. The UAS then provides an offer in a response
// to the re-INVITE becoming, thus, the offerer.
// https://tools.ietf.org/html/rfc6141#section-1
const body = getBody(response.message);
if (!body) {
// No way to recover, so terminate session and mark as failed.
this.logger.error("Received 2xx response to re-INVITE without a session description");
this.ackAndBye(response, 400, "Missing session description");
this.stateTransition(SessionState.Terminated);
this.pendingReinvite = false;
return;
}
if (options.withoutSdp) {
// INVITE without SDP - set remote offer and send an answer in the ACK
const answerOptions = {
sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptionsReInvite,
sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiersReInvite
};
this.setOfferAndGetAnswer(body, answerOptions)
.then((answerBody) => {
response.ack({ body: answerBody });
})
.catch((error) => {
// No way to recover, so terminate session and mark as failed.
this.logger.error("Failed to handle offer in 2xx response to re-INVITE");
this.logger.error(error.message);
if (this.state === SessionState.Terminated) {
// A BYE should not be sent if already terminated.
// For example, a BYE may be sent/received while re-INVITE is outstanding.
response.ack();
}
else {
this.ackAndBye(response, 488, "Bad Media Description");
this.stateTransition(SessionState.Terminated);
}
})
.then(() => {
this.pendingReinvite = false;
if (options.requestDelegate && options.requestDelegate.onAccept) {
options.requestDelegate.onAccept(response);
}
});
}
else {
// INVITE with SDP - set remote answer and send an ACK
const answerOptions = {
sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptionsReInvite,
sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiersReInvite
};
this.setAnswer(body, answerOptions)
.then(() => {
response.ack();
})
.catch((error) => {
// No way to recover, so terminate session and mark as failed.
this.logger.error("Failed to handle answer in 2xx response to re-INVITE");
this.logger.error(error.message);
// A BYE should only be sent if session is not already terminated.
// For example, a BYE may be sent/received while re-INVITE is outstanding.
// The ACK needs to be sent regardless as it was not handled by the transaction.
if (this.state !== SessionState.Terminated) {
this.ackAndBye(response, 488, "Bad Media Description");
this.stateTransition(SessionState.Terminated);
}
else {
response.ack();
}
})
.then(() => {
this.pendingReinvite = false;
if (options.requestDelegate && options.requestDelegate.onAccept) {
options.requestDelegate.onAccept(response);
}
});
}
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onProgress: (response) => {
return;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onRedirect: (response) => {
return;
},
onReject: (response) => {
this.logger.warn("Received a non-2xx response to re-INVITE");
this.pendingReinvite = false;
if (options.withoutSdp) {
if (options.requestDelegate && options.requestDelegate.onReject) {
options.requestDelegate.onReject(response);
}
}
else {
this.rollbackOffer()
.catch((error) => {
// No way to recover, so terminate session and mark as failed.
this.logger.error("Failed to rollback offer on non-2xx response to re-INVITE");
this.logger.error(error.message);
// A BYE should only be sent if session is not already terminated.
// For example, a BYE may be sent/received while re-INVITE is outstanding.
// Note that the ACK was already sent by the transaction, so just need to send BYE.
if (this.state !== SessionState.Terminated) {
if (!this.dialog) {
throw new Error("Dialog undefined.");
}
const extraHeaders = [];
extraHeaders.push("Reason: " + this.getReasonHeaderValue(500, "Internal Server Error"));
this.dialog.bye(undefined, { extraHeaders });
this.stateTransition(SessionState.Terminated);
}
})
.then(() => {
if (options.requestDelegate && options.requestDelegate.onReject) {
options.requestDelegate.onReject(response);
}
});
}
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onTrying: (response) => {
return;
}
};
const requestOptions = options.requestOptions || {};
requestOptions.extraHeaders = (requestOptions.extraHeaders || []).slice();
requestOptions.extraHeaders.push("Allow: " + AllowedMethods.toString());
requestOptions.extraHeaders.push("Contact: " + this._contact);
// Just send an INVITE with no sdp...
if (options.withoutSdp) {
if (!this.dialog) {
this.pendingReinvite = false;
throw new Error("Dialog undefined.");
}
return Promise.resolve(this.dialog.invite(delegate, requestOptions));
}
// Get an offer and send it in an INVITE
const offerOptions = {
sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptionsReInvite,
sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiersReInvite
};
return this.getOffer(offerOptions)
.then((offerBody) => {
if (!this.dialog) {
this.pendingReinvite = false;
throw new Error("Dialog undefined.");
}
requestOptions.body = offerBody;
return this.dialog.invite(delegate, requestOptions);
})
.catch((error) => {
this.logger.error(error.message);
this.logger.error("Failed to send re-INVITE");
this.pendingReinvite = false;
throw error;
});
}
/**
* Deliver a {@link Message}. Sends a MESSAGE.
* @param options - Options bucket. See {@link SessionMessageOptions} for details.
*/
message(options = {}) {
// guard session state
if (this.state !== SessionState.Established) {
const message = "Session.message() may only be called if established session.";
this.logger.error(message);
return Promise.reject(new Error(`Invalid session state ${this.state}`));
}
const requestDelegate = options.requestDelegate;
const requestOptions = this.copyRequestOptions(options.requestOptions);
return this._message(requestDelegate, requestOptions);
}
/**
* Proffer a {@link Referral}. Send a REFER.
* @param referTo - The referral target. If a `Session`, a REFER w/Replaces is sent.
* @param options - Options bucket. See {@link SessionReferOptions} for details.
*/
refer(referTo, options = {}) {
// guard session state
if (this.state !== SessionState.Established) {
const message = "Session.refer() may only be called if established session.";
this.logger.error(message);
return Promise.reject(new Error(`Invalid session state ${this.state}`));
}
// REFER with Replaces (Attended Transfer) only supported with established sessions.
if (referTo instanceof Session && !referTo.dialog) {
const message = "Session.refer() may only be called with session which is established. " +
"You are perhaps attempting to attended transfer to a target for which " +
"there is not dialog yet established. Perhaps you are attempting a " +
"'semi-attended' tansfer? Regardless, this is not supported. The recommended " +
"approached is to check to see if the target Session is in the Established " +
"state before calling refer(); if the state is not Established you may " +
"proceed by falling back using a URI as the target (blind transfer).";
this.logger.error(message);
return Promise.reject(new Error(`Invalid session state ${this.state}`));
}
const requestDelegate = options.requestDelegate;
const requestOptions = this.copyRequestOptions(options.requestOptions);
requestOptions.extraHeaders = requestOptions.extraHeaders
? requestOptions.extraHeaders.concat(this.referExtraHeaders(this.referToString(referTo)))
: this.referExtraHeaders(this.referToString(referTo));
return this._refer(options.onNotify, requestDelegate, requestOptions);
}
/**
* Send BYE.
* @param delegate - Request delegate.
* @param options - Request options bucket.
* @internal
*/
_bye(delegate, options) {
// Using core session dialog
if (!this.dialog) {
return Promise.reject(new Error("Session dialog undefined."));
}
const dialog = this.dialog;
// The caller's UA MAY send a BYE for either confirmed or early dialogs,
// and the callee's UA MAY send a BYE on confirmed dialogs, but MUST NOT
// send a BYE on early dialogs. However, the callee's UA MUST NOT send a
// BYE on a confirmed dialog until it has received an ACK for its 2xx
// response or until the server transaction times out.
// https://tools.ietf.org/html/rfc3261#section-15
switch (dialog.sessionState) {
case SessionDialogState.Initial:
throw new Error(`Invalid dialog state ${dialog.sessionState}`);
case SessionDialogState.Early: // Implementation choice - not sending BYE for early dialogs.
throw new Error(`Invalid dialog state ${dialog.sessionState}`);
case SessionDialogState.AckWait: {
// This state only occurs if we are the callee.
this.stateTransition(SessionState.Terminating); // We're terminating
return new Promise((resolve) => {
dialog.delegate = {
// When ACK shows up, say BYE.
onAck: () => {
const request = dialog.bye(delegate, options);
this.stateTransition(SessionState.Terminated);
resolve(request);
return Promise.resolve();
},
// Or the server transaction times out before the ACK arrives.
onAckTimeout: () => {
const request = dialog.bye(delegate, options);
this.stateTransition(SessionState.Terminated);
resolve(request);
}
};
});
}
case SessionDialogState.Confirmed: {
const request = dialog.bye(delegate, options);
this.stateTransition(SessionState.Terminated);
return Promise.resolve(request);
}
case SessionDialogState.Terminated:
throw new Error(`Invalid dialog state ${dialog.sessionState}`);
default:
throw new Error("Unrecognized state.");
}
}
/**
* Send INFO.
* @param delegate - Request delegate.
* @param options - Request options bucket.
* @internal
*/
_info(delegate, options) {
// Using core session dialog
if (!this.dialog) {
return Promise.reject(new Error("Session dialog undefined."));
}
return Promise.resolve(this.dialog.info(delegate, options));
}
/**
* Send MESSAGE.
* @param delegate - Request delegate.
* @param options - Request options bucket.
* @internal
*/
_message(delegate, options) {
// Using core session dialog
if (!this.dialog) {
return Promise.reject(new Error("Session dialog undefined."));
}
return Promise.resolve(this.dialog.message(delegate, options));
}
/**
* Send REFER.
* @param onNotify - Notification callback.
* @param delegate - Request delegate.
* @param options - Request options bucket.
* @internal
*/
_refer(onNotify, delegate, options) {
// Using core session dialog
if (!this.dialog) {
return Promise.reject(new Error("Session dialog undefined."));
}
// If set, deliver any in-dialog NOTIFY requests here...
this.onNotify = onNotify;
return Promise.resolve(this.dialog.refer(delegate, options));
}
/**
* Send ACK and then BYE. There are unrecoverable errors which can occur
* while handling dialog forming and in-dialog INVITE responses and when
* they occur we ACK the response and send a BYE.
* Note that the BYE is sent in the dialog associated with the response
* which is not necessarily `this.dialog`. And, accordingly, the
* session state is not transitioned to terminated and session is not closed.
* @param inviteResponse - The response causing the error.
* @param statusCode - Status code for he reason phrase.
* @param reasonPhrase - Reason phrase for the BYE.
* @internal
*/
ackAndBye(response, statusCode, reasonPhrase) {
response.ack();
const extraHeaders = [];
if (statusCode) {
extraHeaders.push("Reason: " + this.getReasonHeaderValue(statusCode, reasonPhrase));
}
// Using the dialog session associate with the response (which might not be this.dialog)
response.session.bye(undefined, { extraHeaders });
}
/**
* Handle in dialog ACK request.
* @internal
*/
onAckRequest(request) {
this.logger.log("Session.onAckRequest");
if (this.state !== SessionState.Established && this.state !== SessionState.Terminating) {
this.logger.error(`ACK received while in state ${this.state}, dropping request`);
return Promise.resolve();
}
const dialog = this.dialog;
if (!dialog) {
throw new Error("Dialog undefined.");
}
// if received answer in ACK.
const answerOptions = {
sessionDescriptionHandlerOptions: this.pendingReinviteAck
? this.sessionDescriptionHandlerOptionsReInvite
: this.sessionDescriptionHandlerOptions,
sessionDescriptionHandlerModifiers: this.pendingReinviteAck
? this._sessionDescriptionHandlerModifiersReInvite
: this._sessionDescriptionHandlerModifiers
};
if (this.delegate && this.delegate.onAck) {
const ack = new Ack(request);
this.delegate.onAck(ack);
}
// reset pending ACK flag
this.pendingReinviteAck = false;
switch (dialog.signalingState) {
case SignalingState.Initial: {
// State should never be reached as first reliable response must have answer/offer.
// So we must have never has sent an offer.
this.logger.error(`Invalid signaling state ${dialog.signalingState}.`);
const extraHeaders = ["Reason: " + this.getReasonHeaderValue(488, "Bad Media Description")];
dialog.bye(undefined, { extraHeaders });
this.stateTransition(SessionState.Terminated);
return Promise.resolve();
}
case SignalingState.Stable: {
// State we should be in.
// Either the ACK has the answer that got us here, or we were in this state prior to the ACK.
const body = getBody(request.message);
// If the ACK doesn't have an answer, nothing to be done.
if (!body) {
return Promise.resolve();
}
if (body.contentDisposition === "render") {
this._renderbody = body.content;
this._rendertype = body.contentType;
return Promise.resolve();
}
if (body.contentDisposition !== "session") {
return Promise.resolve();
}
return this.setAnswer(body, answerOptions).catch((error) => {
this.logger.error(error.message);
const extraHeaders = ["Reason: " + this.getReasonHeaderValue(488, "Bad Media Description")];
dialog.bye(undefined, { extraHeaders });
this.stateTransition(SessionState.Terminated);
});
}
case SignalingState.HaveLocalOffer: {
// State should never be reached as local offer would be answered by this ACK.
// So we must have received an ACK without an answer.
this.logger.error(`Invalid signaling state ${dialog.signalingState}.`);
const extraHeaders = ["Reason: " + this.getReasonHeaderValue(488, "Bad Media Description")];
dialog.bye(undefined, { extraHeaders });
this.stateTransition(SessionState.Terminated);
return Promise.resolve();
}
case SignalingState.HaveRemoteOffer: {
// State should never be reached as remote offer would be answered in first reliable response.
// So we must have never has sent an answer.
this.logger.error(`Invalid signaling state ${dialog.signalingState}.`);
const extraHeaders = ["Reason: " + this.getReasonHeaderValue(488, "Bad Media Description")];
dialog.bye(undefined, { extraHeaders });
this.stateTransition(SessionState.Terminated);
return Promise.resolve();
}
case SignalingState.Closed:
throw new Error(`Invalid signaling state ${dialog.signalingState}.`);
default:
throw new Error(`Invalid signaling state ${dialog.signalingState}.`);
}
}
/**
* Handle in dialog BYE request.
* @internal
*/
onByeRequest(request) {
this.logger.log("Session.onByeRequest");
if (this.state !== SessionState.Established) {
this.logger.error(`BYE received while in state ${this.state}, dropping request`);
return;
}
if (this.delegate && this.delegate.onBye) {
const bye = new Bye(request);
this.delegate.onBye(bye);
}
else {
request.accept();
}
this.stateTransition(SessionState.Terminated);
}
/**
* Handle in dialog INFO request.
* @internal
*/
onInfoRequest(request) {
this.logger.log("Session.onInfoRequest");
if (this.state !== SessionState.Established) {
this.logger.error(`INFO received while in state ${this.state}, dropping request`);
return;
}
if (this.delegate && this.delegate.onInfo) {
const info = new Info(request);
this.delegate.onInfo(info);
}
else {
// FIXME: TODO: We should reject request...
//
// If a UA receives an INFO request associated with an Info Package that
// the UA has not indicated willingness to receive, the UA MUST send a
// 469 (Bad Info Package) response (see Section 11.6), which contains a
// Recv-Info header field with Info Packages for which the UA is willing
// to receive INFO requests.
// https://tools.ietf.org/html/rfc6086#section-4.2.2
request.accept();
}
}
/**
* Handle in dialog INVITE request.
* @internal
*/
onInviteRequest(request) {
this.logger.log("Session.onInviteRequest");
if (this.state !== SessionState.Established) {
this.logger.error(`INVITE received while in state ${this.state}, dropping request`);
return;
}
// set pending ACK flag
this.pendingReinviteAck = true;
// TODO: would be nice to have core track and set the Contact header,
// but currently the session which is setting it is holding onto it.
const extraHeaders = ["Contact: " + this._contact];
// Handle P-Asserted-Identity
if (request.message.hasHeader("P-Asserted-Identity")) {
const header = request.message.getHeader("P-Asserted-Identity");
if (!header) {
throw new Error("Header undefined.");
}
this._assertedIdentity = Grammar.nameAddrHeaderParse(header);
}
const options = {
sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptionsReInvite,
sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiersReInvite
};
this.generateResponseOfferAnswerInDialog(options)
.then((body) => {
const outgoingResponse = request.accept({ statusCode: 200, extraHeaders, body });
if (this.delegate && this.delegate.onInvite) {
this.delegate.onInvite(request.message, outgoingResponse.message, 200);
}
})
.catch((error) => {
this.logger.error(error.message);
this.logger.error("Failed to handle to re-INVITE request");
if (!this.dialog) {
throw new Error("Dialog undefined.");
}
this.logger.error(this.dialog.signalingState);
// If we don't have a local/remote offer...
if (this.dialog.signalingState === SignalingState.Stable) {
const outgoingResponse = request.reject({ statusCode: 488 }); // Not Acceptable Here
if (this.delegate && this.delegate.onInvite) {
this.delegate.onInvite(request.message, outgoingResponse.message, 488);
}
return;
}
// Otherwise rollback
this.rollbackOffer()
.then(() => {
const outgoingResponse = request.reject({ statusCode: 488 }); // Not Acceptable Here
if (this.delegate && this.delegate.onInvite) {
this.delegate.onInvite(request.message, outgoingResponse.message, 488);
}
})
.catch((errorRollback) => {
// No way to recover, so terminate session and mark as failed.
this.logger.error(errorRollback.message);
this.logger.error("Failed to rollback offer on re-INVITE request");
const outgoingResponse = request.reject({ statusCode: 488 }); // Not Acceptable Here
// A BYE should only be sent if session is not already terminated.
// For example, a BYE may be sent/received while re-INVITE is outstanding.
// Note that the ACK was already sent by the transaction, so just need to send BYE.
if (this.state !== SessionState.Terminated) {
if (!this.dialog) {
throw new Error("Dialog undefined.");
}
const extraHeadersBye = [];
extraHeadersBye.push("Reason: " + this.getReasonHeaderValue(500, "Internal Server Error"));
this.dialog.bye(undefined, { extraHeaders: extraHeadersBye });
this.stateTransition(SessionState.Terminated);
}
if (this.delegate && this.delegate.onInvite) {
this.delegate.onInvite(request.message, outgoingResponse.message, 488);
}
});
});
}
/**
* Handle in dialog MESSAGE request.
* @internal
*/
onMessageRequest(request) {
this.logger.log("Session.onMessageRequest");
if (this.state !== SessionState.Established) {
this.logger.error(`MESSAGE received while in state ${this.state}, dropping request`);
return;
}
if (this.delegate && this.delegate.onMessage) {
const message = new Message(request);
this.delegate.onMessage(message);
}
else {
request.accept();
}
}
/**
* Handle in dialog NOTIFY request.
* @internal
*/
onNotifyRequest(request) {
this.logger.log("Session.onNotifyRequest");
if (this.state !== SessionState.Established) {
this.logger.error(`NOTIFY received while in state ${this.state}, dropping request`);
return;
}
// If this a NOTIFY associated with the progress of a REFER,
// look to delegate handling to the associated callback.
if (this.onNotify) {
const notification = new Notification(request);
this.onNotify(notification);
return;
}
// Otherwise accept the NOTIFY.
if (this.delegate && this.delegate.onNotify) {
const notification = new Notification(request);
this.delegate.onNotify(notification);
}
else {
request.accept();
}
}
/**
* Handle in dialog PRACK request.
* @internal
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onPrackRequest(request) {
this.logger.log("Session.onPrackRequest");
if (this.state !== SessionState.Established) {
this.logger.error(`PRACK received while in state ${this.state}, dropping request`);
return;
}
throw new Error("Unimplemented.");
}
/**
* Handle in dialog REFER request.
* @internal
*/
onReferRequest(request) {
this.logger.log("Session.onReferRequest");
if (this.state !== SessionState.Established) {
this.logger.error(`REFER received while in state ${this.state}, dropping request`);
return;
}
// REFER is a SIP request and is constructed as defined in [1]. A REFER
// request MUST contain exactly one Refer-To header field value.
// https://tools.ietf.org/html/rfc3515#section-2.4.1
if (!request.message.hasHeader("refer-to")) {
this.logger.warn("Invalid REFER packet. A refer-to header is required. Rejecting.");
request.reject();
return;
}
const referral = new Referral(request, this);
if (this.delegate && this.delegate.onRefer) {
this.delegate.onRefer(referral);
}
else {
this.logger.log("No delegate available to handle REFER, automatically accepting and following.");
referral
.accept()
.then(() => referral.makeInviter(this._referralInviterOptions).invite())
.catch((error) => {
// FIXME: logging and eating error...
this.logger.error(error.message);
});
}
}
/**
* Generate an offer or answer for a response to an INVITE request.
* If a remote offer was provided in the request, set the remote
* description and get a local answer. If a remote offer was not
* provided, generates a local offer.
* @internal
*/
generateResponseOfferAnswer(request, options) {
if (this.dialog) {
return this.generateResponseOfferAnswerInDialog(options);
}
const body = getBody(request.message);
if (!body || body.contentDisposition !== "session") {
return this.getOffer(options);
}
else {
return this.setOfferAndGetAnswer(body, options);
}
}
/**
* Generate an offer or answer for a response to an INVITE request
* when a dialog (early or otherwise) has already been established.
* This method may NOT be called if a dialog has yet to be established.
* @internal
*/
generateResponseOfferAnswerInDialog(options) {
if (!this.dialog) {
throw new Error("Dialog undefined.");
}
switch (this.dialog.signalingState) {
case SignalingState.Initial:
return this.getOffer(options);
case SignalingState.HaveLocalOffer:
// o Once the UAS has sent or received an answer to the initial
// offer, it MUST NOT generate subsequent offers in any responses
// to the initial INVITE. This means that a UAS based on this
// specification alone can never generate subsequent offers until
// completion of the initial transaction.
// https://tools.ietf.org/html/rfc3261#section-13.2.1
return Promise.resolve(undefined);
case SignalingState.HaveRemoteOffer:
if (!this.dialog.offer) {
throw new Error(`Session offer undefined in signaling state ${this.dialog.signalingState}.`);
}
return this.setOfferAndGetAnswer(this.dialog.offer, options);
case SignalingState.Stable:
// o Once the UAS has sent or received an answer to the initial
// offer, it MUST NOT generate subsequent offers in any responses
// to the initial INVITE. This means that a UAS based on this
// specification alone can never generate subsequent offers until
// completion of the initial transaction.
// https://tools.ietf.org/html/rfc3261#section-13.2.1
if (this.state !== SessionState.Established) {
return Promise.resolve(undefined);
}
// In dialog INVITE without offer, get an offer for the response.
return this.getOffer(options);
case SignalingState.Closed:
throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`);
default:
throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`);
}
}
/**
* Get local offer.
* @internal
*/
getOffer(options) {
const sdh = this.setupSessionDescriptionHandler();
const sdhOptions = options.sessionDescriptionHandlerOptions;
const sdhModifiers = options.sessionDescriptionHandlerModifiers;
// This is intentionally written very defensively. Don't trust SDH to behave.
try {
return sdh
.getDescription(sdhOptions, sdhModifiers)
.then((bodyAndContentType) => fromBodyLegacy(bodyAndContentType))
.catch((error) => {
// don't trust SDH to reject with Error
this.logger.error("Session.getOffer: SDH getDescription rejected...");
const e = error instanceof Error ? error : new Error("Session.getOffer unknown error.");
this.logger.error(e.message);
throw e;
});
}
catch (error) {
// don't trust SDH to throw an Error
this.logger.error("Session.getOffer: SDH getDescription threw...");
const e = error instanceof Error ? error : new Error(error);
this.logger.error(e.message);
return Promise.reject(e);
}
}
/**
* Rollback local/remote offer.
* @internal
*/
rollbackOffer() {
const sdh = this.setupSessionDescriptionHandler();
if (sdh.rollbackDescription === undefined) {
return Promise.resolve();
}
// This is intentionally written very defensively. Don't trust SDH to behave.
try {
return sdh.rollbackDescription().catch((error) => {
// don't trust SDH to reject with Error
this.logger.error("Session.rollbackOffer: SDH rollbackDescription rejected...");
const e = error instanceof Error ? error : new Error("Session.rollbackOffer unknown error.");
this.logger.error(e.message);
throw e;
});
}
catch (error) {
// don't trust SDH to throw an Error
this.logger.error("Session.rollbackOffer: SDH rollbackDescription threw...");
const e = error instanceof Error ? error : new Error(error);
this.logger.error(e.message);
return Promise.reject(e);
}
}
/**
* Set remote answer.
* @internal
*/
setAnswer(answer, options) {
const sdh = this.setupSessionDescriptionHandler();
const sdhOptions = options.sessionDescriptionHandlerOptions;
const sdhModifiers = options.sessionDescriptionHandlerModifiers;
// This is intentionally written very defensively. Don't trust SDH to behave.
try {
if (!sdh.hasDescription(answer.contentType)) {
return Promise.reject(new ContentTypeUnsupportedError());
}
}
catch (error) {
this.logger.error("Session.setAnswer: SDH hasDescription threw...");
const e = error instanceof Error ? error : new Error(error);
this.logger.error(e.message);
return Promise.reject(e);
}
try {
return sdh.setDescription(answer.content, sdhOptions, sdhModifiers).catch((error) => {
// don't trust SDH to reject with Error
this.logger.error("Session.setAnswer: SDH setDescription rejected...");
const e = error instanceof Error ? error : new Error("Session.setAnswer unknown error.");
this.logger.error(e.message);
throw e;
});
}
catch (error) {
// don't trust SDH to throw an Error
this.logger.error("Session.setAnswer: SDH setDescription threw...");
const e = error instanceof Error ? error : new Error(error);
this.logger.error(e.message);
return Promise.reject(e);
}
}
/**
* Set remote offer and get local answer.