UNPKG

sip.js

Version:

A SIP library for JavaScript

677 lines (676 loc) 32.3 kB
import { Grammar } from "../grammar/grammar.js"; import { equivalentURI, URI } from "../grammar/uri.js"; import { C } from "../core/messages/methods/constants.js"; import { EmitterImpl } from "./emitter.js"; import { RequestPendingError } from "./exceptions/request-pending.js"; import { RegistererState } from "./registerer-state.js"; /** * A registerer registers a contact for an address of record (outgoing REGISTER). * @public */ export class Registerer { /** * Constructs a new instance of the `Registerer` class. * @param userAgent - User agent. See {@link UserAgent} for details. * @param options - Options bucket. See {@link RegistererOptions} for details. */ constructor(userAgent, options = {}) { this.disposed = false; /** The contacts returned from the most recent accepted REGISTER request. */ this._contacts = []; /** The number of seconds to wait before retrying to register. */ this._retryAfter = undefined; /** The registration state. */ this._state = RegistererState.Initial; /** True is waiting for final response to outstanding REGISTER request. */ this._waiting = false; // state emitter this._stateEventEmitter = new EmitterImpl(); // waiting emitter this._waitingEventEmitter = new EmitterImpl(); // Set user agent this.userAgent = userAgent; // Default registrar is domain portion of user agent uri const defaultUserAgentRegistrar = userAgent.configuration.uri.clone(); defaultUserAgentRegistrar.user = undefined; // Initialize configuration this.options = Object.assign(Object.assign(Object.assign({}, Registerer.defaultOptions()), { registrar: defaultUserAgentRegistrar }), Registerer.stripUndefinedProperties(options)); // Make sure we are not using references to array options this.options.extraContactHeaderParams = (this.options.extraContactHeaderParams || []).slice(); this.options.extraHeaders = (this.options.extraHeaders || []).slice(); // Make sure we are not using references to registrar uri if (!this.options.registrar) { throw new Error("Registrar undefined."); } this.options.registrar = this.options.registrar.clone(); // Set instanceId and regId conditional defaults and validate if (this.options.regId && !this.options.instanceId) { this.options.instanceId = this.userAgent.instanceId; } else if (!this.options.regId && this.options.instanceId) { this.options.regId = 1; } if (this.options.instanceId && Grammar.parse(this.options.instanceId, "uuid") === -1) { throw new Error("Invalid instanceId."); } if (this.options.regId && this.options.regId < 0) { throw new Error("Invalid regId."); } const registrar = this.options.registrar; const fromURI = (this.options.params && this.options.params.fromUri) || userAgent.userAgentCore.configuration.aor; const toURI = (this.options.params && this.options.params.toUri) || userAgent.configuration.uri; const params = this.options.params || {}; const extraHeaders = (options.extraHeaders || []).slice(); // Build the request this.request = userAgent.userAgentCore.makeOutgoingRequestMessage(C.REGISTER, registrar, fromURI, toURI, params, extraHeaders, undefined); // Registration expires this.expires = this.options.expires || Registerer.defaultExpires; if (this.expires < 0) { throw new Error("Invalid expires."); } // Interval at which re-REGISTER requests are sent this.refreshFrequency = this.options.refreshFrequency || Registerer.defaultRefreshFrequency; if (this.refreshFrequency < 50 || this.refreshFrequency > 99) { throw new Error("Invalid refresh frequency. The value represents a percentage of the expiration time and should be between 50 and 99."); } // initialize logger this.logger = userAgent.getLogger("sip.Registerer"); 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 "registrar": this.logger.log("· " + key + ": " + value); break; default: this.logger.log("· " + key + ": " + JSON.stringify(value)); } }); } // Identifier this.id = this.request.callId + this.request.from.parameters.tag; // Add to the user agent's session collection. this.userAgent._registerers[this.id] = this; } /** Default registerer options. */ static defaultOptions() { return { expires: Registerer.defaultExpires, extraContactHeaderParams: [], extraHeaders: [], logConfiguration: true, instanceId: "", params: {}, regId: 0, registrar: new URI("sip", "anonymous", "anonymous.invalid"), refreshFrequency: Registerer.defaultRefreshFrequency }; } /** * 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 registered contacts. */ get contacts() { return this._contacts.slice(); } /** * The number of seconds to wait before retrying to register. * @defaultValue `undefined` * @remarks * When the server rejects a registration request, if it provides a suggested * duration to wait before retrying, that value is available here when and if * the state transitions to `Unsubscribed`. It is also available during the * callback to `onReject` after a call to `register`. (Note that if the state * if already `Unsubscribed`, a rejected request created by `register` will * not cause the state to transition to `Unsubscribed`. One way to avoid this * case is to dispose of `Registerer` when unregistered and create a new * `Registerer` for any attempts to retry registering.) * @example * ```ts * // Checking for retry after on state change * registerer.stateChange.addListener((newState) => { * switch (newState) { * case RegistererState.Unregistered: * const retryAfter = registerer.retryAfter; * break; * } * }); * * // Checking for retry after on request rejection * registerer.register({ * requestDelegate: { * onReject: () => { * const retryAfter = registerer.retryAfter; * } * } * }); * ``` */ get retryAfter() { return this._retryAfter; } /** The registration state. */ get state() { return this._state; } /** Emits when the registerer state changes. */ get stateChange() { return this._stateEventEmitter; } /** Destructor. */ dispose() { if (this.disposed) { return Promise.resolve(); } this.disposed = true; this.logger.log(`Registerer ${this.id} in state ${this.state} is being disposed`); // Remove from the user agent's registerer collection delete this.userAgent._registerers[this.id]; // If registered, unregisters and resolves after final response received. return new Promise((resolve) => { const doClose = () => { // If we are registered, unregister and resolve after our state changes if (!this.waiting && this._state === RegistererState.Registered) { this.stateChange.addListener(() => { this.terminated(); resolve(); }, { once: true }); this.unregister(); return; } // Otherwise just resolve this.terminated(); resolve(); }; // If we are waiting for an outstanding request, wait for it to finish and then try closing. // Otherwise just try closing. if (this.waiting) { this.waitingChange.addListener(() => { doClose(); }, { once: true }); } else { doClose(); } }); } /** * Sends the REGISTER request. * @remarks * If successful, sends re-REGISTER requests prior to registration expiration until `unsubscribe()` is called. * Rejects with `RequestPendingError` if a REGISTER request is already in progress. */ register(options = {}) { if (this.state === RegistererState.Terminated) { this.stateError(); throw new Error("Registerer terminated. Unable to register."); } if (this.disposed) { this.stateError(); throw new Error("Registerer disposed. Unable to register."); } // UAs MUST NOT send a new registration (that is, containing new Contact // header field values, as opposed to a retransmission) until they have // received a final response from the registrar for the previous one or // the previous REGISTER request has timed out. // https://tools.ietf.org/html/rfc3261#section-10.2 if (this.waiting) { this.waitingWarning(); const error = new RequestPendingError("REGISTER request already in progress, waiting for final response"); return Promise.reject(error); } // Options if (options.requestOptions) { this.options = Object.assign(Object.assign({}, this.options), options.requestOptions); } // Extra headers const extraHeaders = (this.options.extraHeaders || []).slice(); extraHeaders.push("Contact: " + this.generateContactHeader(this.expires)); // this is UA.C.ALLOWED_METHODS, removed to get around circular dependency extraHeaders.push("Allow: " + ["ACK", "CANCEL", "INVITE", "MESSAGE", "BYE", "OPTIONS", "INFO", "NOTIFY", "REFER"].toString()); // Call-ID: All registrations from a UAC SHOULD use the same Call-ID // header field value for registrations sent to a particular // registrar. // // CSeq: The CSeq value guarantees proper ordering of REGISTER // requests. A UA MUST increment the CSeq value by one for each // REGISTER request with the same Call-ID. // https://tools.ietf.org/html/rfc3261#section-10.2 this.request.cseq++; this.request.setHeader("cseq", this.request.cseq + " REGISTER"); this.request.extraHeaders = extraHeaders; this.waitingToggle(true); const outgoingRegisterRequest = this.userAgent.userAgentCore.register(this.request, { onAccept: (response) => { let expires; // FIXME: This does NOT appear to be to spec and should be removed. // I haven't found anywhere that an Expires header may be used in a response. if (response.message.hasHeader("expires")) { expires = Number(response.message.getHeader("expires")); } // 8. The registrar returns a 200 (OK) response. The response MUST // contain Contact header field values enumerating all current // bindings. Each Contact value MUST feature an "expires" // parameter indicating its expiration interval chosen by the // registrar. The response SHOULD include a Date header field. // https://tools.ietf.org/html/rfc3261#section-10.3 this._contacts = response.message.getHeaders("contact"); let contacts = this._contacts.length; if (!contacts) { this.logger.error("No Contact header in response to REGISTER, dropping response."); this.unregistered(); return; } // The 200 (OK) response from the registrar contains a list of Contact // fields enumerating all current bindings. The UA compares each // contact address to see if it created the contact address, using // comparison rules in Section 19.1.4. If so, it updates the expiration // time interval according to the expires parameter or, if absent, the // Expires field value. The UA then issues a REGISTER request for each // of its bindings before the expiration interval has elapsed. // https://tools.ietf.org/html/rfc3261#section-10.2.4 let contact; while (contacts--) { contact = response.message.parseHeader("contact", contacts); if (!contact) { throw new Error("Contact undefined"); } if (this.userAgent.contact.pubGruu && equivalentURI(contact.uri, this.userAgent.contact.pubGruu)) { expires = Number(contact.getParam("expires")); break; } // If we are using a randomly generated user name (which is the default behavior) if (this.userAgent.configuration.contactName === "") { // compare the user portion of the URI under the assumption that it will be unique if (contact.uri.user === this.userAgent.contact.uri.user) { expires = Number(contact.getParam("expires")); break; } } else { // otherwise use comparision rules in Section 19.1.4 if (equivalentURI(contact.uri, this.userAgent.contact.uri)) { expires = Number(contact.getParam("expires")); break; } } contact = undefined; } // There must be a matching contact. if (contact === undefined) { this.logger.error("No Contact header pointing to us, dropping response"); this.unregistered(); this.waitingToggle(false); return; } // The contact must have an expires. if (expires === undefined) { this.logger.error("Contact pointing to us is missing expires parameter, dropping response"); this.unregistered(); this.waitingToggle(false); return; } // Save gruu values if (contact.hasParam("temp-gruu")) { const gruu = contact.getParam("temp-gruu"); if (gruu) { this.userAgent.contact.tempGruu = Grammar.URIParse(gruu.replace(/"/g, "")); } } if (contact.hasParam("pub-gruu")) { const gruu = contact.getParam("pub-gruu"); if (gruu) { this.userAgent.contact.pubGruu = Grammar.URIParse(gruu.replace(/"/g, "")); } } this.registered(expires); if (options.requestDelegate && options.requestDelegate.onAccept) { options.requestDelegate.onAccept(response); } this.waitingToggle(false); }, onProgress: (response) => { if (options.requestDelegate && options.requestDelegate.onProgress) { options.requestDelegate.onProgress(response); } }, onRedirect: (response) => { this.logger.error("Redirect received. Not supported."); this.unregistered(); if (options.requestDelegate && options.requestDelegate.onRedirect) { options.requestDelegate.onRedirect(response); } this.waitingToggle(false); }, onReject: (response) => { if (response.message.statusCode === 423) { // If a UA receives a 423 (Interval Too Brief) response, it MAY retry // the registration after making the expiration interval of all contact // addresses in the REGISTER request equal to or greater than the // expiration interval within the Min-Expires header field of the 423 // (Interval Too Brief) response. // https://tools.ietf.org/html/rfc3261#section-10.2.8 // // The registrar MAY choose an expiration less than the requested // expiration interval. If and only if the requested expiration // interval is greater than zero AND smaller than one hour AND // less than a registrar-configured minimum, the registrar MAY // reject the registration with a response of 423 (Interval Too // Brief). This response MUST contain a Min-Expires header field // that states the minimum expiration interval the registrar is // willing to honor. It then skips the remaining steps. // https://tools.ietf.org/html/rfc3261#section-10.3 if (!response.message.hasHeader("min-expires")) { // This response MUST contain a Min-Expires header field this.logger.error("423 response received for REGISTER without Min-Expires, dropping response"); this.unregistered(); this.waitingToggle(false); return; } // Increase our registration interval to the suggested minimum this.expires = Number(response.message.getHeader("min-expires")); // Attempt the registration again immediately this.waitingToggle(false); this.register(); return; } this.logger.warn(`Failed to register, status code ${response.message.statusCode}`); // The Retry-After header field can be used with a 500 (Server Internal // Error) or 503 (Service Unavailable) response to indicate how long the // service is expected to be unavailable to the requesting client... // https://tools.ietf.org/html/rfc3261#section-20.33 let retryAfterDuration = NaN; if (response.message.statusCode === 500 || response.message.statusCode === 503) { const header = response.message.getHeader("retry-after"); if (header) { retryAfterDuration = Number.parseInt(header, undefined); } } // Set for the state change (if any) and the delegate callback (if any) this._retryAfter = isNaN(retryAfterDuration) ? undefined : retryAfterDuration; this.unregistered(); if (options.requestDelegate && options.requestDelegate.onReject) { options.requestDelegate.onReject(response); } this._retryAfter = undefined; this.waitingToggle(false); }, onTrying: (response) => { if (options.requestDelegate && options.requestDelegate.onTrying) { options.requestDelegate.onTrying(response); } } }); return Promise.resolve(outgoingRegisterRequest); } /** * Sends the REGISTER request with expires equal to zero. * @remarks * Rejects with `RequestPendingError` if a REGISTER request is already in progress. */ unregister(options = {}) { if (this.state === RegistererState.Terminated) { this.stateError(); throw new Error("Registerer terminated. Unable to register."); } if (this.disposed) { if (this.state !== RegistererState.Registered) { // allows unregister while disposing and registered this.stateError(); throw new Error("Registerer disposed. Unable to register."); } } // UAs MUST NOT send a new registration (that is, containing new Contact // header field values, as opposed to a retransmission) until they have // received a final response from the registrar for the previous one or // the previous REGISTER request has timed out. // https://tools.ietf.org/html/rfc3261#section-10.2 if (this.waiting) { this.waitingWarning(); const error = new RequestPendingError("REGISTER request already in progress, waiting for final response"); return Promise.reject(error); } if (this._state !== RegistererState.Registered && !options.all) { this.logger.warn("Not currently registered, but sending an unregister anyway."); } // Extra headers const extraHeaders = ((options.requestOptions && options.requestOptions.extraHeaders) || []).slice(); this.request.extraHeaders = extraHeaders; // Registrations are soft state and expire unless refreshed, but can // also be explicitly removed. A client can attempt to influence the // expiration interval selected by the registrar as described in Section // 10.2.1. A UA requests the immediate removal of a binding by // specifying an expiration interval of "0" for that contact address in // a REGISTER request. UAs SHOULD support this mechanism so that // bindings can be removed before their expiration interval has passed. // // The REGISTER-specific Contact header field value of "*" applies to // all registrations, but it MUST NOT be used unless the Expires header // field is present with a value of "0". // https://tools.ietf.org/html/rfc3261#section-10.2.2 if (options.all) { extraHeaders.push("Contact: *"); extraHeaders.push("Expires: 0"); } else { extraHeaders.push("Contact: " + this.generateContactHeader(0)); } // Call-ID: All registrations from a UAC SHOULD use the same Call-ID // header field value for registrations sent to a particular // registrar. // // CSeq: The CSeq value guarantees proper ordering of REGISTER // requests. A UA MUST increment the CSeq value by one for each // REGISTER request with the same Call-ID. // https://tools.ietf.org/html/rfc3261#section-10.2 this.request.cseq++; this.request.setHeader("cseq", this.request.cseq + " REGISTER"); // Pre-emptive clear the registration timer to avoid a race condition where // this timer fires while waiting for a final response to the unsubscribe. if (this.registrationTimer !== undefined) { clearTimeout(this.registrationTimer); this.registrationTimer = undefined; } this.waitingToggle(true); const outgoingRegisterRequest = this.userAgent.userAgentCore.register(this.request, { onAccept: (response) => { this._contacts = response.message.getHeaders("contact"); // Update contacts this.unregistered(); if (options.requestDelegate && options.requestDelegate.onAccept) { options.requestDelegate.onAccept(response); } this.waitingToggle(false); }, onProgress: (response) => { if (options.requestDelegate && options.requestDelegate.onProgress) { options.requestDelegate.onProgress(response); } }, onRedirect: (response) => { this.logger.error("Unregister redirected. Not currently supported."); this.unregistered(); if (options.requestDelegate && options.requestDelegate.onRedirect) { options.requestDelegate.onRedirect(response); } this.waitingToggle(false); }, onReject: (response) => { this.logger.error(`Unregister rejected with status code ${response.message.statusCode}`); this.unregistered(); if (options.requestDelegate && options.requestDelegate.onReject) { options.requestDelegate.onReject(response); } this.waitingToggle(false); }, onTrying: (response) => { if (options.requestDelegate && options.requestDelegate.onTrying) { options.requestDelegate.onTrying(response); } } }); return Promise.resolve(outgoingRegisterRequest); } /** * Clear registration timers. */ clearTimers() { if (this.registrationTimer !== undefined) { clearTimeout(this.registrationTimer); this.registrationTimer = undefined; } if (this.registrationExpiredTimer !== undefined) { clearTimeout(this.registrationExpiredTimer); this.registrationExpiredTimer = undefined; } } /** * Generate Contact Header */ generateContactHeader(expires) { let contact = this.userAgent.contact.toString({ register: true }); if (this.options.regId && this.options.instanceId) { contact += ";reg-id=" + this.options.regId; contact += ';+sip.instance="<urn:uuid:' + this.options.instanceId + '>"'; } if (this.options.extraContactHeaderParams) { this.options.extraContactHeaderParams.forEach((header) => { contact += ";" + header; }); } contact += ";expires=" + expires; return contact; } /** * Helper function, called when registered. */ registered(expires) { this.clearTimers(); // Re-Register before the expiration interval has elapsed. // For that, calculate the delay as a percentage of the expiration time this.registrationTimer = setTimeout(() => { this.registrationTimer = undefined; this.register(); }, (this.refreshFrequency / 100) * expires * 1000); // We are unregistered if the registration expires. this.registrationExpiredTimer = setTimeout(() => { this.logger.warn("Registration expired"); this.unregistered(); }, expires * 1000); if (this._state !== RegistererState.Registered) { this.stateTransition(RegistererState.Registered); } } /** * Helper function, called when unregistered. */ unregistered() { this.clearTimers(); if (this._state !== RegistererState.Unregistered) { this.stateTransition(RegistererState.Unregistered); } } /** * Helper function, called when terminated. */ terminated() { this.clearTimers(); if (this._state !== RegistererState.Terminated) { this.stateTransition(RegistererState.Terminated); } } /** * Transition registration state. */ stateTransition(newState) { const invalidTransition = () => { throw new Error(`Invalid state transition from ${this._state} to ${newState}`); }; // Validate transition switch (this._state) { case RegistererState.Initial: if (newState !== RegistererState.Registered && newState !== RegistererState.Unregistered && newState !== RegistererState.Terminated) { invalidTransition(); } break; case RegistererState.Registered: if (newState !== RegistererState.Unregistered && newState !== RegistererState.Terminated) { invalidTransition(); } break; case RegistererState.Unregistered: if (newState !== RegistererState.Registered && newState !== RegistererState.Terminated) { invalidTransition(); } break; case RegistererState.Terminated: invalidTransition(); break; default: throw new Error("Unrecognized state."); } // Transition this._state = newState; this.logger.log(`Registration transitioned to state ${this._state}`); this._stateEventEmitter.emit(this._state); // Dispose if (newState === RegistererState.Terminated) { this.dispose(); } } /** True if the registerer is currently waiting for final response to a REGISTER request. */ get waiting() { return this._waiting; } /** Emits when the registerer waiting state changes. */ get waitingChange() { return this._waitingEventEmitter; } /** * Toggle waiting. */ waitingToggle(waiting) { if (this._waiting === waiting) { throw new Error(`Invalid waiting transition from ${this._waiting} to ${waiting}`); } this._waiting = waiting; this.logger.log(`Waiting toggled to ${this._waiting}`); this._waitingEventEmitter.emit(this._waiting); } /** Hopefully helpful as the standard behavior has been found to be unexpected. */ waitingWarning() { let message = "An attempt was made to send a REGISTER request while a prior one was still in progress."; message += " RFC 3261 requires UAs MUST NOT send a new registration until they have received a final response"; message += " from the registrar for the previous one or the previous REGISTER request has timed out."; message += " Note that if the transport disconnects, you still must wait for the prior request to time out before"; message += " sending a new REGISTER request or alternatively dispose of the current Registerer and create a new Registerer."; this.logger.warn(message); } /** Hopefully helpful as the standard behavior has been found to be unexpected. */ stateError() { const reason = this.state === RegistererState.Terminated ? "is in 'Terminated' state" : "has been disposed"; let message = `An attempt was made to send a REGISTER request when the Registerer ${reason}.`; message += " The Registerer transitions to 'Terminated' when Registerer.dispose() is called."; message += " Perhaps you called UserAgent.stop() which dipsoses of all Registerers?"; this.logger.error(message); } } Registerer.defaultExpires = 600; Registerer.defaultRefreshFrequency = 99;