UNPKG

sip.js

Version:

A SIP library for JavaScript

1,109 lines 63.4 kB
import { Invitation } from "../../../api/invitation.js"; import { Inviter } from "../../../api/inviter.js"; import { Messager } from "../../../api/messager.js"; import { Registerer } from "../../../api/registerer.js"; import { RegistererState } from "../../../api/registerer-state.js"; import { RequestPendingError } from "../../../api/exceptions/request-pending.js"; import { Session } from "../../../api/session.js"; import { SessionState } from "../../../api/session-state.js"; import { UserAgent } from "../../../api/user-agent.js"; import { UserAgentState } from "../../../api/user-agent-state.js"; import { SessionDescriptionHandler } from "../session-description-handler/session-description-handler.js"; import { Transport } from "../transport/transport.js"; import { defaultManagedSessionFactory } from "./managed-session-factory-default.js"; /** * A session manager for SIP.js sessions. * @public */ export class SessionManager { /** * Constructs a new instance of the `SessionManager` class. * @param server - SIP WebSocket Server URL. * @param options - Options bucket. See {@link SessionManagerOptions} for details. */ constructor(server, options = {}) { /** Sessions being managed. */ this.managedSessions = []; this.attemptingReconnection = false; this.optionsPingFailure = false; this.optionsPingRunning = false; this.shouldBeConnected = false; this.shouldBeRegistered = false; // Delegate this.delegate = options.delegate; // Copy options this.options = Object.assign({ aor: "", autoStop: true, delegate: {}, iceStopWaitingOnServerReflexive: false, managedSessionFactory: defaultManagedSessionFactory(), maxSimultaneousSessions: 2, media: {}, optionsPingInterval: -1, optionsPingRequestURI: "", reconnectionAttempts: 3, reconnectionDelay: 4, registrationRetry: false, registrationRetryInterval: 3, registerGuard: null, registererOptions: {}, registererRegisterOptions: {}, sendDTMFUsingSessionDescriptionHandler: false, userAgentOptions: {} }, SessionManager.stripUndefinedProperties(options)); // UserAgentOptions const userAgentOptions = Object.assign({}, options.userAgentOptions); // Transport if (!userAgentOptions.transportConstructor) { userAgentOptions.transportConstructor = Transport; } // TransportOptions if (!userAgentOptions.transportOptions) { userAgentOptions.transportOptions = { server }; } // URI if (!userAgentOptions.uri) { // If an AOR was provided, convert it to a URI if (options.aor) { const uri = UserAgent.makeURI(options.aor); if (!uri) { throw new Error(`Failed to create valid URI from ${options.aor}`); } userAgentOptions.uri = uri; } } // UserAgent this.userAgent = new UserAgent(userAgentOptions); // UserAgent's delegate this.userAgent.delegate = { // Handle connection with server established onConnect: () => { this.logger.log(`Connected`); if (this.delegate && this.delegate.onServerConnect) { this.delegate.onServerConnect(); } // Attempt to register if we are supposed to be registered if (this.shouldBeRegistered) { this.register(); } // Start OPTIONS pings if we are to be pinging if (this.options.optionsPingInterval > 0) { this.optionsPingStart(); } }, // Handle connection with server lost onDisconnect: async (error) => { this.logger.log(`Disconnected`); // Stop OPTIONS ping if need be. let optionsPingFailure = false; if (this.options.optionsPingInterval > 0) { optionsPingFailure = this.optionsPingFailure; this.optionsPingFailure = false; this.optionsPingStop(); } // Let delgate know we have disconnected if (this.delegate && this.delegate.onServerDisconnect) { this.delegate.onServerDisconnect(error); } // If the user called `disconnect` a graceful cleanup will be done therein. // Only cleanup if network/server dropped the connection. // Only reconnect if network/server dropped the connection if (error || optionsPingFailure) { // There is no transport at this point, so we are not expecting to be able to // send messages much less get responses. So just dispose of everything without // waiting for anything to succeed. if (this.registerer) { this.logger.log(`Disposing of registerer...`); this.registerer.dispose().catch((e) => { this.logger.debug(`Error occurred disposing of registerer after connection with server was lost.`); this.logger.debug(e.toString()); }); this.registerer = undefined; } this.managedSessions .slice() .map((el) => el.session) .forEach(async (session) => { this.logger.log(`Disposing of session...`); session.dispose().catch((e) => { this.logger.debug(`Error occurred disposing of a session after connection with server was lost.`); this.logger.debug(e.toString()); }); }); // Attempt to reconnect if we are supposed to be connected. if (this.shouldBeConnected) { this.attemptReconnection(); } } }, // Handle incoming invitations onInvite: (invitation) => { this.logger.log(`[${invitation.id}] Received INVITE`); // Guard against a maximum number of pre-existing sessions. // An incoming INVITE request may be received at any time and/or while in the process // of sending an outgoing INVITE request. So we reject any incoming INVITE in those cases. const maxSessions = this.options.maxSimultaneousSessions; if (maxSessions !== 0 && this.managedSessions.length > maxSessions) { this.logger.warn(`[${invitation.id}] Session already in progress, rejecting INVITE...`); invitation .reject() .then(() => { this.logger.log(`[${invitation.id}] Rejected INVITE`); }) .catch((error) => { this.logger.error(`[${invitation.id}] Failed to reject INVITE`); this.logger.error(error.toString()); }); return; } // Use our configured constraints as options for any Inviter created as result of a REFER const referralInviterOptions = { sessionDescriptionHandlerOptions: { constraints: this.constraints } }; // Initialize our session this.initSession(invitation, referralInviterOptions); // Delegate if (this.delegate && this.delegate.onCallReceived) { this.delegate.onCallReceived(invitation); } else { this.logger.warn(`[${invitation.id}] No handler available, rejecting INVITE...`); invitation .reject() .then(() => { this.logger.log(`[${invitation.id}] Rejected INVITE`); }) .catch((error) => { this.logger.error(`[${invitation.id}] Failed to reject INVITE`); this.logger.error(error.toString()); }); } }, // Handle incoming messages onMessage: (message) => { message.accept().then(() => { if (this.delegate && this.delegate.onMessageReceived) { this.delegate.onMessageReceived(message); } }); }, // Handle incoming notifications onNotify: (notification) => { notification.accept().then(() => { if (this.delegate && this.delegate.onNotificationReceived) { this.delegate.onNotificationReceived(notification); } }); } }; // RegistererOptions this.registererOptions = Object.assign({}, options.registererOptions); // RegistererRegisterOptions this.registererRegisterOptions = Object.assign({}, options.registererRegisterOptions); // Retry registration on failure or rejection. if (this.options.registrationRetry) { // If the register request is rejected, try again... this.registererRegisterOptions.requestDelegate = this.registererRegisterOptions.requestDelegate || {}; const existingOnReject = this.registererRegisterOptions.requestDelegate.onReject; this.registererRegisterOptions.requestDelegate.onReject = (response) => { existingOnReject && existingOnReject(response); // If at first we don't succeed, try try again... this.attemptRegistration(); }; } // Use the SIP.js logger this.logger = this.userAgent.getLogger("sip.SessionManager"); // Monitor network connectivity and attempt reconnection and reregistration when we come online window.addEventListener("online", () => { this.logger.log(`Online`); if (this.shouldBeConnected) { this.connect(); } }); // NOTE: The autoStop option does not currently work as one likley expects. // This code is here because the "autoStop behavior" and this assoicated // implemenation has been a recurring request. So instead of removing // the implementation again (because it doesn't work) and then having // to explain agian the issue over and over again to those who want it, // we have included it here to break that cycle. The implementation is // harmless and serves to provide an explaination for those interested. if (this.options.autoStop) { // Standard operation workflow will resume after this callback exits, meaning // that any asynchronous operations are likely not going to be finished, especially // if they are guaranteed to not be executed in the current tick (promises fall // under this category, they will never be resolved synchronously by design). window.addEventListener("beforeunload", async () => { this.shouldBeConnected = false; this.shouldBeRegistered = false; if (this.userAgent.state !== UserAgentState.Stopped) { // The stop() method returns a promise which will not resolve before the page unloads. await this.userAgent.stop(); } }); } } /** * 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; }, {}); } /** * The local media stream. Undefined if call not answered. * @param session - Session to get the media stream from. */ getLocalMediaStream(session) { const sdh = session.sessionDescriptionHandler; if (!sdh) { return undefined; } if (!(sdh instanceof SessionDescriptionHandler)) { throw new Error("Session description handler not instance of web SessionDescriptionHandler"); } return sdh.localMediaStream; } /** * The remote media stream. Undefined if call not answered. * @param session - Session to get the media stream from. */ getRemoteMediaStream(session) { const sdh = session.sessionDescriptionHandler; if (!sdh) { return undefined; } if (!(sdh instanceof SessionDescriptionHandler)) { throw new Error("Session description handler not instance of web SessionDescriptionHandler"); } return sdh.remoteMediaStream; } /** * The local audio track, if available. * @param session - Session to get track from. * @deprecated Use localMediaStream and get track from the stream. */ getLocalAudioTrack(session) { var _a; return (_a = this.getLocalMediaStream(session)) === null || _a === void 0 ? void 0 : _a.getTracks().find((track) => track.kind === "audio"); } /** * The local video track, if available. * @param session - Session to get track from. * @deprecated Use localMediaStream and get track from the stream. */ getLocalVideoTrack(session) { var _a; return (_a = this.getLocalMediaStream(session)) === null || _a === void 0 ? void 0 : _a.getTracks().find((track) => track.kind === "video"); } /** * The remote audio track, if available. * @param session - Session to get track from. * @deprecated Use remoteMediaStream and get track from the stream. */ getRemoteAudioTrack(session) { var _a; return (_a = this.getRemoteMediaStream(session)) === null || _a === void 0 ? void 0 : _a.getTracks().find((track) => track.kind === "audio"); } /** * The remote video track, if available. * @param session - Session to get track from. * @deprecated Use remoteMediaStream and get track from the stream. */ getRemoteVideoTrack(session) { var _a; return (_a = this.getRemoteMediaStream(session)) === null || _a === void 0 ? void 0 : _a.getTracks().find((track) => track.kind === "video"); } /** * Connect. * @remarks * If not started, starts the UserAgent connecting the WebSocket Transport. * Otherwise reconnects the UserAgent's WebSocket Transport. * Attempts will be made to reconnect as needed. */ async connect() { this.logger.log(`Connecting UserAgent...`); this.shouldBeConnected = true; if (this.userAgent.state !== UserAgentState.Started) { return this.userAgent.start(); } return this.userAgent.reconnect(); } /** * Disconnect. * @remarks * If not stopped, stops the UserAgent disconnecting the WebSocket Transport. */ async disconnect() { this.logger.log(`Disconnecting UserAgent...`); if (this.userAgent.state === UserAgentState.Stopped) { return Promise.resolve(); } this.shouldBeConnected = false; this.shouldBeRegistered = false; this.registerer = undefined; return this.userAgent.stop(); } /** * Return true if transport is connected. */ isConnected() { return this.userAgent.isConnected(); } /** * Start receiving incoming calls. * @remarks * Send a REGISTER request for the UserAgent's AOR. * Resolves when the REGISTER request is sent, otherwise rejects. * Attempts will be made to re-register as needed. */ async register(registererRegisterOptions) { this.logger.log(`Registering UserAgent...`); this.shouldBeRegistered = true; if (registererRegisterOptions !== undefined) { this.registererRegisterOptions = Object.assign({}, registererRegisterOptions); } if (!this.registerer) { this.registerer = new Registerer(this.userAgent, this.registererOptions); this.registerer.stateChange.addListener((state) => { switch (state) { case RegistererState.Initial: break; case RegistererState.Registered: if (this.delegate && this.delegate.onRegistered) { this.delegate.onRegistered(); } break; case RegistererState.Unregistered: if (this.delegate && this.delegate.onUnregistered) { this.delegate.onUnregistered(); } // If we transition to an unregister state, attempt to get back to a registered state. if (this.shouldBeRegistered) { this.attemptRegistration(); } break; case RegistererState.Terminated: break; default: throw new Error("Unknown registerer state."); } }); } return this.attemptRegistration(true); } /** * Stop receiving incoming calls. * @remarks * Send an un-REGISTER request for the UserAgent's AOR. * Resolves when the un-REGISTER request is sent, otherwise rejects. */ async unregister(registererUnregisterOptions) { this.logger.log(`Unregistering UserAgent...`); this.shouldBeRegistered = false; if (!this.registerer) { this.logger.warn(`No registerer to unregister.`); return Promise.resolve(); } return this.registerer.unregister(registererUnregisterOptions).then(() => { return; }); } /** * Make an outgoing call. * @remarks * Send an INVITE request to create a new Session. * Resolves when the INVITE request is sent, otherwise rejects. * Use `onCallAnswered` delegate method to determine if Session is established. * @param destination - The target destination to call. A SIP address to send the INVITE to. * @param inviterOptions - Optional options for Inviter constructor. * @param inviterInviteOptions - Optional options for Inviter.invite(). */ async call(destination, inviterOptions, inviterInviteOptions) { this.logger.log(`Beginning Session...`); // Guard against a maximum number of pre-existing sessions. // An incoming INVITE request may be received at any time and/or while in the process // of sending an outgoing INVITE request. So we reject any incoming INVITE in those cases. const maxSessions = this.options.maxSimultaneousSessions; if (maxSessions !== 0 && this.managedSessions.length > maxSessions) { return Promise.reject(new Error("Maximum number of sessions already exists.")); } const target = UserAgent.makeURI(destination); if (!target) { return Promise.reject(new Error(`Failed to create a valid URI from "${destination}"`)); } // Use our configured constraints as InviterOptions if none provided if (!inviterOptions) { inviterOptions = {}; } if (!inviterOptions.sessionDescriptionHandlerOptions) { inviterOptions.sessionDescriptionHandlerOptions = {}; } if (!inviterOptions.sessionDescriptionHandlerOptions.constraints) { inviterOptions.sessionDescriptionHandlerOptions.constraints = this.constraints; } // If utilizing early media, add a handler to catch 183 Session Progress // messages and then to play the associated remote media (the early media). if (inviterOptions.earlyMedia) { inviterInviteOptions = inviterInviteOptions || {}; inviterInviteOptions.requestDelegate = inviterInviteOptions.requestDelegate || {}; const existingOnProgress = inviterInviteOptions.requestDelegate.onProgress; inviterInviteOptions.requestDelegate.onProgress = (response) => { if (response.message.statusCode === 183) { this.setupRemoteMedia(inviter); } existingOnProgress && existingOnProgress(response); }; } // TODO: Any existing onSessionDescriptionHandler is getting clobbered here. // If we get a server reflexive candidate, stop waiting on ICE gathering to complete. // The candidate is a server reflexive candidate; the ip indicates an intermediary // address assigned by the STUN server to represent the candidate's peer anonymously. if (this.options.iceStopWaitingOnServerReflexive) { inviterOptions.delegate = inviterOptions.delegate || {}; inviterOptions.delegate.onSessionDescriptionHandler = (sessionDescriptionHandler) => { if (!(sessionDescriptionHandler instanceof SessionDescriptionHandler)) { throw new Error("Session description handler not instance of SessionDescriptionHandler"); } sessionDescriptionHandler.peerConnectionDelegate = { onicecandidate: (event) => { var _a; if (((_a = event.candidate) === null || _a === void 0 ? void 0 : _a.type) === "srflx") { this.logger.log(`[${inviter.id}] Found srflx ICE candidate, stop waiting...`); // In sip.js > 0.20.1 this cast should be removed as iceGatheringComplete will be public const sdh = sessionDescriptionHandler; sdh.iceGatheringComplete(); } } }; }; } // Create a new Inviter for the outgoing Session const inviter = new Inviter(this.userAgent, target, inviterOptions); // Send INVITE return this.sendInvite(inviter, inviterOptions, inviterInviteOptions).then(() => { return inviter; }); } /** * Hangup a call. * @param session - Session to hangup. * @remarks * Send a BYE request, CANCEL request or reject response to end the current Session. * Resolves when the request/response is sent, otherwise rejects. * Use `onCallHangup` delegate method to determine if and when call is ended. */ async hangup(session) { this.logger.log(`[${session.id}] Hangup...`); if (!this.sessionExists(session)) { return Promise.reject(new Error("Session does not exist.")); } return this.terminate(session); } /** * Answer an incoming call. * @param session - Session to answer. * @remarks * Accept an incoming INVITE request creating a new Session. * Resolves with the response is sent, otherwise rejects. * Use `onCallAnswered` delegate method to determine if and when call is established. * @param invitationAcceptOptions - Optional options for Inviter.accept(). */ async answer(session, invitationAcceptOptions) { this.logger.log(`[${session.id}] Accepting Invitation...`); if (!this.sessionExists(session)) { return Promise.reject(new Error("Session does not exist.")); } if (!(session instanceof Invitation)) { return Promise.reject(new Error("Session not instance of Invitation.")); } // Use our configured constraints as InvitationAcceptOptions if none provided if (!invitationAcceptOptions) { invitationAcceptOptions = {}; } if (!invitationAcceptOptions.sessionDescriptionHandlerOptions) { invitationAcceptOptions.sessionDescriptionHandlerOptions = {}; } if (!invitationAcceptOptions.sessionDescriptionHandlerOptions.constraints) { invitationAcceptOptions.sessionDescriptionHandlerOptions.constraints = this.constraints; } return session.accept(invitationAcceptOptions); } /** * Decline an incoming call. * @param session - Session to decline. * @remarks * Reject an incoming INVITE request. * Resolves with the response is sent, otherwise rejects. * Use `onCallHangup` delegate method to determine if and when call is ended. */ async decline(session) { this.logger.log(`[${session.id}] Rejecting Invitation...`); if (!this.sessionExists(session)) { return Promise.reject(new Error("Session does not exist.")); } if (!(session instanceof Invitation)) { return Promise.reject(new Error("Session not instance of Invitation.")); } return session.reject(); } /** * Hold call * @param session - Session to hold. * @remarks * Send a re-INVITE with new offer indicating "hold". * Resolves when the re-INVITE request is sent, otherwise rejects. * Use `onCallHold` delegate method to determine if request is accepted or rejected. * See: https://tools.ietf.org/html/rfc6337 */ async hold(session) { this.logger.log(`[${session.id}] Holding session...`); return this.setHold(session, true); } /** * Unhold call. * @param session - Session to unhold. * @remarks * Send a re-INVITE with new offer indicating "unhold". * Resolves when the re-INVITE request is sent, otherwise rejects. * Use `onCallHold` delegate method to determine if request is accepted or rejected. * See: https://tools.ietf.org/html/rfc6337 */ async unhold(session) { this.logger.log(`[${session.id}] Unholding session...`); return this.setHold(session, false); } /** * Hold state. * @param session - Session to check. * @remarks * True if session is on hold. */ isHeld(session) { const managedSession = this.sessionManaged(session); return managedSession ? managedSession.held : false; } /** * Mute call. * @param session - Session to mute. * @remarks * Disable sender's media tracks. */ mute(session) { this.logger.log(`[${session.id}] Disabling media tracks...`); this.setMute(session, true); } /** * Unmute call. * @param session - Session to unmute. * @remarks * Enable sender's media tracks. */ unmute(session) { this.logger.log(`[${session.id}] Enabling media tracks...`); this.setMute(session, false); } /** * Mute state. * @param session - Session to check. * @remarks * True if sender's media track is disabled. */ isMuted(session) { const managedSession = this.sessionManaged(session); return managedSession ? managedSession.muted : false; } /** * Send DTMF. * @param session - Session to send on. * @remarks * Send an INFO request with content type application/dtmf-relay. * @param tone - Tone to send. */ async sendDTMF(session, tone) { this.logger.log(`[${session.id}] Sending DTMF...`); // Validate tone if (!/^[0-9A-D#*,]$/.exec(tone)) { return Promise.reject(new Error("Invalid DTMF tone.")); } if (!this.sessionExists(session)) { return Promise.reject(new Error("Session does not exist.")); } this.logger.log(`[${session.id}] Sending DTMF tone: ${tone}`); if (this.options.sendDTMFUsingSessionDescriptionHandler) { if (!session.sessionDescriptionHandler) { return Promise.reject(new Error("Session desciption handler undefined.")); } if (!session.sessionDescriptionHandler.sendDtmf(tone)) { return Promise.reject(new Error("Failed to send DTMF")); } return Promise.resolve(); } else { // As RFC 6086 states, sending DTMF via INFO is not standardized... // // Companies have been using INFO messages in order to transport // Dual-Tone Multi-Frequency (DTMF) tones. All mechanisms are // proprietary and have not been standardized. // https://tools.ietf.org/html/rfc6086#section-2 // // It is however widely supported based on this draft: // https://tools.ietf.org/html/draft-kaplan-dispatch-info-dtmf-package-00 // The UA MUST populate the "application/dtmf-relay" body, as defined // earlier, with the button pressed and the duration it was pressed // for. Technically, this actually requires the INFO to be generated // when the user *releases* the button, however if the user has still // not released a button after 5 seconds, which is the maximum duration // supported by this mechanism, the UA should generate the INFO at that // time. // https://tools.ietf.org/html/draft-kaplan-dispatch-info-dtmf-package-00#section-5.3 const dtmf = tone; const duration = 2000; const body = { contentDisposition: "render", contentType: "application/dtmf-relay", content: "Signal=" + dtmf + "\r\nDuration=" + duration }; const requestOptions = { body }; return session.info({ requestOptions }).then(() => { return; }); } } /** * Transfer. * @param session - Session with the transferee to transfer. * @param target - The referral target. * @remarks * If target is a Session this is an attended transfer completion (REFER with Replaces), * otherwise this is a blind transfer (REFER). Attempting an attended transfer * completion on a call that has not been answered will be rejected. To implement * an attended transfer with early completion, hangup the call with the target * and execute a blind transfer to the target. */ async transfer(session, target, options) { this.logger.log(`[${session.id}] Referring session...`); if (target instanceof Session) { return session.refer(target, options).then(() => { return; }); } const uri = UserAgent.makeURI(target); if (!uri) { return Promise.reject(new Error(`Failed to create a valid URI from "${target}"`)); } return session.refer(uri, options).then(() => { return; }); } /** * Send a message. * @remarks * Send a MESSAGE request. * @param destination - The target destination for the message. A SIP address to send the MESSAGE to. */ async message(destination, message) { this.logger.log(`Sending message...`); const target = UserAgent.makeURI(destination); if (!target) { return Promise.reject(new Error(`Failed to create a valid URI from "${destination}"`)); } return new Messager(this.userAgent, target, message).message(); } /** Media constraints. */ get constraints() { let constraints = { audio: true, video: false }; // default to audio only calls if (this.options.media.constraints) { constraints = Object.assign({}, this.options.media.constraints); } return constraints; } /** * Attempt reconnection up to `reconnectionAttempts` times. * @param reconnectionAttempt - Current attempt number. */ attemptReconnection(reconnectionAttempt = 1) { const reconnectionAttempts = this.options.reconnectionAttempts; const reconnectionDelay = this.options.reconnectionDelay; if (!this.shouldBeConnected) { this.logger.log(`Should not be connected currently`); return; // If intentionally disconnected, don't reconnect. } if (this.attemptingReconnection) { this.logger.log(`Reconnection attempt already in progress`); } if (reconnectionAttempt > reconnectionAttempts) { this.logger.log(`Reconnection maximum attempts reached`); return; } if (reconnectionAttempt === 1) { this.logger.log(`Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - trying`); } else { this.logger.log(`Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - trying in ${reconnectionDelay} seconds`); } this.attemptingReconnection = true; setTimeout(() => { if (!this.shouldBeConnected) { this.logger.log(`Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - aborted`); this.attemptingReconnection = false; return; // If intentionally disconnected, don't reconnect. } this.userAgent .reconnect() .then(() => { this.logger.log(`Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - succeeded`); this.attemptingReconnection = false; }) .catch((error) => { this.logger.log(`Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - failed`); this.logger.error(error.message); this.attemptingReconnection = false; this.attemptReconnection(++reconnectionAttempt); }); }, reconnectionAttempt === 1 ? 0 : reconnectionDelay * 1000); } /** * Register to receive calls. * @param withoutDelay - If true attempt immediately, otherwise wait `registrationRetryInterval`. */ attemptRegistration(withoutDelay = false) { this.logger.log(`Registration attempt ${withoutDelay ? "without delay" : ""}`); if (!this.shouldBeRegistered) { this.logger.log(`Should not be registered currently`); return Promise.resolve(); } // It only makes sense to have one attempt in progress at a time. // Perhaps we shall (or should) try once again. if (this.registrationAttemptTimeout !== undefined) { this.logger.log(`Registration attempt already in progress`); return Promise.resolve(); } // Helper function to send the register request. const _register = () => { // If we do not have a registerer, it is not worth trying to register. if (!this.registerer) { this.logger.log(`Registerer undefined`); return Promise.resolve(); } // If the WebSocket transport is not connected, it is not worth trying to register. // Perhpas we shall (or should) try once we are connected. if (!this.isConnected()) { this.logger.log(`User agent not connected`); return Promise.resolve(); } // If the UserAgent is stopped, it is not worth trying to register. // Perhaps we shall (or should) try once the UserAgent is running. if (this.userAgent.state === UserAgentState.Stopped) { this.logger.log(`User agent stopped`); return Promise.resolve(); } // If no guard defined, we are good to proceed without any further ado. if (!this.options.registerGuard) { return this.registerer.register(this.registererRegisterOptions).then(() => { return; }); } // Otherwise check to make sure the guard does not want us halt. return this.options .registerGuard() .catch((error) => { this.logger.log(`Register guard rejected will making registration attempt`); throw error; }) .then((halt) => { if (halt || !this.registerer) { return Promise.resolve(); } return this.registerer.register(this.registererRegisterOptions).then(() => { return; }); }); }; // Compute an amount of time in seconds to wait before sending another register request. // This is a small attempt to avoid DOS attacking our own backend in the event that a // relatively large number of clients sychonously keep retrying register reqeusts. // This is known to happen when the backend goes down for a period and all clients // are attempting to register again - the backend gets slammed with synced reqeusts. const computeRegistrationTimeout = (lowerBound) => { const upperBound = lowerBound * 2; return 1000 * (Math.random() * (upperBound - lowerBound) + lowerBound); }; // Send register request after a delay return new Promise((resolve, reject) => { this.registrationAttemptTimeout = setTimeout(() => { _register() .then(() => { this.registrationAttemptTimeout = undefined; resolve(); }) .catch((error) => { this.registrationAttemptTimeout = undefined; if (error instanceof RequestPendingError) { resolve(); } else { reject(error); } }); }, withoutDelay ? 0 : computeRegistrationTimeout(this.options.registrationRetryInterval)); }); } /** Helper function to remove media from html elements. */ cleanupMedia(session) { const managedSession = this.sessionManaged(session); if (!managedSession) { throw new Error("Managed session does not exist."); } if (managedSession.mediaLocal) { if (managedSession.mediaLocal.video) { managedSession.mediaLocal.video.srcObject = null; managedSession.mediaLocal.video.pause(); } } if (managedSession.mediaRemote) { if (managedSession.mediaRemote.audio) { managedSession.mediaRemote.audio.srcObject = null; managedSession.mediaRemote.audio.pause(); } if (managedSession.mediaRemote.video) { managedSession.mediaRemote.video.srcObject = null; managedSession.mediaRemote.video.pause(); } } } /** Helper function to enable/disable media tracks. */ enableReceiverTracks(session, enable) { if (!this.sessionExists(session)) { throw new Error("Session does not exist."); } const sessionDescriptionHandler = session.sessionDescriptionHandler; if (!(sessionDescriptionHandler instanceof SessionDescriptionHandler)) { throw new Error("Session's session description handler not instance of SessionDescriptionHandler."); } sessionDescriptionHandler.enableReceiverTracks(enable); } /** Helper function to enable/disable media tracks. */ enableSenderTracks(session, enable) { if (!this.sessionExists(session)) { throw new Error("Session does not exist."); } const sessionDescriptionHandler = session.sessionDescriptionHandler; if (!(sessionDescriptionHandler instanceof SessionDescriptionHandler)) { throw new Error("Session's session description handler not instance of SessionDescriptionHandler."); } sessionDescriptionHandler.enableSenderTracks(enable); } /** * Setup session delegate and state change handler. * @param session - Session to setup. * @param referralInviterOptions - Options for any Inviter created as result of a REFER. */ initSession(session, referralInviterOptions) { // Add the session this.sessionAdd(session); // Call session created callback if (this.delegate && this.delegate.onCallCreated) { this.delegate.onCallCreated(session); } // Setup session state change handler session.stateChange.addListener((state) => { this.logger.log(`[${session.id}] Session state changed to ${state}`); switch (state) { case SessionState.Initial: break; case SessionState.Establishing: break; case SessionState.Established: this.setupLocalMedia(session); this.setupRemoteMedia(session); if (this.delegate && this.delegate.onCallAnswered) { this.delegate.onCallAnswered(session); } break; case SessionState.Terminating: // fall through case SessionState.Terminated: // This will already have executed if/when we fall // through from Terminating and thus the managed // session may already have been cleaned up. if (this.sessionExists(session)) { this.cleanupMedia(session); this.sessionRemove(session); if (this.delegate && this.delegate.onCallHangup) { this.delegate.onCallHangup(session); } } break; default: throw new Error("Unknown session state."); } }); // TODO: Any existing onInfo or onRefer delegate gets clobbered here. // Setup delegate session.delegate = session.delegate || {}; session.delegate.onInfo = (info) => { // As RFC 6086 states, sending DTMF via INFO is not standardized... // // Companies have been using INFO messages in order to transport // Dual-Tone Multi-Frequency (DTMF) tones. All mechanisms are // proprietary and have not been standardized. // https://tools.ietf.org/html/rfc6086#section-2 // // It is however widely supported based on this draft: // https://tools.ietf.org/html/draft-kaplan-dispatch-info-dtmf-package-00 var _a; // FIXME: TODO: We should reject correctly... // // 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 // No delegate if (((_a = this.delegate) === null || _a === void 0 ? void 0 : _a.onCallDTMFReceived) === undefined) { info.reject(); return; } // Invalid content type const contentType = info.request.getHeader("content-type"); if (!contentType || !/^application\/dtmf-relay/i.exec(contentType)) { info.reject(); return; } // Invalid body const body = info.request.body.split("\r\n", 2); if (body.length !== 2) { info.reject(); return; } // Invalid tone let tone; const toneRegExp = /^(Signal\s*?=\s*?)([0-9A-D#*]{1})(\s)?.*/; if (body[0] !== undefined && toneRegExp.test(body[0])) { tone = body[0].replace(toneRegExp, "$2"); } if (!tone) { info.reject(); return; } // Invalid duration let duration; const durationRegExp = /^(Duration\s?=\s?)([0-9]{1,4})(\s)?.*/; if (body[1] !== undefined && durationRegExp.test(body[1])) { duration = parseInt(body[1].replace(durationRegExp, "$2"), 10); } if (!duration) { info.reject(); return; } info .accept() .then(() => { if (this.delegate && this.delegate.onCallDTMFReceived) { if (!tone || !duration) { throw new Error("Tone or duration undefined."); } this.delegate.onCallDTMFReceived(session, tone, duration); } }) .catch((error) => { this.logger.error(error.message); }); }; session.delegate.onRefer = (referral) => { referral .accept() .then(() => this.sendInvite(referral.makeInviter(referralInviterOptions), referralInviterOptions)) .catch((error) => { this.logger.error(error.message); }); }; } /** * Periodically send OPTIONS pings and disconnect when a ping fails. * @param requestURI - Request URI to target * @param fromURI - From URI * @param toURI - To URI */ optionsPingRun(requestURI, fromURI, toURI) { // Guard against nvalid interval if (this.options.optionsPingInterval < 1) { throw new Error("Invalid options ping interval."); } // Guard against sending a ping when there is one outstanading if (this.optionsPingRunning) { return; } this.optionsPingRunning = true; // Setup next ping to run in future this.optionsPingTimeout = setTimeout(() => { this.optionsPingTimeout = undefined; // If ping succeeds... const onPingSuccess = () => { // record success or failure this.optionsPingFailure = false; // if we are still running, queue up the next ping if (this.optionsPingRunning) { this.optionsPingRunning = false; this.optionsPingRun(requestURI, fromURI, toURI); } }; // If ping fails... const onPingFailure = () => { this.logger.error("OPTIONS ping failed"); // record success or failure this.optionsPingFailure = true; // stop running this.optionsPingRunning = false; // disconnect the transport this.userAgent.transport.disconnect().catch((error) => this.logger.error(error)); }; // Create an OPTIONS request message const core = this.userAgent.userAgentCore; const message = core.makeOutgoingRequestMessage("OPTIONS", requestURI, fromURI, toURI, {}); // Send the request message this.optionsPingRequest = core.request(message, { onAccept: () => { this.optionsPingRequest = undefined; onPingSuccess(); }, onReject: (response) => { this.optionsPingRequest = undefined; // Ping fails on following responses... // - 408 Request Timeout (no response was received) // - 503 Service Unavailable (a transport layer error occured) if (response.message.statusCode === 408 || response.message.statusCode === 503) { onPingFailure(); } else { onPingSuccess(); } } }); }, this.options.optionsPingInterval * 1000); } /** * Start sending OPTIONS pings. */ optionsPingStart() { this.logger.log(`OPTIONS pings started`); // Create the URIs needed to send OPTIONS pings let requestURI, fromURI, toURI; if (this.options.optionsPingRequestURI) { // Use whatever specific RURI is provided. requestURI = UserAgent.makeURI(this.options.optionsPingRequestURI); if (!requestURI) { throw new Error("Failed to create R