From d48bf5e707f48dce1a7e43d93ebeed75e67ebd04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Luis=20Mill=C3=A1n?= Date: Wed, 21 Oct 2026 11:41:35 +0200 Subject: [PATCH] TS: RequestSender 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. --- src/Dialog/RequestSender.js | 2 +- src/Message.js | 2 +- src/Options.js | 2 +- src/RTCSession.js | 2 +- src/Registrator.js | 2 +- src/RequestSender.js | 182 -------------------------- src/RequestSender.ts | 248 ++++++++++++++++++++++++++++++++++++ src/Subscriber.js | 2 +- src/Transactions.ts | 20 ++- 9 files changed, 262 insertions(+), 200 deletions(-) delete mode 100644 src/RequestSender.js create mode 100644 src/RequestSender.ts diff --git a/src/Dialog/RequestSender.js b/src/Dialog/RequestSender.js index 95ab5b10..d846302c 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 1c15b7db..94b94b99 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 07a70de7..ff9be47c 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 f6c8b8b2..654976ed 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 00851398..1dd71414 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 1b6e1985..00000000 --- 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 00000000..8d267b78 --- /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 + ) { + 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 50bb0b80..99a10d99 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 b94d9ea5..b3c3bf07 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; @@ -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; @@ -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; @@ -574,8 +570,8 @@ export class InviteServerTransaction extends EventEmitter { state: Status; resendProvisionalTimer: ReturnType | 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;