Commit 3e895f7 for jssip.net
commit 3e895f7fc9346b98676f1d8ab674ae4e86843047
Author: José Luis Millán <jmillan@aliax.net>
Date: Tue Oct 20 17:01:42 2026 +0200
TS: Configuration (#987)
diff --git a/src/Config.js b/src/Config.js
deleted file mode 100644
index 64689ff..0000000
--- a/src/Config.js
+++ /dev/null
@@ -1,314 +0,0 @@
-const Utils = require('./Utils');
-const JsSIP_C = require('./Constants');
-const Grammar = require('./Grammar');
-const URI = require('./URI');
-const Socket = require('./Socket');
-const Exceptions = require('./Exceptions');
-
-// Default settings.
-exports.settings = {
- // SIP authentication.
- authorization_user: null,
- password: null,
- realm: null,
- ha1: null,
- authorization_jwt: null,
-
- // SIP account.
- display_name: null,
- uri: null,
- contact_uri: null,
-
- // SIP instance id (GRUU).
- instance_id: null,
-
- // Preloaded SIP Route header field.
- use_preloaded_route: false,
-
- // Session parameters.
- session_timers: true,
- session_timers_refresh_method: JsSIP_C.UPDATE,
- session_timers_force_refresher: false,
- no_answer_timeout: 60,
-
- // Registration parameters.
- register: true,
- register_expires: 600,
- register_from_tag_trail: '',
- registrar_server: null,
-
- // Connection options.
- sockets: null,
- connection_recovery_max_interval: JsSIP_C.CONNECTION_RECOVERY_MAX_INTERVAL,
- connection_recovery_min_interval: JsSIP_C.CONNECTION_RECOVERY_MIN_INTERVAL,
-
- // Global extra headers, to be added to every request and response
- extra_headers: null,
-
- /*
- * Host address.
- * Value to be set in Via sent_by and host part of Contact FQDN.
- */
- via_host: `${Utils.createRandomToken(12)}.invalid`,
-};
-
-// Configuration checks.
-const checks = {
- mandatory: {
- sockets(sockets) {
- /* Allow defining sockets parameter as:
- * Socket: socket
- * Array of Socket: [socket1, socket2]
- * Array of Objects: [{socket: socket1, weight:1}, {socket: Socket2, weight:0}]
- * Array of Objects and Socket: [{socket: socket1}, socket2]
- */
- const _sockets = [];
-
- if (Socket.isSocket(sockets)) {
- _sockets.push({ socket: sockets });
- } else if (Array.isArray(sockets) && sockets.length) {
- for (const socket of sockets) {
- if (
- Object.prototype.hasOwnProperty.call(socket, 'socket') &&
- Socket.isSocket(socket.socket)
- ) {
- _sockets.push(socket);
- } else if (Socket.isSocket(socket)) {
- _sockets.push({ socket: socket });
- }
- }
- } else {
- return;
- }
-
- return _sockets;
- },
-
- uri(uri) {
- if (!/^sip:/i.test(uri)) {
- uri = `${JsSIP_C.SIP}:${uri}`;
- }
- const parsed = URI.parse(uri);
-
- if (!parsed) {
- return;
- } else if (!parsed.user) {
- return;
- } else {
- return parsed;
- }
- },
- },
-
- optional: {
- authorization_user(authorization_user) {
- if (Grammar.parse(`"${authorization_user}"`, 'quoted_string') === -1) {
- return;
- } else {
- return authorization_user;
- }
- },
- authorization_jwt(authorization_jwt) {
- if (typeof authorization_jwt === 'string') {
- return authorization_jwt;
- }
- },
- user_agent(user_agent) {
- if (typeof user_agent === 'string') {
- return user_agent;
- }
- },
-
- connection_recovery_max_interval(connection_recovery_max_interval) {
- if (Utils.isDecimal(connection_recovery_max_interval)) {
- const value = Number(connection_recovery_max_interval);
-
- if (value > 0) {
- return value;
- }
- }
- },
-
- connection_recovery_min_interval(connection_recovery_min_interval) {
- if (Utils.isDecimal(connection_recovery_min_interval)) {
- const value = Number(connection_recovery_min_interval);
-
- if (value > 0) {
- return value;
- }
- }
- },
-
- contact_uri(contact_uri) {
- if (typeof contact_uri === 'string') {
- const uri = Grammar.parse(contact_uri, 'SIP_URI');
-
- if (uri !== -1) {
- return uri;
- }
- }
- },
-
- display_name(display_name) {
- return display_name;
- },
-
- instance_id(instance_id) {
- if (/^uuid:/i.test(instance_id)) {
- instance_id = instance_id.substr(5);
- }
-
- if (Grammar.parse(instance_id, 'uuid') === -1) {
- return;
- } else {
- return instance_id;
- }
- },
-
- no_answer_timeout(no_answer_timeout) {
- if (Utils.isDecimal(no_answer_timeout)) {
- const value = Number(no_answer_timeout);
-
- if (value > 0) {
- return value;
- }
- }
- },
-
- session_timers(session_timers) {
- if (typeof session_timers === 'boolean') {
- return session_timers;
- }
- },
-
- session_timers_refresh_method(method) {
- if (typeof method === 'string') {
- method = method.toUpperCase();
-
- if (method === JsSIP_C.INVITE || method === JsSIP_C.UPDATE) {
- return method;
- }
- }
- },
-
- session_timers_force_refresher(session_timers_force_refresher) {
- if (typeof session_timers_force_refresher === 'boolean') {
- return session_timers_force_refresher;
- }
- },
-
- password(password) {
- return String(password);
- },
-
- realm(realm) {
- return String(realm);
- },
-
- ha1(ha1) {
- return String(ha1);
- },
-
- register(register) {
- if (typeof register === 'boolean') {
- return register;
- }
- },
-
- register_expires(register_expires) {
- if (Utils.isDecimal(register_expires)) {
- const value = Number(register_expires);
-
- if (value >= 0) {
- return value;
- }
- }
- },
-
- register_from_tag_trail(register_from_tag_trail) {
- if (typeof register_from_tag_trail === 'function') {
- return register_from_tag_trail;
- }
-
- return String(register_from_tag_trail);
- },
-
- registrar_server(registrar_server) {
- if (!/^sip:/i.test(registrar_server)) {
- registrar_server = `${JsSIP_C.SIP}:${registrar_server}`;
- }
-
- const parsed = URI.parse(registrar_server);
-
- if (!parsed) {
- return;
- } else if (parsed.user) {
- return;
- } else {
- return parsed;
- }
- },
-
- use_preloaded_route(use_preloaded_route) {
- if (typeof use_preloaded_route === 'boolean') {
- return use_preloaded_route;
- }
- },
-
- extra_headers(extra_headers) {
- const _extraHeaders = [];
-
- if (Array.isArray(extra_headers) && extra_headers.length) {
- for (const header of extra_headers) {
- if (typeof header === 'string') {
- _extraHeaders.push(header);
- }
- }
- } else {
- return;
- }
-
- return _extraHeaders;
- },
- },
-};
-
-exports.load = (dst, src) => {
- // Check Mandatory parameters.
- for (const parameter in checks.mandatory) {
- if (!src.hasOwnProperty(parameter)) {
- throw new Exceptions.ConfigurationError(parameter);
- } else {
- const value = src[parameter];
- const checked_value = checks.mandatory[parameter](value);
-
- if (checked_value !== undefined) {
- dst[parameter] = checked_value;
- } else {
- throw new Exceptions.ConfigurationError(parameter, value);
- }
- }
- }
-
- // Check Optional parameters.
- for (const parameter in checks.optional) {
- if (src.hasOwnProperty(parameter)) {
- const value = src[parameter];
-
- /* If the parameter value is null, empty string, undefined, empty array
- * or it's a number with NaN value, then apply its default value.
- */
- if (Utils.isEmpty(value)) {
- continue;
- }
-
- const checked_value = checks.optional[parameter](value);
-
- if (checked_value !== undefined) {
- dst[parameter] = checked_value;
- } else {
- throw new Exceptions.ConfigurationError(parameter, value);
- }
- }
- }
-};
diff --git a/src/Configuration.ts b/src/Configuration.ts
new file mode 100644
index 0000000..8dbccbb
--- /dev/null
+++ b/src/Configuration.ts
@@ -0,0 +1,470 @@
+import {
+ CONNECTION_RECOVERY_MAX_INTERVAL,
+ CONNECTION_RECOVERY_MIN_INTERVAL,
+ INVITE,
+ SIP,
+ UPDATE,
+} from './Constants';
+import { ConfigurationError } from './Exceptions';
+import * as Grammar from './Grammar';
+import { isSocket } from './Socket';
+import type { WeightedSocket } from './Socket';
+import { createRandomToken, isDecimal, isEmpty } from './Utils';
+import URI = require('./URI');
+
+/**
+ * The working configuration of a `UA`, as built from the defaults below and
+ * the values given by the application.
+ *
+ * TODO: `UA` then mutates this object in ways this type cannot express: it
+ * deletes `sockets`, adds `jssip_id` and `hostport_params`, and rewrites
+ * `no_answer_timeout` from seconds into milliseconds. It should derive its own
+ * value from this one instead of mutating it.
+ */
+export type Configuration = {
+ // SIP authentication.
+ authorization_user: string | null;
+ password: string | null;
+ realm: string | null;
+ ha1: string | null;
+ authorization_jwt: string | null;
+
+ // SIP account.
+ /* NOTE: A number because `UA` allows a `0` to be given, which it then turns
+ * into '0'.
+ */
+ display_name: string | number | null;
+ uri: URI | null;
+ contact_uri: URI | null;
+
+ // SIP instance id (GRUU).
+ instance_id: string | null;
+
+ // Preloaded SIP Route header field.
+ use_preloaded_route: boolean;
+
+ // Session parameters.
+ session_timers: boolean;
+ session_timers_refresh_method: string;
+ session_timers_force_refresher: boolean;
+ no_answer_timeout: number;
+
+ // Registration parameters.
+ register: boolean;
+ register_expires: number;
+ register_from_tag_trail: string | (() => string);
+ registrar_server: URI | null;
+
+ // Connection options.
+ sockets: WeightedSocket[] | null;
+ connection_recovery_max_interval: number;
+ connection_recovery_min_interval: number;
+
+ // Global extra headers, to be added to every request and response
+ extra_headers: string[] | null;
+
+ /*
+ * Host address.
+ * Value to be set in Via sent_by and host part of Contact FQDN.
+ */
+ via_host: string;
+
+ /* NOTE: Optional because, unlike every other parameter, it has no default
+ * below. It only exists once the application configures it.
+ */
+ user_agent?: string;
+};
+
+// Default values.
+export const DEFAULT_CONFIGURATION: Configuration = {
+ // SIP authentication.
+ authorization_user: null,
+ password: null,
+ realm: null,
+ ha1: null,
+ authorization_jwt: null,
+
+ // SIP account.
+ display_name: null,
+ uri: null,
+ contact_uri: null,
+
+ // SIP instance id (GRUU).
+ instance_id: null,
+
+ // Preloaded SIP Route header field.
+ use_preloaded_route: false,
+
+ // Session parameters.
+ session_timers: true,
+ session_timers_refresh_method: UPDATE,
+ session_timers_force_refresher: false,
+ no_answer_timeout: 60,
+
+ // Registration parameters.
+ register: true,
+ register_expires: 600,
+ register_from_tag_trail: '',
+ registrar_server: null,
+
+ // Connection options.
+ sockets: null,
+ connection_recovery_max_interval: CONNECTION_RECOVERY_MAX_INTERVAL,
+ connection_recovery_min_interval: CONNECTION_RECOVERY_MIN_INTERVAL,
+
+ // Global extra headers, to be added to every request and response
+ extra_headers: null,
+
+ /*
+ * Host address.
+ * Value to be set in Via sent_by and host part of Contact FQDN.
+ */
+ via_host: `${createRandomToken(12)}.invalid`,
+};
+
+/**
+ * Parameters the application must provide.
+ */
+type MandatoryParam = 'uri' | 'sockets';
+
+/**
+ * Parameters the application may provide. Every configuration parameter that
+ * is neither mandatory nor computed by `UA` has a check, which the mapped type
+ * below enforces in both directions.
+ */
+type OptionalParam = Exclude<keyof Configuration, MandatoryParam | 'via_host'>;
+
+/* NOTE: A check receives whatever the application passed, hence `unknown`.
+ * Its return type is tied to the configuration parameter it populates, so a
+ * check can neither skip validating its input nor produce a value that
+ * parameter would not accept.
+ */
+type ConfigCheck<K extends keyof Configuration> = (
+ value: unknown
+) => Configuration[K] | undefined;
+
+// Configuration checks.
+const checks: {
+ mandatory: { [K in MandatoryParam]: ConfigCheck<K> };
+ optional: { [K in OptionalParam]: ConfigCheck<K> };
+} = {
+ mandatory: {
+ sockets(sockets) {
+ /* Allow defining sockets parameter as:
+ * Socket: socket
+ * Array of Socket: [socket1, socket2]
+ * Array of Objects: [{socket: socket1, weight:1}, {socket: Socket2, weight:0}]
+ * Array of Objects and Socket: [{socket: socket1}, socket2]
+ */
+ const _sockets: WeightedSocket[] = [];
+
+ if (isSocket(sockets)) {
+ _sockets.push({ socket: sockets });
+ } else if (Array.isArray(sockets) && sockets.length) {
+ for (const socket of sockets) {
+ if (
+ Object.prototype.hasOwnProperty.call(socket, 'socket') &&
+ isSocket(socket.socket)
+ ) {
+ _sockets.push(socket);
+ } else if (isSocket(socket)) {
+ _sockets.push({ socket: socket });
+ }
+ }
+ } else {
+ return;
+ }
+
+ return _sockets;
+ },
+
+ uri(value) {
+ /* NOTE: Coerced rather than rejected, since the regular expression and
+ * the template below used to coerce it anyway.
+ */
+ let uri = String(value);
+
+ if (!/^sip:/i.test(uri)) {
+ uri = `${SIP}:${uri}`;
+ }
+ const parsed = URI.parse(uri);
+
+ if (!parsed) {
+ return;
+ } else if (!parsed.user) {
+ return;
+ } else {
+ return parsed;
+ }
+ },
+ },
+
+ optional: {
+ authorization_user(value) {
+ const authorization_user = String(value);
+
+ if (Grammar.parse(`"${authorization_user}"`, 'quoted_string') === -1) {
+ return;
+ } else {
+ return authorization_user;
+ }
+ },
+ authorization_jwt(authorization_jwt) {
+ if (typeof authorization_jwt === 'string') {
+ return authorization_jwt;
+ }
+
+ return undefined;
+ },
+ user_agent(user_agent) {
+ if (typeof user_agent === 'string') {
+ return user_agent;
+ }
+
+ return undefined;
+ },
+
+ connection_recovery_max_interval(connection_recovery_max_interval) {
+ if (isDecimal(connection_recovery_max_interval)) {
+ const value = Number(connection_recovery_max_interval);
+
+ if (value > 0) {
+ return value;
+ }
+ }
+
+ return undefined;
+ },
+
+ connection_recovery_min_interval(connection_recovery_min_interval) {
+ if (isDecimal(connection_recovery_min_interval)) {
+ const value = Number(connection_recovery_min_interval);
+
+ if (value > 0) {
+ return value;
+ }
+ }
+
+ return undefined;
+ },
+
+ contact_uri(contact_uri) {
+ if (typeof contact_uri === 'string') {
+ const uri = Grammar.parse(contact_uri, 'SIP_URI');
+
+ if (uri !== -1) {
+ return uri;
+ }
+ }
+
+ return undefined;
+ },
+
+ /* NOTE: A number is accepted because `UA` turns a `0` into '0'. Anything
+ * else used to be accepted too, only to fail later on when building a
+ * `NameAddrHeader`.
+ */
+ display_name(value) {
+ if (typeof value === 'string' || typeof value === 'number') {
+ return value;
+ }
+
+ return undefined;
+ },
+
+ instance_id(value) {
+ let instance_id = String(value);
+
+ if (/^uuid:/i.test(instance_id)) {
+ instance_id = instance_id.substr(5);
+ }
+
+ if (Grammar.parse(instance_id, 'uuid') === -1) {
+ return;
+ } else {
+ return instance_id;
+ }
+ },
+
+ no_answer_timeout(no_answer_timeout) {
+ if (isDecimal(no_answer_timeout)) {
+ const value = Number(no_answer_timeout);
+
+ if (value > 0) {
+ return value;
+ }
+ }
+
+ return undefined;
+ },
+
+ session_timers(session_timers) {
+ if (typeof session_timers === 'boolean') {
+ return session_timers;
+ }
+
+ return undefined;
+ },
+
+ session_timers_refresh_method(method) {
+ if (typeof method === 'string') {
+ method = method.toUpperCase();
+
+ if (method === INVITE || method === UPDATE) {
+ return method;
+ }
+ }
+
+ return undefined;
+ },
+
+ session_timers_force_refresher(session_timers_force_refresher) {
+ if (typeof session_timers_force_refresher === 'boolean') {
+ return session_timers_force_refresher;
+ }
+
+ return undefined;
+ },
+
+ password(password) {
+ return String(password);
+ },
+
+ realm(realm) {
+ return String(realm);
+ },
+
+ ha1(ha1) {
+ return String(ha1);
+ },
+
+ register(register) {
+ if (typeof register === 'boolean') {
+ return register;
+ }
+
+ return undefined;
+ },
+
+ register_expires(register_expires) {
+ if (isDecimal(register_expires)) {
+ const value = Number(register_expires);
+
+ if (value >= 0) {
+ return value;
+ }
+ }
+
+ return undefined;
+ },
+
+ register_from_tag_trail(value) {
+ if (typeof value === 'function') {
+ /* NOTE: Whether it really takes no argument and returns a string
+ * cannot be verified at runtime.
+ */
+ return value as () => string;
+ }
+
+ return String(value);
+ },
+
+ registrar_server(value) {
+ let registrar_server = String(value);
+
+ if (!/^sip:/i.test(registrar_server)) {
+ registrar_server = `${SIP}:${registrar_server}`;
+ }
+
+ const parsed = URI.parse(registrar_server);
+
+ if (!parsed) {
+ return;
+ } else if (parsed.user) {
+ return;
+ } else {
+ return parsed;
+ }
+ },
+
+ use_preloaded_route(use_preloaded_route) {
+ if (typeof use_preloaded_route === 'boolean') {
+ return use_preloaded_route;
+ }
+
+ return undefined;
+ },
+
+ extra_headers(extra_headers) {
+ const _extraHeaders: string[] = [];
+
+ if (Array.isArray(extra_headers) && extra_headers.length) {
+ for (const header of extra_headers) {
+ if (typeof header === 'string') {
+ _extraHeaders.push(header);
+ }
+ }
+ } else {
+ return;
+ }
+
+ return _extraHeaders;
+ },
+ },
+};
+
+/* NOTE: `dst[key] = value` cannot be expressed when `key` is a union, as
+ * TypeScript then requires the value to satisfy every member at once. The
+ * check that matters, that a check returns what its setting accepts, is
+ * already enforced by `ConfigCheck`.
+ */
+function assign<K extends keyof Configuration>(
+ dst: Configuration,
+ key: K,
+ value: Configuration[K]
+): void {
+ (dst as Record<K, Configuration[K]>)[key] = value;
+}
+
+export const load = (
+ dst: Configuration,
+ src: Record<string, unknown>
+): void => {
+ // Check Mandatory parameters.
+ for (const parameter of Object.keys(checks.mandatory) as MandatoryParam[]) {
+ if (!Object.prototype.hasOwnProperty.call(src, parameter)) {
+ throw new ConfigurationError(parameter);
+ } else {
+ const value = src[parameter];
+ const checked_value = checks.mandatory[parameter](value);
+
+ if (checked_value !== undefined) {
+ assign(dst, parameter, checked_value);
+ } else {
+ throw new ConfigurationError(parameter, value);
+ }
+ }
+ }
+
+ // Check Optional parameters.
+ for (const parameter of Object.keys(checks.optional) as OptionalParam[]) {
+ if (Object.prototype.hasOwnProperty.call(src, parameter)) {
+ const value = src[parameter];
+
+ /* If the parameter value is null, empty string, undefined, empty array
+ * or it's a number with NaN value, then apply its default value.
+ */
+ if (isEmpty(value)) {
+ continue;
+ }
+
+ const checked_value = checks.optional[parameter](value);
+
+ if (checked_value !== undefined) {
+ assign(dst, parameter, checked_value);
+ } else {
+ throw new ConfigurationError(parameter, value);
+ }
+ }
+ }
+};
diff --git a/src/SIPMessage.ts b/src/SIPMessage.ts
index 37d589e..3f0ee99 100644
--- a/src/SIPMessage.ts
+++ b/src/SIPMessage.ts
@@ -120,9 +120,9 @@ export interface ServerTransactionLike extends EventEmitter {
* nor `transport`, even though `UA` exposes both as public getters, so that
* gap must be closed first.
*
- * TODO: The `configuration` shape below is the *resolved* configuration, which
- * is not the same as the `UAConfiguration` input type declared in `UA.d.ts`.
- * It should become a type exported by `Config` once that module is ported.
+ * TODO: The `configuration` shape below is `Config.Configuration` plus the
+ * `jssip_id` that `UA` adds, so it can only be replaced once `UA` is ported.
+ * Note it is not the `UAConfiguration` input type declared in `UA.d.ts`.
*/
export interface UALike {
readonly configuration: {
diff --git a/src/UA.js b/src/UA.js
index e330e34..571d6a9 100644
--- a/src/UA.js
+++ b/src/UA.js
@@ -17,7 +17,7 @@ const URI = require('./URI');
const Parser = require('./Parser');
const SIPMessage = require('./SIPMessage');
const sanityCheck = require('./sanityCheck');
-const config = require('./Config');
+const config = require('./Configuration');
const logger = new Logger('UA');
@@ -68,7 +68,7 @@ module.exports = class UA extends EventEmitter {
credentials: {},
};
- this._configuration = Object.assign({}, config.settings);
+ this._configuration = Object.assign({}, config.DEFAULT_CONFIGURATION);
this._dynConfiguration = {};
this._dialogs = {};
@@ -933,7 +933,12 @@ module.exports = class UA extends EventEmitter {
logger.debug('configuration parameters after validation:');
for (const parameter in this._configuration) {
// Only show the user user configurable parameters.
- if (Object.prototype.hasOwnProperty.call(config.settings, parameter)) {
+ if (
+ Object.prototype.hasOwnProperty.call(
+ config.DEFAULT_CONFIGURATION,
+ parameter
+ )
+ ) {
switch (parameter) {
case 'uri':
case 'registrar_server': {