Commit 19f14c6 for jssip.net

commit 19f14c6d5be10dd40966906f9ebb2008eaa92fef
Author: José Luis Millán <jmillan@aliax.net>
Date:   Wed Oct 21 11:53:13 2026 +0200

    TS: RequestSender (#991)

    Also fix, onTransportError() was always being called when UA was closed, even for BYE and ACK. Check comment:

    // If ua is in closing process or even closed just allow sending Bye and ACK.

diff --git a/src/Dialog/RequestSender.js b/src/Dialog/RequestSender.js
index 95ab5b1..d846302 100644
--- a/src/Dialog/RequestSender.js
+++ b/src/Dialog/RequestSender.js
@@ -1,6 +1,6 @@
 const JsSIP_C = require('../Constants');
 const Transactions = require('../Transactions');
-const RequestSender = require('../RequestSender');
+const { RequestSender } = require('../RequestSender');

 // Default event handlers.
 const EventHandlers = {
diff --git a/src/Message.js b/src/Message.js
index 1c15b7d..94b94b9 100644
--- a/src/Message.js
+++ b/src/Message.js
@@ -3,7 +3,7 @@ const { Logger } = require('./Logger');
 const JsSIP_C = require('./Constants');
 const SIPMessage = require('./SIPMessage');
 const Utils = require('./Utils');
-const RequestSender = require('./RequestSender');
+const { RequestSender } = require('./RequestSender');
 const Exceptions = require('./Exceptions');
 const URI = require('./URI');

diff --git a/src/Options.js b/src/Options.js
index 07a70de..ff9be47 100644
--- a/src/Options.js
+++ b/src/Options.js
@@ -3,7 +3,7 @@ const { Logger } = require('./Logger');
 const JsSIP_C = require('./Constants');
 const SIPMessage = require('./SIPMessage');
 const Utils = require('./Utils');
-const RequestSender = require('./RequestSender');
+const { RequestSender } = require('./RequestSender');
 const Exceptions = require('./Exceptions');

 const logger = new Logger('Options');
diff --git a/src/RTCSession.js b/src/RTCSession.js
index f6c8b8b..654976e 100644
--- a/src/RTCSession.js
+++ b/src/RTCSession.js
@@ -11,7 +11,7 @@ const Utils = require('./Utils');
 const Timers = require('./Timers');
 const SIPMessage = require('./SIPMessage');
 const Dialog = require('./Dialog');
-const RequestSender = require('./RequestSender');
+const { RequestSender } = require('./RequestSender');
 const RTCSession_DTMF = require('./RTCSession/DTMF');
 const RTCSession_Info = require('./RTCSession/Info');
 const RTCSession_ReferNotifier = require('./RTCSession/ReferNotifier');
diff --git a/src/Registrator.js b/src/Registrator.js
index 0085139..1dd7141 100644
--- a/src/Registrator.js
+++ b/src/Registrator.js
@@ -2,7 +2,7 @@ const { Logger } = require('./Logger');
 const Utils = require('./Utils');
 const JsSIP_C = require('./Constants');
 const SIPMessage = require('./SIPMessage');
-const RequestSender = require('./RequestSender');
+const { RequestSender } = require('./RequestSender');

 const logger = new Logger('Registrator');

diff --git a/src/RequestSender.js b/src/RequestSender.js
deleted file mode 100644
index 1b6e198..0000000
--- a/src/RequestSender.js
+++ /dev/null
@@ -1,182 +0,0 @@
-const { Logger } = require('./Logger');
-const JsSIP_C = require('./Constants');
-const { DigestAuthentication } = require('./DigestAuthentication');
-const Transactions = require('./Transactions');
-
-const logger = new Logger('RequestSender');
-
-// Default event handlers.
-const EventHandlers = {
-	onRequestTimeout: () => {},
-	onTransportError: () => {},
-	onReceiveResponse: () => {},
-	onAuthenticated: () => {},
-};
-
-module.exports = class RequestSender {
-	constructor(ua, request, eventHandlers) {
-		this._ua = ua;
-		this._eventHandlers = eventHandlers;
-		this._method = request.method;
-		this._request = request;
-		this._auth = null;
-		this._challenged = false;
-		this._staled = false;
-
-		// Define the undefined handlers.
-		for (const handler in EventHandlers) {
-			if (Object.prototype.hasOwnProperty.call(EventHandlers, handler)) {
-				if (!this._eventHandlers[handler]) {
-					this._eventHandlers[handler] = EventHandlers[handler];
-				}
-			}
-		}
-
-		// If ua is in closing process or even closed just allow sending Bye and ACK.
-		if (
-			ua.status === ua.C.STATUS_USER_CLOSED &&
-			(this._method !== JsSIP_C.BYE || this._method !== JsSIP_C.ACK)
-		) {
-			this._eventHandlers.onTransportError();
-		}
-	}
-
-	/**
-	 * Create the client transaction and send the message.
-	 */
-	send() {
-		const eventHandlers = {
-			onRequestTimeout: () => {
-				this._eventHandlers.onRequestTimeout();
-			},
-			onTransportError: () => {
-				this._eventHandlers.onTransportError();
-			},
-			onReceiveResponse: response => {
-				this._receiveResponse(response);
-			},
-		};
-
-		switch (this._method) {
-			case 'INVITE': {
-				this.clientTransaction = new Transactions.InviteClientTransaction(
-					this._ua,
-					this._ua.transport,
-					this._request,
-					eventHandlers
-				);
-				break;
-			}
-			case 'ACK': {
-				this.clientTransaction = new Transactions.AckClientTransaction(
-					this._ua,
-					this._ua.transport,
-					this._request,
-					eventHandlers
-				);
-				break;
-			}
-			default: {
-				this.clientTransaction = new Transactions.NonInviteClientTransaction(
-					this._ua,
-					this._ua.transport,
-					this._request,
-					eventHandlers
-				);
-			}
-		}
-		// If authorization JWT is present, use it.
-		if (this._ua._configuration.authorization_jwt) {
-			this._request.setHeader(
-				'Authorization',
-				this._ua._configuration.authorization_jwt
-			);
-		}
-
-		this.clientTransaction.send();
-	}
-
-	/**
-	 * Called from client transaction when receiving a correct response to the request.
-	 * Authenticate request if needed or pass the response back to the applicant.
-	 */
-	_receiveResponse(response) {
-		let challenge;
-		let authorization_header_name;
-		const status_code = response.status_code;
-
-		/*
-		 * Authentication
-		 * Authenticate once. _challenged_ flag used to avoid infinite authentications.
-		 */
-		if (
-			(status_code === 401 || status_code === 407) &&
-			(this._ua.configuration.password !== null ||
-				this._ua.configuration.ha1 !== null)
-		) {
-			// Get and parse the appropriate WWW-Authenticate or Proxy-Authenticate header.
-			if (response.status_code === 401) {
-				challenge = response.parseHeader('www-authenticate');
-				authorization_header_name = 'authorization';
-			} else {
-				challenge = response.parseHeader('proxy-authenticate');
-				authorization_header_name = 'proxy-authorization';
-			}
-
-			// Verify it seems a valid challenge.
-			if (!challenge) {
-				logger.debug(
-					`${response.status_code} with wrong or missing challenge, cannot authenticate`
-				);
-				this._eventHandlers.onReceiveResponse(response);
-
-				return;
-			}
-
-			if (!this._challenged || (!this._staled && challenge.stale === true)) {
-				if (!this._auth) {
-					this._auth = new DigestAuthentication({
-						username: this._ua.configuration.authorization_user,
-						password: this._ua.configuration.password,
-						realm: this._ua.configuration.realm,
-						ha1: this._ua.configuration.ha1,
-					});
-				}
-
-				// Verify that the challenge is really valid.
-				if (!this._auth.authenticate(this._request, challenge)) {
-					this._eventHandlers.onReceiveResponse(response);
-
-					return;
-				}
-				this._challenged = true;
-
-				// Update ha1 and realm in the UA.
-				this._ua.set('realm', this._auth.get('realm'));
-				this._ua.set('ha1', this._auth.get('ha1'));
-
-				if (challenge.stale) {
-					this._staled = true;
-				}
-
-				this._request = this._request.clone();
-				this._request.cseq += 1;
-				this._request.setHeader(
-					'cseq',
-					`${this._request.cseq} ${this._method}`
-				);
-				this._request.setHeader(
-					authorization_header_name,
-					this._auth.toString()
-				);
-
-				this._eventHandlers.onAuthenticated(this._request);
-				this.send();
-			} else {
-				this._eventHandlers.onReceiveResponse(response);
-			}
-		} else {
-			this._eventHandlers.onReceiveResponse(response);
-		}
-	}
-};
diff --git a/src/RequestSender.ts b/src/RequestSender.ts
new file mode 100644
index 0000000..8d267b7
--- /dev/null
+++ b/src/RequestSender.ts
@@ -0,0 +1,248 @@
+import * as JsSIP_C from './Constants';
+import { DigestAuthentication } from './DigestAuthentication';
+import type { DigestChallenge } from './DigestAuthentication';
+import { Logger } from './Logger';
+import type { IncomingResponse, OutgoingRequest } from './SIPMessage';
+import * as Transactions from './Transactions';
+
+const logger = new Logger('RequestSender');
+
+/**
+ * The `UA` surface a request sender depends on.
+ *
+ * TODO: Replace with `import type { UA } from './UA'` once `UA` is ported to
+ * TypeScript.
+ */
+export interface RequestSenderUA extends Transactions.TransactionUA {
+	readonly C: { STATUS_USER_CLOSED: number };
+	readonly status: number;
+	readonly configuration: Transactions.TransactionUA['configuration'] & {
+		authorization_user: string | null;
+		password: string | null;
+		realm: string | null;
+		ha1: string | null;
+		authorization_jwt: string | null;
+	};
+
+	set(parameter: string, value: unknown): boolean;
+}
+
+/**
+ * The callbacks a request sender reports to the applicant. Every one of them is
+ * optional; the ones left out fall back to `DEFAULT_EVENT_HANDLERS`.
+ */
+export interface RequestSenderEventHandlers {
+	onRequestTimeout(): void;
+	onTransportError(): void;
+	onReceiveResponse(response: IncomingResponse): void;
+	onAuthenticated(request: OutgoingRequest): void;
+}
+
+/**
+ * The transaction an outgoing request is sent over. Note that
+ * `AckClientTransaction` is not a real transaction and so, unlike the other
+ * two, has no `state`.
+ */
+export type ClientTransaction =
+	| Transactions.NonInviteClientTransaction
+	| Transactions.InviteClientTransaction
+	| Transactions.AckClientTransaction;
+
+// Default event handlers.
+const DEFAULT_EVENT_HANDLERS: RequestSenderEventHandlers = {
+	onRequestTimeout: () => {},
+	onTransportError: () => {},
+	onReceiveResponse: () => {},
+	onAuthenticated: () => {},
+};
+
+export class RequestSender {
+	/* NOTE: Not set by the constructor. Set by `send()`, which every applicant
+	 * calls right after construction.
+	 */
+	clientTransaction?: ClientTransaction;
+
+	#ua: RequestSenderUA;
+	#eventHandlers: RequestSenderEventHandlers;
+	#method: string;
+	#request: OutgoingRequest;
+	#auth: DigestAuthentication | null = null;
+	#challenged = false;
+	#staled = false;
+
+	constructor(
+		ua: RequestSenderUA,
+		request: OutgoingRequest,
+		eventHandlers: Partial<RequestSenderEventHandlers>
+	) {
+		this.#ua = ua;
+		this.#method = request.method;
+		this.#request = request;
+
+		/* NOTE: Fills the given object in place, as the original implementation
+		 * does, so the applicant sees the defaults too.
+		 */
+		this.#eventHandlers = Object.assign(eventHandlers, {
+			onRequestTimeout:
+				eventHandlers.onRequestTimeout ||
+				DEFAULT_EVENT_HANDLERS.onRequestTimeout,
+			onTransportError:
+				eventHandlers.onTransportError ||
+				DEFAULT_EVENT_HANDLERS.onTransportError,
+			onReceiveResponse:
+				eventHandlers.onReceiveResponse ||
+				DEFAULT_EVENT_HANDLERS.onReceiveResponse,
+			onAuthenticated:
+				eventHandlers.onAuthenticated || DEFAULT_EVENT_HANDLERS.onAuthenticated,
+		});
+
+		// If ua is in closing process or even closed just allow sending Bye and ACK.
+		if (
+			ua.status === ua.C.STATUS_USER_CLOSED &&
+			this.#method !== JsSIP_C.BYE &&
+			this.#method !== JsSIP_C.ACK
+		) {
+			this.#eventHandlers.onTransportError();
+		}
+	}
+
+	/**
+	 * Create the client transaction and send the message.
+	 */
+	send(): void {
+		const eventHandlers: Transactions.ClientTransactionEventHandlers = {
+			onRequestTimeout: () => {
+				this.#eventHandlers.onRequestTimeout();
+			},
+			onTransportError: () => {
+				this.#eventHandlers.onTransportError();
+			},
+			onReceiveResponse: response => {
+				this.#receiveResponse(response);
+			},
+		};
+
+		switch (this.#method) {
+			case 'INVITE': {
+				this.clientTransaction = new Transactions.InviteClientTransaction(
+					this.#ua,
+					this.#ua.transport,
+					this.#request,
+					eventHandlers
+				);
+				break;
+			}
+			case 'ACK': {
+				this.clientTransaction = new Transactions.AckClientTransaction(
+					this.#ua,
+					this.#ua.transport,
+					this.#request,
+					eventHandlers
+				);
+				break;
+			}
+			default: {
+				this.clientTransaction = new Transactions.NonInviteClientTransaction(
+					this.#ua,
+					this.#ua.transport,
+					this.#request,
+					eventHandlers
+				);
+			}
+		}
+
+		// If authorization JWT is present, use it.
+		if (this.#ua.configuration.authorization_jwt) {
+			this.#request.setHeader(
+				'Authorization',
+				this.#ua.configuration.authorization_jwt
+			);
+		}
+
+		this.clientTransaction.send();
+	}
+
+	/**
+	 * Called from client transaction when receiving a correct response to the request.
+	 * Authenticate request if needed or pass the response back to the applicant.
+	 */
+	#receiveResponse(response: IncomingResponse): void {
+		let challenge: DigestChallenge | undefined;
+		let authorization_header_name: string;
+		const status_code = response.status_code;
+
+		/*
+		 * Authentication
+		 * Authenticate once. _challenged_ flag used to avoid infinite authentications.
+		 */
+		if (
+			(status_code === 401 || status_code === 407) &&
+			(this.#ua.configuration.password !== null ||
+				this.#ua.configuration.ha1 !== null)
+		) {
+			// Get and parse the appropriate WWW-Authenticate or Proxy-Authenticate header.
+			if (response.status_code === 401) {
+				challenge = response.parseHeader('www-authenticate');
+				authorization_header_name = 'authorization';
+			} else {
+				challenge = response.parseHeader('proxy-authenticate');
+				authorization_header_name = 'proxy-authorization';
+			}
+
+			// Verify it seems a valid challenge.
+			if (!challenge) {
+				logger.debug(
+					`${response.status_code} with wrong or missing challenge, cannot authenticate`
+				);
+				this.#eventHandlers.onReceiveResponse(response);
+
+				return;
+			}
+
+			if (!this.#challenged || (!this.#staled && challenge.stale === true)) {
+				if (!this.#auth) {
+					this.#auth = new DigestAuthentication({
+						username: this.#ua.configuration.authorization_user,
+						password: this.#ua.configuration.password,
+						realm: this.#ua.configuration.realm,
+						ha1: this.#ua.configuration.ha1,
+					});
+				}
+
+				// Verify that the challenge is really valid.
+				if (!this.#auth.authenticate(this.#request, challenge)) {
+					this.#eventHandlers.onReceiveResponse(response);
+
+					return;
+				}
+				this.#challenged = true;
+
+				// Update ha1 and realm in the UA.
+				this.#ua.set('realm', this.#auth.get('realm'));
+				this.#ua.set('ha1', this.#auth.get('ha1'));
+
+				if (challenge.stale) {
+					this.#staled = true;
+				}
+
+				this.#request = this.#request.clone();
+				this.#request.cseq += 1;
+				this.#request.setHeader(
+					'cseq',
+					`${this.#request.cseq} ${this.#method}`
+				);
+				this.#request.setHeader(
+					authorization_header_name,
+					this.#auth.toString()
+				);
+
+				this.#eventHandlers.onAuthenticated(this.#request);
+				this.send();
+			} else {
+				this.#eventHandlers.onReceiveResponse(response);
+			}
+		} else {
+			this.#eventHandlers.onReceiveResponse(response);
+		}
+	}
+}
diff --git a/src/Subscriber.js b/src/Subscriber.js
index 50bb0b8..99a10d9 100644
--- a/src/Subscriber.js
+++ b/src/Subscriber.js
@@ -5,7 +5,7 @@ const JsSIP_C = require('./Constants');
 const Utils = require('./Utils');
 const Grammar = require('./Grammar');
 const SIPMessage = require('./SIPMessage');
-const RequestSender = require('./RequestSender');
+const { RequestSender } = require('./RequestSender');
 const Dialog = require('./Dialog');

 const logger = new Logger('Subscriber');
diff --git a/src/Transactions.ts b/src/Transactions.ts
index b94d9ea..b3c3bf0 100644
--- a/src/Transactions.ts
+++ b/src/Transactions.ts
@@ -89,10 +89,8 @@ export class NonInviteClientTransaction extends EventEmitter {
 	request: SIPMessage.OutgoingRequest;
 	eventHandlers: ClientTransactionEventHandlers;

-	/* NOTE: `declare`d (and so not emitted) because the original implementation
-	 * does not create these own properties in the constructor. `state` is
-	 * created by `stateChanged()` and the timer handles when their timers are
-	 * armed.
+	/* NOTE: Not set by the constructor. `state` is set by `stateChanged()` and
+	 * the timer handles when their timers are armed.
 	 */
 	state?: Status;
 	F?: ReturnType<typeof setTimeout>;
@@ -207,10 +205,8 @@ export class InviteClientTransaction extends EventEmitter {
 	request: SIPMessage.OutgoingRequest;
 	eventHandlers: ClientTransactionEventHandlers;

-	/* NOTE: `declare`d (and so not emitted) because the original implementation
-	 * does not create these own properties in the constructor. `state` is
-	 * created by `stateChanged()` and the timer handles when their timers are
-	 * armed.
+	/* NOTE: Not set by the constructor. `state` is set by `stateChanged()` and
+	 * the timer handles when their timers are armed.
 	 */
 	state?: Status;
 	B?: ReturnType<typeof setTimeout>;
@@ -453,8 +449,8 @@ export class NonInviteServerTransaction extends EventEmitter {
 	last_response: string;
 	state: Status;

-	/* NOTE: `declare`d (and so not emitted) because the original implementation
-	 * does not create these own properties in the constructor.
+	/* NOTE: Not set by the constructor. `transportError` is set by
+	 * `onTransportError()` and `J` when its timer is armed.
 	 */
 	transportError?: boolean;
 	J?: ReturnType<typeof setTimeout>;
@@ -574,8 +570,8 @@ export class InviteServerTransaction extends EventEmitter {
 	state: Status;
 	resendProvisionalTimer: ReturnType<typeof setInterval> | null;

-	/* NOTE: `declare`d (and so not emitted) because the original implementation
-	 * does not create these own properties in the constructor.
+	/* NOTE: Not set by the constructor. `transportError` is set by
+	 * `onTransportError()` and the timer handles when their timers are armed.
 	 */
 	transportError?: boolean;
 	H?: ReturnType<typeof setTimeout>;