Commit acfb7fb for jssip.net

commit acfb7fb836967574d636529d1078979c9b573358
Author: José Luis Millán <jmillan@aliax.net>
Date:   Tue Oct 20 16:22:29 2026 +0200

    TS: Transport (#986)

diff --git a/src/SIPMessage.ts b/src/SIPMessage.ts
index 7e632b7..37d589e 100644
--- a/src/SIPMessage.ts
+++ b/src/SIPMessage.ts
@@ -3,6 +3,7 @@ import type { EventEmitter } from 'events';
 import * as JsSIP_C from './Constants';
 import { Logger } from './Logger';
 import * as Grammar from './Grammar';
+import type { Transport } from './Transport';
 import {
 	cloneArray,
 	createRandomToken,
@@ -84,18 +85,6 @@ export type OutgoingRequestParams = {
  * runtime require cycle, since type imports are erased.
  */

-/**
- * The transport surface SIP messages depend on.
- *
- * TODO: Replace with `import type { Transport } from './Transport'` once
- * `Transport` is ported to TypeScript.
- */
-export interface TransportLike {
-	readonly sip_uri: string;
-
-	send(data: string): boolean;
-}
-
 /**
  * The client transaction an outgoing INVITE is bound to.
  *
@@ -149,7 +138,7 @@ export interface UALike {
 		pub_gruu?: string | null;
 		temp_gruu?: string | null;
 	};
-	readonly transport: TransportLike;
+	readonly transport: Transport;
 }

 /**
@@ -829,7 +818,7 @@ export class IncomingMessage {
 export class IncomingRequest extends IncomingMessage {
 	ua: UALike;
 	ruri: URI;
-	transport: TransportLike | null;
+	transport: Transport | null;
 	server_transaction: ServerTransactionLike | null;

 	constructor({ ua, ruri, ...params }: IncomingRequestParams) {
diff --git a/src/Socket.ts b/src/Socket.ts
index 5c6e5a0..00047c1 100644
--- a/src/Socket.ts
+++ b/src/Socket.ts
@@ -28,12 +28,12 @@ export interface Socket {

 	ondisconnect(error: boolean, code?: number, reason?: string): void;

-	ondata<T>(event: T): void;
+	ondata(data: string | ArrayBuffer): void;
 }

 export interface WeightedSocket {
 	socket: Socket;
-	weight: number;
+	weight?: number;
 }

 export const isSocket = (socket: unknown): socket is Socket => {
diff --git a/src/Transport.d.ts b/src/Transport.d.ts
deleted file mode 100644
index 8f05346..0000000
--- a/src/Transport.d.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { Socket } from './Socket';
-
-export interface RecoveryOptions {
-	min_interval: number;
-	max_interval: number;
-}
-
-export class Transport {
-	constructor(sockets: Socket | Socket[], recovery_options?: RecoveryOptions);
-
-	get via_transport(): string;
-
-	get url(): string;
-
-	get sip_uri(): string;
-
-	connect(): void;
-
-	disconnect(): void;
-
-	send(data: string | ArrayBufferLike | Blob | ArrayBufferView): boolean;
-
-	isConnected(): boolean;
-
-	isConnecting(): boolean;
-
-	onconnecting(attempts: number): void;
-
-	onconnect(): void;
-
-	ondisconnect(error: boolean, code?: number, reason?: string): void;
-
-	ondata<T>(event: T): void;
-}
diff --git a/src/Transport.js b/src/Transport.js
deleted file mode 100644
index 008d335..0000000
--- a/src/Transport.js
+++ /dev/null
@@ -1,345 +0,0 @@
-const { Logger } = require('./Logger');
-const Socket = require('./Socket');
-const JsSIP_C = require('./Constants');
-
-const logger = new Logger('Transport');
-
-/**
- * Constants
- */
-const C = {
-	// Transport status.
-	STATUS_CONNECTED: 0,
-	STATUS_CONNECTING: 1,
-	STATUS_DISCONNECTED: 2,
-
-	// Socket status.
-	SOCKET_STATUS_READY: 0,
-	SOCKET_STATUS_ERROR: 1,
-
-	// Recovery options.
-	recovery_options: {
-		// minimum interval in seconds between recover attempts.
-		min_interval: JsSIP_C.CONNECTION_RECOVERY_MIN_INTERVAL,
-		// maximum interval in seconds between recover attempts.
-		max_interval: JsSIP_C.CONNECTION_RECOVERY_MAX_INTERVAL,
-	},
-};
-
-/*
- * Manages one or multiple JsSIP.Socket instances.
- * Is reponsible for transport recovery logic among all socket instances.
- *
- * @socket JsSIP::Socket instance
- */
-module.exports = class Transport {
-	constructor(sockets, recovery_options = C.recovery_options) {
-		logger.debug('new()');
-
-		this.status = C.STATUS_DISCONNECTED;
-
-		// Current socket.
-		this.socket = null;
-
-		// Socket collection.
-		this.sockets = [];
-
-		this.recovery_options = recovery_options;
-		this.recover_attempts = 0;
-		this.recovery_timer = null;
-
-		this.close_requested = false;
-
-		// It seems that TextDecoder is not available in some versions of React-Native.
-		// See https://github.com/versatica/JsSIP/issues/695
-		try {
-			this.textDecoder = new TextDecoder('utf8');
-		} catch (error) {
-			logger.warn(`cannot use TextDecoder: ${error}`);
-		}
-
-		if (typeof sockets === 'undefined') {
-			throw new TypeError("Invalid argument. undefined 'sockets' argument");
-		}
-
-		if (!(sockets instanceof Array)) {
-			sockets = [sockets];
-		}
-
-		sockets.forEach(function (socket) {
-			if (!Socket.isSocket(socket.socket)) {
-				throw new TypeError(
-					"Invalid argument. invalid 'JsSIP.Socket' instance"
-				);
-			}
-
-			if (socket.weight && !Number(socket.weight)) {
-				throw new TypeError(
-					"Invalid argument. 'weight' attribute is not a number"
-				);
-			}
-
-			this.sockets.push({
-				socket: socket.socket,
-				weight: socket.weight || 0,
-				status: C.SOCKET_STATUS_READY,
-			});
-		}, this);
-
-		// Get the socket with higher weight.
-		this._getSocket();
-	}
-
-	/**
-	 * Instance Methods
-	 */
-
-	get via_transport() {
-		return this.socket.via_transport;
-	}
-
-	get url() {
-		return this.socket.url;
-	}
-
-	get sip_uri() {
-		return this.socket.sip_uri;
-	}
-
-	connect() {
-		logger.debug('connect()');
-
-		if (this.isConnected()) {
-			logger.debug('Transport is already connected');
-
-			return;
-		} else if (this.isConnecting()) {
-			logger.debug('Transport is connecting');
-
-			return;
-		}
-
-		this.close_requested = false;
-		this.status = C.STATUS_CONNECTING;
-		this.onconnecting({ socket: this.socket, attempts: this.recover_attempts });
-
-		if (!this.close_requested) {
-			// Bind socket event callbacks.
-			this.socket.onconnect = this._onConnect.bind(this);
-			this.socket.ondisconnect = this._onDisconnect.bind(this);
-			this.socket.ondata = this._onData.bind(this);
-
-			this.socket.connect();
-		}
-
-		return;
-	}
-
-	disconnect() {
-		logger.debug('close()');
-
-		this.close_requested = true;
-		this.recover_attempts = 0;
-		this.status = C.STATUS_DISCONNECTED;
-
-		// Clear recovery_timer.
-		if (this.recovery_timer !== null) {
-			clearTimeout(this.recovery_timer);
-			this.recovery_timer = null;
-		}
-
-		// Unbind socket event callbacks.
-		this.socket.onconnect = () => {};
-		this.socket.ondisconnect = () => {};
-		this.socket.ondata = () => {};
-
-		this.socket.disconnect();
-		this.ondisconnect({
-			socket: this.socket,
-			error: false,
-		});
-	}
-
-	send(data) {
-		logger.debug('send()');
-
-		if (!this.isConnected()) {
-			logger.warn('unable to send message, transport is not connected');
-
-			return false;
-		}
-
-		const message = data.toString();
-
-		logger.debug(`sending message:\n\n${message}\n`);
-
-		return this.socket.send(message);
-	}
-
-	isConnected() {
-		return this.status === C.STATUS_CONNECTED;
-	}
-
-	isConnecting() {
-		return this.status === C.STATUS_CONNECTING;
-	}
-
-	/**
-	 * Private API.
-	 */
-
-	_reconnect() {
-		this.recover_attempts += 1;
-
-		let k = Math.floor(Math.random() * Math.pow(2, this.recover_attempts) + 1);
-
-		if (k < this.recovery_options.min_interval) {
-			k = this.recovery_options.min_interval;
-		} else if (k > this.recovery_options.max_interval) {
-			k = this.recovery_options.max_interval;
-		}
-
-		logger.debug(
-			`reconnection attempt: ${this.recover_attempts}. next connection attempt in ${k} seconds`
-		);
-
-		this.recovery_timer = setTimeout(() => {
-			if (
-				!this.close_requested &&
-				!(this.isConnected() || this.isConnecting())
-			) {
-				// Get the next available socket with higher weight.
-				this._getSocket();
-
-				// Connect the socket.
-				this.connect();
-			}
-		}, k * 1000);
-	}
-
-	/**
-	 * get the next available socket with higher weight
-	 */
-	_getSocket() {
-		let candidates = [];
-
-		this.sockets.forEach(socket => {
-			if (socket.status === C.SOCKET_STATUS_ERROR) {
-				return; // continue the array iteration
-			} else if (candidates.length === 0) {
-				candidates.push(socket);
-			} else if (socket.weight > candidates[0].weight) {
-				candidates = [socket];
-			} else if (socket.weight === candidates[0].weight) {
-				candidates.push(socket);
-			}
-		});
-
-		if (candidates.length === 0) {
-			// All sockets have failed. reset sockets status.
-			this.sockets.forEach(socket => {
-				socket.status = C.SOCKET_STATUS_READY;
-			});
-
-			// Get next available socket.
-			this._getSocket();
-
-			return;
-		}
-
-		const idx = Math.floor(Math.random() * candidates.length);
-
-		this.socket = candidates[idx].socket;
-	}
-
-	/**
-	 * Socket Event Handlers
-	 */
-
-	_onConnect() {
-		this.recover_attempts = 0;
-		this.status = C.STATUS_CONNECTED;
-
-		// Clear recovery_timer.
-		if (this.recovery_timer !== null) {
-			clearTimeout(this.recovery_timer);
-			this.recovery_timer = null;
-		}
-
-		this.onconnect({ socket: this });
-	}
-
-	_onDisconnect(error, code, reason) {
-		this.status = C.STATUS_DISCONNECTED;
-		this.ondisconnect({
-			socket: this.socket,
-			error,
-			code,
-			reason,
-		});
-
-		if (this.close_requested) {
-			return;
-		}
-
-		// Update socket status.
-		else {
-			this.sockets.forEach(function (socket) {
-				if (this.socket === socket.socket) {
-					socket.status = C.SOCKET_STATUS_ERROR;
-				}
-			}, this);
-		}
-
-		this._reconnect(error);
-	}
-
-	_onData(data) {
-		// CRLF Keep Alive request from server, reply.
-		if (data === '\r\n\r\n') {
-			logger.debug('received message with double-CRLF Keep Alive request');
-
-			try {
-				// Reply with single CRLF.
-				this.socket.send('\r\n');
-			} catch (error) {
-				logger.warn(`error sending Keep Alive response: ${error}`);
-			}
-
-			return;
-		}
-
-		// CRLF Keep Alive response from server, ignore it.
-		if (data === '\r\n') {
-			logger.debug('received message with CRLF Keep Alive response');
-
-			return;
-		}
-
-		// Binary message.
-		else if (typeof data !== 'string') {
-			try {
-				if (this.textDecoder) {
-					data = this.textDecoder.decode(data);
-				} else {
-					data = String.fromCharCode.apply(null, new Uint8Array(data));
-				}
-			} catch (error) {
-				logger.debug(
-					`received binary message failed to be converted into string: ${error}`
-				);
-
-				return;
-			}
-
-			logger.debug(`received binary message:\n\n${data}\n`);
-		}
-
-		// Text message.
-		else {
-			logger.debug(`received text message:\n\n${data}\n`);
-		}
-
-		this.ondata({ transport: this, message: data });
-	}
-};
diff --git a/src/Transport.ts b/src/Transport.ts
new file mode 100644
index 0000000..87f7d88
--- /dev/null
+++ b/src/Transport.ts
@@ -0,0 +1,412 @@
+import { Logger } from './Logger';
+import { isSocket } from './Socket';
+import type { Socket, WeightedSocket } from './Socket';
+import {
+	CONNECTION_RECOVERY_MAX_INTERVAL,
+	CONNECTION_RECOVERY_MIN_INTERVAL,
+} from './Constants';
+
+const logger = new Logger('Transport');
+
+/**
+ * Constants
+ */
+const C = {
+	// Transport status.
+	STATUS_CONNECTED: 0,
+	STATUS_CONNECTING: 1,
+	STATUS_DISCONNECTED: 2,
+
+	// Socket status.
+	SOCKET_STATUS_READY: 0,
+	SOCKET_STATUS_ERROR: 1,
+
+	// Recovery options.
+	recovery_options: {
+		// minimum interval in seconds between recover attempts.
+		min_interval: CONNECTION_RECOVERY_MIN_INTERVAL,
+		// maximum interval in seconds between recover attempts.
+		max_interval: CONNECTION_RECOVERY_MAX_INTERVAL,
+	},
+};
+
+export type RecoveryOptions = {
+	min_interval: number;
+	max_interval: number;
+};
+
+/**
+ * A socket of the collection, along with its weight and current status.
+ */
+export type TransportSocket = {
+	socket: Socket;
+	weight: number;
+	status: number;
+};
+
+export type TransportConnectingEvent = {
+	socket: Socket;
+	attempts: number;
+};
+
+/**
+ * NOTE: `socket` holds the `Transport` itself, not the underlying socket.
+ */
+export type TransportConnectedEvent = {
+	socket: Transport;
+};
+
+export type TransportDisconnectedEvent = {
+	socket: Socket;
+	error: boolean;
+	code?: number;
+	reason?: string;
+};
+
+export type TransportDataEvent = {
+	transport: Transport;
+	message: string;
+};
+
+/*
+ * Manages one or multiple JsSIP.Socket instances.
+ * Is reponsible for transport recovery logic among all socket instances.
+ *
+ * @socket JsSIP::Socket instance
+ */
+export class Transport {
+	#status: number;
+	// Current socket.
+	#socket: Socket;
+	// Socket collection.
+	#sockets: TransportSocket[] = [];
+	#recovery_options: RecoveryOptions;
+	#recover_attempts = 0;
+	#recovery_timer?: ReturnType<typeof setTimeout>;
+	#close_requested = false;
+	#textDecoder?: TextDecoder;
+
+	// NOTE: These are assigned by `UA` once the transport is in use.
+	declare onconnecting: (event: TransportConnectingEvent) => void;
+	declare onconnect: (event: TransportConnectedEvent) => void;
+	declare ondisconnect: (event: TransportDisconnectedEvent) => void;
+	declare ondata: (event: TransportDataEvent) => void;
+
+	constructor(
+		sockets: WeightedSocket | WeightedSocket[],
+		recovery_options: RecoveryOptions = C.recovery_options
+	) {
+		logger.debug('new()');
+
+		this.#status = C.STATUS_DISCONNECTED;
+		this.#recovery_options = recovery_options;
+
+		// It seems that TextDecoder is not available in some versions of React-Native.
+		// See https://github.com/versatica/JsSIP/issues/695
+		try {
+			this.#textDecoder = new TextDecoder('utf8');
+		} catch (error) {
+			logger.warn(`cannot use TextDecoder: ${error}`);
+		}
+
+		if (typeof sockets === 'undefined') {
+			throw new TypeError("Invalid argument. undefined 'sockets' argument");
+		}
+
+		if (!(sockets instanceof Array)) {
+			sockets = [sockets];
+		}
+
+		sockets.forEach(socket => {
+			if (!isSocket(socket.socket)) {
+				throw new TypeError(
+					"Invalid argument. invalid 'JsSIP.Socket' instance"
+				);
+			}
+
+			if (socket.weight && !Number(socket.weight)) {
+				throw new TypeError(
+					"Invalid argument. 'weight' attribute is not a number"
+				);
+			}
+
+			this.#sockets.push({
+				socket: socket.socket,
+				weight: socket.weight ?? 0,
+				status: C.SOCKET_STATUS_READY,
+			});
+		});
+
+		// Get the socket with higher weight.
+		this.#socket = this.#getSocket();
+	}
+
+	/**
+	 * Instance Methods
+	 */
+
+	get via_transport(): string {
+		return this.#socket.via_transport;
+	}
+
+	get url(): string {
+		return this.#socket.url;
+	}
+
+	get sip_uri(): string {
+		return this.#socket.sip_uri;
+	}
+
+	get sockets(): TransportSocket[] {
+		return this.#sockets;
+	}
+
+	get recovery_options(): RecoveryOptions {
+		return this.#recovery_options;
+	}
+
+	connect(): void {
+		logger.debug('connect()');
+
+		if (this.isConnected()) {
+			logger.debug('Transport is already connected');
+
+			return;
+		} else if (this.isConnecting()) {
+			logger.debug('Transport is connecting');
+
+			return;
+		}
+
+		this.#close_requested = false;
+		this.#status = C.STATUS_CONNECTING;
+		this.onconnecting({
+			socket: this.#socket,
+			attempts: this.#recover_attempts,
+		});
+
+		if (!this.#close_requested) {
+			// Bind socket event callbacks.
+			this.#socket.onconnect = this.#onConnect.bind(this);
+			this.#socket.ondisconnect = this.#onDisconnect.bind(this);
+			this.#socket.ondata = this.#onData.bind(this);
+
+			this.#socket.connect();
+		}
+
+		return;
+	}
+
+	disconnect(): void {
+		logger.debug('close()');
+
+		this.#close_requested = true;
+		this.#recover_attempts = 0;
+		this.#status = C.STATUS_DISCONNECTED;
+
+		// Clear recovery_timer.
+		if (this.#recovery_timer !== undefined) {
+			clearTimeout(this.#recovery_timer);
+			this.#recovery_timer = undefined;
+		}
+
+		// Unbind socket event callbacks.
+		this.#socket.onconnect = () => {};
+		this.#socket.ondisconnect = () => {};
+		this.#socket.ondata = () => {};
+
+		this.#socket.disconnect();
+		this.ondisconnect({
+			socket: this.#socket,
+			error: false,
+		});
+	}
+
+	send(data: { toString(): string }): boolean {
+		logger.debug('send()');
+
+		if (!this.isConnected()) {
+			logger.warn('unable to send message, transport is not connected');
+
+			return false;
+		}
+
+		const message = data.toString();
+
+		logger.debug(`sending message:\n\n${message}\n`);
+
+		return this.#socket.send(message);
+	}
+
+	isConnected(): boolean {
+		return this.#status === C.STATUS_CONNECTED;
+	}
+
+	isConnecting(): boolean {
+		return this.#status === C.STATUS_CONNECTING;
+	}
+
+	/**
+	 * Private API.
+	 */
+
+	#reconnect(): void {
+		this.#recover_attempts += 1;
+
+		let k = Math.floor(Math.random() * Math.pow(2, this.#recover_attempts) + 1);
+
+		if (k < this.#recovery_options.min_interval) {
+			k = this.#recovery_options.min_interval;
+		} else if (k > this.#recovery_options.max_interval) {
+			k = this.#recovery_options.max_interval;
+		}
+
+		logger.debug(
+			`reconnection attempt: ${this.#recover_attempts}. next connection attempt in ${k} seconds`
+		);
+
+		this.#recovery_timer = setTimeout(() => {
+			if (
+				!this.#close_requested &&
+				!(this.isConnected() || this.isConnecting())
+			) {
+				// Get the next available socket with higher weight.
+				this.#socket = this.#getSocket();
+
+				// Connect the socket.
+				this.connect();
+			}
+		}, k * 1000);
+	}
+
+	/**
+	 * get the next available socket with higher weight
+	 */
+	#getSocket(): Socket {
+		let candidates: TransportSocket[] = [];
+
+		this.#sockets.forEach(socket => {
+			if (socket.status === C.SOCKET_STATUS_ERROR) {
+				return; // continue the array iteration
+			} else if (candidates.length === 0) {
+				candidates.push(socket);
+			} else if (socket.weight > candidates[0]!.weight) {
+				candidates = [socket];
+			} else if (socket.weight === candidates[0]!.weight) {
+				candidates.push(socket);
+			}
+		});
+
+		if (candidates.length === 0) {
+			// All sockets have failed. reset sockets status.
+			this.#sockets.forEach(socket => {
+				socket.status = C.SOCKET_STATUS_READY;
+			});
+
+			// Get next available socket.
+			return this.#getSocket();
+		}
+
+		const idx = Math.floor(Math.random() * candidates.length);
+
+		return candidates[idx]!.socket;
+	}
+
+	/**
+	 * Socket Event Handlers
+	 */
+
+	#onConnect(): void {
+		this.#recover_attempts = 0;
+		this.#status = C.STATUS_CONNECTED;
+
+		// Clear recovery_timer.
+		if (this.#recovery_timer !== undefined) {
+			clearTimeout(this.#recovery_timer);
+			this.#recovery_timer = undefined;
+		}
+
+		this.onconnect({ socket: this });
+	}
+
+	#onDisconnect(error: boolean, code?: number, reason?: string): void {
+		this.#status = C.STATUS_DISCONNECTED;
+		this.ondisconnect({
+			socket: this.#socket,
+			error,
+			code,
+			reason,
+		});
+
+		if (this.#close_requested) {
+			return;
+		}
+
+		// Update socket status.
+		else {
+			this.#sockets.forEach(socket => {
+				if (this.#socket === socket.socket) {
+					socket.status = C.SOCKET_STATUS_ERROR;
+				}
+			});
+		}
+
+		this.#reconnect();
+	}
+
+	#onData(data: string | ArrayBuffer): void {
+		// CRLF Keep Alive request from server, reply.
+		if (data === '\r\n\r\n') {
+			logger.debug('received message with double-CRLF Keep Alive request');
+
+			try {
+				// Reply with single CRLF.
+				this.#socket.send('\r\n');
+			} catch (error) {
+				logger.warn(`error sending Keep Alive response: ${error}`);
+			}
+
+			return;
+		}
+
+		// CRLF Keep Alive response from server, ignore it.
+		if (data === '\r\n') {
+			logger.debug('received message with CRLF Keep Alive response');
+
+			return;
+		}
+
+		let message: string;
+
+		// Binary message.
+		if (typeof data !== 'string') {
+			try {
+				if (this.#textDecoder) {
+					message = this.#textDecoder.decode(data);
+				} else {
+					message = String.fromCharCode.apply(
+						null,
+						Array.from(new Uint8Array(data))
+					);
+				}
+			} catch (error) {
+				logger.debug(
+					`received binary message failed to be converted into string: ${error}`
+				);
+
+				return;
+			}
+
+			logger.debug(`received binary message:\n\n${message}\n`);
+		}
+
+		// Text message.
+		else {
+			message = data;
+
+			logger.debug(`received text message:\n\n${message}\n`);
+		}
+
+		this.ondata({ transport: this, message });
+	}
+}
diff --git a/src/UA.js b/src/UA.js
index 2c51bb6..e330e34 100644
--- a/src/UA.js
+++ b/src/UA.js
@@ -10,7 +10,7 @@ const Notifier = require('./Notifier');
 const Message = require('./Message');
 const Options = require('./Options');
 const Transactions = require('./Transactions');
-const Transport = require('./Transport');
+const { Transport } = require('./Transport');
 const Utils = require('./Utils');
 const Exceptions = require('./Exceptions');
 const URI = require('./URI');
diff --git a/src/WebSocketInterface.ts b/src/WebSocketInterface.ts
index 733f824..33d2315 100644
--- a/src/WebSocketInterface.ts
+++ b/src/WebSocketInterface.ts
@@ -17,7 +17,7 @@ export class WebSocketInterface implements Socket {
 		code?: number,
 		reason?: string
 	) => void;
-	declare ondata: <T>(event: T) => void;
+	declare ondata: (data: string | ArrayBuffer) => void;

 	constructor(url: string) {
 		logger.debug('new() [url:"%s"]', url);