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': {