From 8cc163d8bbe10bbde9a54c9868683db575bd5316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Luis=20Mill=C3=A1n?= Date: Tue, 20 Oct 2026 12:58:03 +0200 Subject: [PATCH 1/4] TS: move SIPMessage --- src/SIPMessage.d.ts | 52 --- src/SIPMessage.js | 684 -------------------------------- src/SIPMessage.ts | 944 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 944 insertions(+), 736 deletions(-) delete mode 100644 src/SIPMessage.d.ts delete mode 100644 src/SIPMessage.js create mode 100644 src/SIPMessage.ts diff --git a/src/SIPMessage.d.ts b/src/SIPMessage.d.ts deleted file mode 100644 index 40da3cd0..00000000 --- a/src/SIPMessage.d.ts +++ /dev/null @@ -1,52 +0,0 @@ -import NameAddrHeader = require('./NameAddrHeader'); -import URI = require('./URI'); - -declare class IncomingMessage { - method: string; - from: NameAddrHeader; - to: NameAddrHeader; - body: string; - - constructor(); - - countHeader(name: string): number; - - getHeader(name: string): string; - - getHeaders(name: string): string[]; - - hasHeader(name: string): boolean; - - parseHeader(name: string, idx?: number): T; - - toString(): string; -} - -export class IncomingRequest extends IncomingMessage { - ruri: URI; -} - -export class IncomingResponse extends IncomingMessage { - status_code: number; - reason_phrase: string; -} - -export class OutgoingRequest { - method: string; - ruri: URI; - cseq: number; - call_id: string; - from: NameAddrHeader; - to: NameAddrHeader; - body: string; - - setHeader(name: string, value: string | string[]): void; - - getHeader(name: string): string; - - getHeaders(name: string): string[]; - - hasHeader(name: string): boolean; - - toString(): string; -} diff --git a/src/SIPMessage.js b/src/SIPMessage.js deleted file mode 100644 index 44b08e7f..00000000 --- a/src/SIPMessage.js +++ /dev/null @@ -1,684 +0,0 @@ -const sdp_transform = require('sdp-transform'); -const { Logger } = require('./Logger'); -const JsSIP_C = require('./Constants'); -const Utils = require('./Utils'); -const NameAddrHeader = require('./NameAddrHeader'); -const Grammar = require('./Grammar'); - -const logger = new Logger('SIPMessage'); - -/** - * -param {String} method request method - * -param {String} ruri request uri - * -param {UA} ua - * -param {Object} params parameters that will have priority over ua.configuration parameters: - *
- * - cseq, call_id, from_tag, from_uri, from_display_name, to_uri, to_tag, route_set - * -param {Object} [headers] extra headers - * -param {String} [body] - */ -class OutgoingRequest { - constructor(method, ruri, ua, params, extraHeaders, body) { - // Mandatory parameters check. - if (!method || !ruri || !ua) { - return null; - } - - params = params || {}; - - this.ua = ua; - this.headers = {}; - this.method = method; - this.ruri = ruri; - this.body = body; - this.extraHeaders = Utils.cloneArray(extraHeaders); - - if (this.ua.configuration.extra_headers) { - this.extraHeaders = this.extraHeaders.concat( - this.ua.configuration.extra_headers - ); - } - - // Fill the Common SIP Request Headers. - - // Route. - if (params.route_set) { - this.setHeader('route', params.route_set); - } else if (ua.configuration.use_preloaded_route) { - this.setHeader('route', `<${ua.transport.sip_uri};lr>`); - } - - // Via. - // Empty Via header. Will be filled by the client transaction. - this.setHeader('via', ''); - - // Max-Forwards. - this.setHeader('max-forwards', JsSIP_C.MAX_FORWARDS); - - // To - const to_uri = params.to_uri || ruri; - const to_params = params.to_tag ? { tag: params.to_tag } : null; - const to_display_name = - typeof params.to_display_name !== 'undefined' - ? params.to_display_name - : null; - - this.to = new NameAddrHeader(to_uri, to_display_name, to_params); - this.setHeader('to', this.to.toString()); - - // From. - const from_uri = params.from_uri || ua.configuration.uri; - const from_params = { tag: params.from_tag || Utils.newTag() }; - let display_name; - - if (typeof params.from_display_name !== 'undefined') { - display_name = params.from_display_name; - } else if (ua.configuration.display_name) { - display_name = ua.configuration.display_name; - } else { - display_name = null; - } - - this.from = new NameAddrHeader(from_uri, display_name, from_params); - this.setHeader('from', this.from.toString()); - - // Call-ID. - const call_id = - params.call_id || ua.configuration.jssip_id + Utils.createRandomToken(15); - - this.call_id = call_id; - this.setHeader('call-id', call_id); - - // CSeq. - const cseq = params.cseq || Math.floor(Math.random() * 10000); - - this.cseq = cseq; - this.setHeader('cseq', `${cseq} ${method}`); - } - - /** - * Replace the the given header by the given value. - * -param {String} name header name - * -param {String | Array} value header value - */ - setHeader(name, value) { - // Remove the header from extraHeaders if present. - const regexp = new RegExp(`^\\s*${name}\\s*:`, 'i'); - - for (let idx = 0; idx < this.extraHeaders.length; idx++) { - if (regexp.test(this.extraHeaders[idx])) { - this.extraHeaders.splice(idx, 1); - } - } - - this.headers[Utils.headerize(name)] = Array.isArray(value) - ? value - : [value]; - } - - /** - * Get the value of the given header name at the given position. - * -param {String} name header name - * -returns {String|undefined} Returns the specified header, null if header doesn't exist. - */ - getHeader(name) { - const headers = this.headers[Utils.headerize(name)]; - - if (headers) { - if (headers[0]) { - return headers[0]; - } - } else { - const regexp = new RegExp(`^\\s*${name}\\s*:`, 'i'); - - for (const header of this.extraHeaders) { - if (regexp.test(header)) { - return header.substring(header.indexOf(':') + 1).trim(); - } - } - } - - return; - } - - /** - * Get the header/s of the given name. - * -param {String} name header name - * -returns {Array} Array with all the headers of the specified name. - */ - getHeaders(name) { - const headers = this.headers[Utils.headerize(name)]; - const result = []; - - if (headers) { - for (const header of headers) { - result.push(header); - } - - return result; - } else { - const regexp = new RegExp(`^\\s*${name}\\s*:`, 'i'); - - for (const header of this.extraHeaders) { - if (regexp.test(header)) { - result.push(header.substring(header.indexOf(':') + 1).trim()); - } - } - - return result; - } - } - - /** - * Verify the existence of the given header. - * -param {String} name header name - * -returns {boolean} true if header with given name exists, false otherwise - */ - hasHeader(name) { - if (this.headers[Utils.headerize(name)]) { - return true; - } else { - const regexp = new RegExp(`^\\s*${name}\\s*:`, 'i'); - - for (const header of this.extraHeaders) { - if (regexp.test(header)) { - return true; - } - } - } - - return false; - } - - /** - * Parse the current body as a SDP and store the resulting object - * into this.sdp. - * -param {Boolean} force: Parse even if this.sdp already exists. - * - * Returns this.sdp. - */ - parseSDP(force) { - if (!force && this.sdp) { - return this.sdp; - } else { - this.sdp = sdp_transform.parse(this.body || ''); - - return this.sdp; - } - } - - toString() { - let msg = `${this.method} ${this.ruri} SIP/2.0\r\n`; - - for (const headerName in this.headers) { - if (Object.prototype.hasOwnProperty.call(this.headers, headerName)) { - for (const headerValue of this.headers[headerName]) { - msg += `${headerName}: ${headerValue}\r\n`; - } - } - } - - for (const header of this.extraHeaders) { - msg += `${header.trim()}\r\n`; - } - - // Supported. - const supported = []; - - switch (this.method) { - case JsSIP_C.REGISTER: { - supported.push('path', 'gruu'); - break; - } - case JsSIP_C.INVITE: { - if (this.ua.configuration.session_timers) { - supported.push('timer'); - } - if (this.ua.contact.pub_gruu || this.ua.contact.temp_gruu) { - supported.push('gruu'); - } - supported.push('ice', 'replaces'); - break; - } - case JsSIP_C.UPDATE: { - if (this.ua.configuration.session_timers) { - supported.push('timer'); - } - supported.push('ice'); - break; - } - } - - supported.push('outbound'); - - const userAgent = this.ua.configuration.user_agent || JsSIP_C.USER_AGENT; - - // Allow. - msg += `Allow: ${JsSIP_C.ALLOWED_METHODS}\r\n`; - msg += `Supported: ${supported}\r\n`; - msg += `User-Agent: ${userAgent}\r\n`; - - if (this.body) { - const length = Utils.str_utf8_length(this.body); - - msg += `Content-Length: ${length}\r\n\r\n`; - msg += this.body; - } else { - msg += 'Content-Length: 0\r\n\r\n'; - } - - return msg; - } - - clone() { - const request = new OutgoingRequest(this.method, this.ruri, this.ua); - - Object.keys(this.headers).forEach(function (name) { - request.headers[name] = this.headers[name].slice(); - }, this); - - request.body = this.body; - request.extraHeaders = Utils.cloneArray(this.extraHeaders); - request.to = this.to; - request.from = this.from; - request.call_id = this.call_id; - request.cseq = this.cseq; - - return request; - } -} - -class InitialOutgoingInviteRequest extends OutgoingRequest { - constructor(ruri, ua, params, extraHeaders, body) { - super(JsSIP_C.INVITE, ruri, ua, params, extraHeaders, body); - - this.transaction = null; - } - - cancel(reason) { - this.transaction.cancel(reason); - } - - clone() { - const request = new InitialOutgoingInviteRequest(this.ruri, this.ua); - - Object.keys(this.headers).forEach(function (name) { - request.headers[name] = this.headers[name].slice(); - }, this); - - request.body = this.body; - request.extraHeaders = Utils.cloneArray(this.extraHeaders); - request.to = this.to; - request.from = this.from; - request.call_id = this.call_id; - request.cseq = this.cseq; - - request.transaction = this.transaction; - - return request; - } -} - -class IncomingMessage { - constructor() { - this.data = null; - this.headers = null; - this.method = null; - this.via = null; - this.via_branch = null; - this.call_id = null; - this.cseq = null; - this.from = null; - this.from_tag = null; - this.to = null; - this.to_tag = null; - this.body = null; - this.sdp = null; - } - - /** - * Insert a header of the given name and value into the last position of the - * header array. - */ - addHeader(name, value) { - const header = { raw: value }; - - name = Utils.headerize(name); - - if (this.headers[name]) { - this.headers[name].push(header); - } else { - this.headers[name] = [header]; - } - } - - /** - * Get the value of the given header name at the given position. - */ - getHeader(name) { - const header = this.headers[Utils.headerize(name)]; - - if (header) { - if (header[0]) { - return header[0].raw; - } - } else { - return; - } - } - - /** - * Get the header/s of the given name. - */ - getHeaders(name) { - const headers = this.headers[Utils.headerize(name)]; - const result = []; - - if (!headers) { - return []; - } - - for (const header of headers) { - result.push(header.raw); - } - - return result; - } - - /** - * Verify the existence of the given header. - */ - hasHeader(name) { - return this.headers[Utils.headerize(name)] ? true : false; - } - - /** - * Parse the given header on the given index. - * -param {String} name header name - * -param {Number} [idx=0] header index - * -returns {Object|undefined} Parsed header object, undefined if the header - * is not present or in case of a parsing error. - */ - parseHeader(name, idx = 0) { - name = Utils.headerize(name); - - if (!this.headers[name]) { - logger.debug(`header "${name}" not present`); - - return; - } else if (idx >= this.headers[name].length) { - logger.debug(`not so many "${name}" headers present`); - - return; - } - - const header = this.headers[name][idx]; - const value = header.raw; - - if (header.parsed) { - return header.parsed; - } - - // Substitute '-' by '_' for grammar rule matching. - const parsed = Grammar.parse(value, name.replace(/-/g, '_')); - - if (parsed === -1) { - this.headers[name].splice(idx, 1); // delete from headers - logger.debug( - `error parsing "${name}" header field with value "${value}"` - ); - - return; - } else { - header.parsed = parsed; - - return parsed; - } - } - - /** - * Message Header attribute selector. Alias of parseHeader. - * -param {String} name header name - * -param {Number} [idx=0] header index - * -returns {Object|undefined} Parsed header object, undefined if the header - * is not present or in case of a parsing error. - * - * -example - * message.s('via',3).port - */ - s(name, idx) { - return this.parseHeader(name, idx); - } - - /** - * Replace the value of the given header by the value. - * -param {String} name header name - * -param {String} value header value - */ - setHeader(name, value) { - const header = { raw: value }; - - this.headers[Utils.headerize(name)] = [header]; - } - - /** - * Parse the current body as a SDP and store the resulting object - * into this.sdp. - * -param {Boolean} force: Parse even if this.sdp already exists. - * - * Returns this.sdp. - */ - parseSDP(force) { - if (!force && this.sdp) { - return this.sdp; - } else { - this.sdp = sdp_transform.parse(this.body || ''); - - return this.sdp; - } - } - - toString() { - return this.data; - } -} - -class IncomingRequest extends IncomingMessage { - constructor(ua) { - super(); - - this.ua = ua; - this.headers = {}; - this.ruri = null; - this.transport = null; - this.server_transaction = null; - } - - /** - * Stateful reply. - * -param {Number} code status code - * -param {String} reason reason phrase - * -param {Object} headers extra headers - * -param {String} body body - * -param {Function} [onSuccess] onSuccess callback - * -param {Function} [onFailure] onFailure callback - */ - reply(code, reason, extraHeaders, body, onSuccess, onFailure) { - const supported = []; - let to = this.getHeader('To'); - - code = code || null; - reason = reason || null; - - // Validate code and reason values. - if (!code || code < 100 || code > 699) { - throw new TypeError(`Invalid status_code: ${code}`); - } else if ( - reason && - typeof reason !== 'string' && - !(reason instanceof String) - ) { - throw new TypeError(`Invalid reason_phrase: ${reason}`); - } - - reason = reason || JsSIP_C.REASON_PHRASE[code] || ''; - extraHeaders = Utils.cloneArray(extraHeaders); - - if (this.ua.configuration.extra_headers) { - extraHeaders = extraHeaders.concat(this.ua.configuration.extra_headers); - } - - let response = `SIP/2.0 ${code} ${reason}\r\n`; - - if (this.method === JsSIP_C.INVITE && code > 100 && code <= 200) { - const headers = this.getHeaders('record-route'); - - for (const header of headers) { - response += `Record-Route: ${header}\r\n`; - } - } - - const vias = this.getHeaders('via'); - - for (const via of vias) { - response += `Via: ${via}\r\n`; - } - - if (!this.to_tag && code > 100) { - to += `;tag=${Utils.newTag()}`; - } else if (this.to_tag && !this.s('to').hasParam('tag')) { - to += `;tag=${this.to_tag}`; - } - - response += `To: ${to}\r\n`; - response += `From: ${this.getHeader('From')}\r\n`; - response += `Call-ID: ${this.call_id}\r\n`; - response += `CSeq: ${this.cseq} ${this.method}\r\n`; - - for (const header of extraHeaders) { - response += `${header.trim()}\r\n`; - } - - // Supported. - switch (this.method) { - case JsSIP_C.INVITE: { - if (this.ua.configuration.session_timers) { - supported.push('timer'); - } - if (this.ua.contact.pub_gruu || this.ua.contact.temp_gruu) { - supported.push('gruu'); - } - supported.push('ice', 'replaces'); - break; - } - case JsSIP_C.UPDATE: { - if (this.ua.configuration.session_timers) { - supported.push('timer'); - } - if (body) { - supported.push('ice'); - } - supported.push('replaces'); - } - } - - supported.push('outbound'); - - // Allow and Accept. - if (this.method === JsSIP_C.OPTIONS) { - response += `Allow: ${JsSIP_C.ALLOWED_METHODS}\r\n`; - response += `Accept: ${JsSIP_C.ACCEPTED_BODY_TYPES}\r\n`; - } else if (code === 405) { - response += `Allow: ${JsSIP_C.ALLOWED_METHODS}\r\n`; - } else if (code === 415) { - response += `Accept: ${JsSIP_C.ACCEPTED_BODY_TYPES}\r\n`; - } - - response += `Supported: ${supported}\r\n`; - - if (body) { - const length = Utils.str_utf8_length(body); - - response += 'Content-Type: application/sdp\r\n'; - response += `Content-Length: ${length}\r\n\r\n`; - response += body; - } else { - response += `Content-Length: ${0}\r\n\r\n`; - } - - this.server_transaction.receiveResponse( - code, - response, - onSuccess, - onFailure - ); - } - - /** - * Stateless reply. - * -param {Number} code status code - * -param {String} reason reason phrase - */ - reply_sl(code = null, reason = null) { - const vias = this.getHeaders('via'); - - // Validate code and reason values. - if (!code || code < 100 || code > 699) { - throw new TypeError(`Invalid status_code: ${code}`); - } else if ( - reason && - typeof reason !== 'string' && - !(reason instanceof String) - ) { - throw new TypeError(`Invalid reason_phrase: ${reason}`); - } - - reason = reason || JsSIP_C.REASON_PHRASE[code] || ''; - - let response = `SIP/2.0 ${code} ${reason}\r\n`; - - for (const via of vias) { - response += `Via: ${via}\r\n`; - } - - let to = this.getHeader('To'); - - if (!this.to_tag && code > 100) { - to += `;tag=${Utils.newTag()}`; - } else if (this.to_tag && !this.s('to').hasParam('tag')) { - to += `;tag=${this.to_tag}`; - } - - response += `To: ${to}\r\n`; - response += `From: ${this.getHeader('From')}\r\n`; - response += `Call-ID: ${this.call_id}\r\n`; - response += `CSeq: ${this.cseq} ${this.method}\r\n`; - - if (this.ua.configuration.extra_headers) { - for (const header of this.ua.configuration.extra_headers) { - response += `${header.trim()}\r\n`; - } - } - - response += `Content-Length: ${0}\r\n\r\n`; - - this.transport.send(response); - } -} - -class IncomingResponse extends IncomingMessage { - constructor() { - super(); - - this.headers = {}; - this.status_code = null; - this.reason_phrase = null; - } -} - -module.exports = { - OutgoingRequest, - InitialOutgoingInviteRequest, - IncomingRequest, - IncomingResponse, -}; diff --git a/src/SIPMessage.ts b/src/SIPMessage.ts new file mode 100644 index 00000000..0b6e0803 --- /dev/null +++ b/src/SIPMessage.ts @@ -0,0 +1,944 @@ +import type { EventEmitter } from 'events'; + +import * as JsSIP_C from './Constants'; +import { Logger } from './Logger'; +import * as Grammar from './Grammar'; +import { + cloneArray, + createRandomToken, + headerize, + newTag, + str_utf8_length, +} from './Utils'; +import NameAddrHeader = require('./NameAddrHeader'); +import URI = require('./URI'); + +// NOTE: `sdp-transform` ships no type declarations and `@types/sdp-transform` +// is not a dependency of this project, so it is consumed through a `require()` +// narrowed to the single function used here. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const sdp_transform = require('sdp-transform') as { + parse(sdp: string): SDP; +}; + +const logger = new Logger('SIPMessage'); + +/** + * A single media description of a parsed SDP. + */ +export type SDPMediaDescription = { + type: string; + direction?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +}; + +/** + * An SDP as returned by `sdp-transform`'s `parse()`. + */ +export type SDP = { + media: SDPMediaDescription[]; + direction?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +}; + +/** + * A header value as stored by an `OutgoingRequest`. + * + * NOTE: It is not just `string` because `Max-Forwards` is stored as the + * `JsSIP_C.MAX_FORWARDS` number. + */ +export type OutgoingHeaderValue = string | number; + +/** + * A header entry as stored by an `IncomingMessage`. + */ +export type IncomingHeader = { + raw: string; + parsed?: Grammar.Grammar; +}; + +/** + * Parameters that take priority over the `ua.configuration` ones. + */ +export type OutgoingRequestParams = { + cseq?: number; + call_id?: string; + from_uri?: URI; + from_tag?: string; + from_display_name?: string; + to_uri?: URI; + to_tag?: string; + to_display_name?: string; + route_set?: OutgoingHeaderValue[]; +}; + +/* + * TODO: The `*Like` types below are structural views of modules that are still + * JavaScript. They are declared here so that this module stays a leaf of the + * TypeScript migration. The real classes satisfy them structurally, so each one + * must be replaced with a plain type import once its module is ported. + * + * Note that importing those modules only for their types does not create a + * 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. + * + * TODO: Replace with `import type { InviteClientTransaction } from + * './Transactions'` once `Transactions` is ported to TypeScript. + */ +export interface ClientTransactionLike { + cancel(reason?: string): void; +} + +/** + * The server transaction an incoming request is bound to. + * + * TODO: Replace with the server transaction types from './Transactions' once + * that module is ported to TypeScript. + */ +export interface ServerTransactionLike extends EventEmitter { + state: number; + + receiveResponse( + code: number, + response: string, + onSuccess?: () => void, + onFailure?: () => void + ): void; +} + +/** + * The `UA` surface SIP messages depend on. + * + * TODO: Replace with `import type { UA } from './UA'` once `UA` is ported to + * TypeScript. Note that `UA.d.ts` currently declares neither `configuration` + * 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. + */ +export interface UALike { + readonly configuration: { + uri: URI; + display_name?: string | null; + jssip_id: string; + session_timers?: boolean; + use_preloaded_route?: boolean; + user_agent?: string | null; + extra_headers?: string[] | null; + }; + readonly contact: { + pub_gruu?: string | null; + temp_gruu?: string | null; + }; + readonly transport: TransportLike; +} + +/** + * -param {String} method request method + * -param {String} ruri request uri + * -param {UA} ua + * -param {Object} params parameters that will have priority over ua.configuration parameters: + *
+ * - cseq, call_id, from_tag, from_uri, from_display_name, to_uri, to_tag, route_set + * -param {Object} [headers] extra headers + * -param {String} [body] + */ +export class OutgoingRequest { + /* NOTE: Declared in the very same order in which the constructor assigns + * them so that the own property order of the instance is preserved. + */ + ua: UALike; + headers: Record; + method: string; + ruri: URI; + body: string | undefined; + extraHeaders: string[]; + to: NameAddrHeader; + from: NameAddrHeader; + call_id: string; + cseq: number; + + /* NOTE: `declare`d (and so not emitted) because the original implementation + * does not create these own properties in the constructor. `sdp` is created + * by `parseSDP()` and `transaction` is assigned by `Transactions.js` when + * the request is sent. + */ + declare sdp?: SDP; + declare transaction?: ClientTransactionLike | null; + + constructor( + method: string, + ruri: URI, + ua: UALike, + params?: OutgoingRequestParams | null, + extraHeaders?: string[] | null, + body?: string + ) { + // Mandatory parameters check. + if (!method || !ruri || !ua) { + throw new TypeError( + 'missing or invalid "method", "ruri" or "ua" parameter' + ); + } + + params = params || {}; + + this.ua = ua; + this.headers = {}; + this.method = method; + this.ruri = ruri; + this.body = body; + this.extraHeaders = cloneArray(extraHeaders); + + if (this.ua.configuration.extra_headers) { + this.extraHeaders = this.extraHeaders.concat( + this.ua.configuration.extra_headers + ); + } + + // Fill the Common SIP Request Headers. + + // Route. + if (params.route_set) { + this.setHeader('route', params.route_set); + } else if (ua.configuration.use_preloaded_route) { + this.setHeader('route', `<${ua.transport.sip_uri};lr>`); + } + + // Via. + // Empty Via header. Will be filled by the client transaction. + this.setHeader('via', ''); + + // Max-Forwards. + this.setHeader('max-forwards', JsSIP_C.MAX_FORWARDS); + + // To + const to_uri = params.to_uri || ruri; + const to_params = params.to_tag ? { tag: params.to_tag } : null; + // NOTE: `NameAddrHeader` tolerates a `null` display name (its setter + // stores it as is), so the cast keeps the original behaviour. + const to_display_name = ( + typeof params.to_display_name !== 'undefined' + ? params.to_display_name + : null + ) as string | undefined; + + this.to = new NameAddrHeader(to_uri, to_display_name, to_params); + this.setHeader('to', this.to.toString()); + + // From. + const from_uri = params.from_uri || ua.configuration.uri; + const from_params = { tag: params.from_tag || newTag() }; + let display_name: string | null; + + if (typeof params.from_display_name !== 'undefined') { + display_name = params.from_display_name; + } else if (ua.configuration.display_name) { + display_name = ua.configuration.display_name; + } else { + display_name = null; + } + + this.from = new NameAddrHeader( + from_uri, + display_name as string | undefined, + from_params + ); + this.setHeader('from', this.from.toString()); + + // Call-ID. + const call_id = + params.call_id || ua.configuration.jssip_id + createRandomToken(15); + + this.call_id = call_id; + this.setHeader('call-id', call_id); + + // CSeq. + const cseq = params.cseq || Math.floor(Math.random() * 10000); + + this.cseq = cseq; + this.setHeader('cseq', `${cseq} ${method}`); + } + + /** + * Replace the the given header by the given value. + * -param {String} name header name + * -param {String | Array} value header value + */ + setHeader( + name: string, + value: OutgoingHeaderValue | OutgoingHeaderValue[] + ): void { + // Remove the header from extraHeaders if present. + const regexp = new RegExp(`^\\s*${name}\\s*:`, 'i'); + + for (let idx = 0; idx < this.extraHeaders.length; idx++) { + if (regexp.test(this.extraHeaders[idx]!)) { + this.extraHeaders.splice(idx, 1); + } + } + + this.headers[headerize(name)] = Array.isArray(value) ? value : [value]; + } + + /** + * Get the value of the given header name at the given position. + * -param {String} name header name + * -returns {String|undefined} Returns the specified header, null if header doesn't exist. + */ + getHeader(name: string): OutgoingHeaderValue | undefined { + const headers = this.headers[headerize(name)]; + + if (headers) { + if (headers[0]) { + return headers[0]; + } + } else { + const regexp = new RegExp(`^\\s*${name}\\s*:`, 'i'); + + for (const header of this.extraHeaders) { + if (regexp.test(header)) { + return header.substring(header.indexOf(':') + 1).trim(); + } + } + } + + return; + } + + /** + * Get the header/s of the given name. + * -param {String} name header name + * -returns {Array} Array with all the headers of the specified name. + */ + getHeaders(name: string): OutgoingHeaderValue[] { + const headers = this.headers[headerize(name)]; + const result: OutgoingHeaderValue[] = []; + + if (headers) { + for (const header of headers) { + result.push(header); + } + + return result; + } else { + const regexp = new RegExp(`^\\s*${name}\\s*:`, 'i'); + + for (const header of this.extraHeaders) { + if (regexp.test(header)) { + result.push(header.substring(header.indexOf(':') + 1).trim()); + } + } + + return result; + } + } + + /** + * Verify the existence of the given header. + * -param {String} name header name + * -returns {boolean} true if header with given name exists, false otherwise + */ + hasHeader(name: string): boolean { + if (this.headers[headerize(name)]) { + return true; + } else { + const regexp = new RegExp(`^\\s*${name}\\s*:`, 'i'); + + for (const header of this.extraHeaders) { + if (regexp.test(header)) { + return true; + } + } + } + + return false; + } + + /** + * Parse the current body as a SDP and store the resulting object + * into this.sdp. + * -param {Boolean} force: Parse even if this.sdp already exists. + * + * Returns this.sdp. + */ + parseSDP(force?: boolean): SDP { + if (!force && this.sdp) { + return this.sdp; + } else { + this.sdp = sdp_transform.parse(this.body || ''); + + return this.sdp; + } + } + + toString(): string { + let msg = `${this.method} ${this.ruri} SIP/2.0\r\n`; + + for (const headerName in this.headers) { + if (Object.prototype.hasOwnProperty.call(this.headers, headerName)) { + for (const headerValue of this.headers[headerName]!) { + msg += `${headerName}: ${headerValue}\r\n`; + } + } + } + + for (const header of this.extraHeaders) { + msg += `${header.trim()}\r\n`; + } + + // Supported. + const supported: string[] = []; + + switch (this.method) { + case JsSIP_C.REGISTER: { + supported.push('path', 'gruu'); + break; + } + case JsSIP_C.INVITE: { + if (this.ua.configuration.session_timers) { + supported.push('timer'); + } + if (this.ua.contact.pub_gruu || this.ua.contact.temp_gruu) { + supported.push('gruu'); + } + supported.push('ice', 'replaces'); + break; + } + case JsSIP_C.UPDATE: { + if (this.ua.configuration.session_timers) { + supported.push('timer'); + } + supported.push('ice'); + break; + } + } + + supported.push('outbound'); + + const userAgent = this.ua.configuration.user_agent || JsSIP_C.USER_AGENT; + + // Allow. + msg += `Allow: ${JsSIP_C.ALLOWED_METHODS}\r\n`; + msg += `Supported: ${supported}\r\n`; + msg += `User-Agent: ${userAgent}\r\n`; + + if (this.body) { + const length = str_utf8_length(this.body); + + msg += `Content-Length: ${length}\r\n\r\n`; + msg += this.body; + } else { + msg += 'Content-Length: 0\r\n\r\n'; + } + + return msg; + } + + clone(): OutgoingRequest { + const request = new OutgoingRequest(this.method, this.ruri, this.ua); + + // NOTE: An arrow function is used instead of the original `function` plus + // `thisArg` so that `this` is properly typed. Iteration and assignment + // order are unchanged. + Object.keys(this.headers).forEach(name => { + request.headers[name] = this.headers[name]!.slice(); + }); + + request.body = this.body; + request.extraHeaders = cloneArray(this.extraHeaders); + request.to = this.to; + request.from = this.from; + request.call_id = this.call_id; + request.cseq = this.cseq; + + return request; + } +} + +export class InitialOutgoingInviteRequest extends OutgoingRequest { + /* NOTE: Unlike in `OutgoingRequest`, this is a real (emitted) field because + * the original implementation does assign it in the constructor. + */ + override transaction: ClientTransactionLike | null = null; + + constructor( + ruri: URI, + ua: UALike, + params?: OutgoingRequestParams | null, + extraHeaders?: string[] | null, + body?: string + ) { + super(JsSIP_C.INVITE, ruri, ua, params, extraHeaders, body); + } + + cancel(reason?: string): void { + this.transaction!.cancel(reason); + } + + override clone(): InitialOutgoingInviteRequest { + const request = new InitialOutgoingInviteRequest(this.ruri, this.ua); + + Object.keys(this.headers).forEach(name => { + request.headers[name] = this.headers[name]!.slice(); + }); + + request.body = this.body; + request.extraHeaders = cloneArray(this.extraHeaders); + request.to = this.to; + request.from = this.from; + request.call_id = this.call_id; + request.cseq = this.cseq; + + request.transaction = this.transaction; + + return request; + } +} + +export class IncomingMessage { + /* NOTE: Declared in the very same order in which the constructor assigns + * them so that the own property order of the instance is preserved. + */ + data: string | null; + headers: Record; + method: string | null; + via: Grammar.Grammar | null; + via_branch: string | null; + call_id: string | null; + cseq: number | null; + from: NameAddrHeader | null; + from_tag: string | null; + to: NameAddrHeader | null; + to_tag: string | null; + body: string | null; + sdp: SDP | null; + + /* NOTE: `declare`d (and so not emitted) because the original implementation + * does not create these own properties. They are assigned by `Parser.js` + * when the corresponding header field is present. + */ + declare session_expires?: number; + declare session_expires_refresher?: string; + declare refer_to?: Grammar.Grammar; + declare replaces?: Grammar.Grammar; + declare event?: Grammar.Grammar; + + constructor() { + this.data = null; + // NOTE: The original implementation sets `null` here and both subclasses + // immediately replace it with `{}`. An empty object is used instead so + // that the type does not have to be nullable. Nothing can observe the + // difference since `IncomingMessage` is never instantiated directly. + this.headers = {}; + this.method = null; + this.via = null; + this.via_branch = null; + this.call_id = null; + this.cseq = null; + this.from = null; + this.from_tag = null; + this.to = null; + this.to_tag = null; + this.body = null; + this.sdp = null; + } + + /** + * Insert a header of the given name and value into the last position of the + * header array. + */ + addHeader(name: string, value: string): void { + const header: IncomingHeader = { raw: value }; + + name = headerize(name); + + const headers = this.headers[name]; + + if (headers) { + headers.push(header); + } else { + this.headers[name] = [header]; + } + } + + /** + * Get the value of the given header name at the given position. + */ + getHeader(name: string): string | undefined { + const header = this.headers[headerize(name)]; + + if (header) { + if (header[0]) { + return header[0].raw; + } + } else { + return; + } + + // NOTE: Explicit (and otherwise implicit) `undefined` for + // `noImplicitReturns`. + return; + } + + /** + * Get the header/s of the given name. + */ + getHeaders(name: string): string[] { + const headers = this.headers[headerize(name)]; + const result: string[] = []; + + if (!headers) { + return []; + } + + for (const header of headers) { + result.push(header.raw); + } + + return result; + } + + /** + * Verify the existence of the given header. + */ + hasHeader(name: string): boolean { + return this.headers[headerize(name)] ? true : false; + } + + /** + * Parse the given header on the given index. + * -param {String} name header name + * -param {Number} [idx=0] header index + * -returns {Object|undefined} Parsed header object, undefined if the header + * is not present or in case of a parsing error. + */ + parseHeader(name: string, idx = 0): T | undefined { + name = headerize(name); + + const headers = this.headers[name]; + + if (!headers) { + logger.debug(`header "${name}" not present`); + + return; + } else if (idx >= headers.length) { + logger.debug(`not so many "${name}" headers present`); + + return; + } + + const header = headers[idx]!; + const value = header.raw; + + if (header.parsed) { + return header.parsed as T; + } + + // Substitute '-' by '_' for grammar rule matching. + const parsed = Grammar.parse(value, name.replace(/-/g, '_')); + + if (parsed === -1) { + headers.splice(idx, 1); // delete from headers + logger.debug( + `error parsing "${name}" header field with value "${value}"` + ); + + return; + } else { + header.parsed = parsed; + + return parsed as T; + } + } + + /** + * Message Header attribute selector. Alias of parseHeader. + * -param {String} name header name + * -param {Number} [idx=0] header index + * -returns {Object|undefined} Parsed header object, undefined if the header + * is not present or in case of a parsing error. + * + * -example + * message.s('via',3).port + */ + s(name: string, idx?: number): T | undefined { + return this.parseHeader(name, idx); + } + + /** + * Replace the value of the given header by the value. + * -param {String} name header name + * -param {String} value header value + */ + setHeader(name: string, value: string): void { + const header: IncomingHeader = { raw: value }; + + this.headers[headerize(name)] = [header]; + } + + /** + * Parse the current body as a SDP and store the resulting object + * into this.sdp. + * -param {Boolean} force: Parse even if this.sdp already exists. + * + * Returns this.sdp. + */ + parseSDP(force?: boolean): SDP { + if (!force && this.sdp) { + return this.sdp; + } else { + this.sdp = sdp_transform.parse(this.body || ''); + + return this.sdp; + } + } + + toString(): string | null { + return this.data; + } +} + +export class IncomingRequest extends IncomingMessage { + /* NOTE: Declared in the very same order in which the constructor assigns + * them so that the own property order of the instance is preserved. + * `headers` is not redeclared because it is already created by the parent + * constructor. + */ + ua: UALike; + ruri: URI | null; + transport: TransportLike | null; + server_transaction: ServerTransactionLike | null; + + constructor(ua: UALike) { + super(); + + this.ua = ua; + this.headers = {}; + this.ruri = null; + this.transport = null; + this.server_transaction = null; + } + + /** + * Stateful reply. + * -param {Number} code status code + * -param {String} reason reason phrase + * -param {Object} headers extra headers + * -param {String} body body + * -param {Function} [onSuccess] onSuccess callback + * -param {Function} [onFailure] onFailure callback + */ + reply( + code: number, + reason?: string | null, + extraHeaders?: string[] | null, + body?: string | null, + onSuccess?: () => void, + onFailure?: () => void + ): void { + const supported: string[] = []; + let to = this.getHeader('To')!; + + // NOTE: `code` is normalized into a local (instead of onto the parameter + // itself) so that the `TypeError` message below is identical to the one + // of the original implementation. + const status_code: number | null = code || null; + + reason = reason || null; + + // Validate code and reason values. + if (!status_code || status_code < 100 || status_code > 699) { + throw new TypeError(`Invalid status_code: ${status_code}`); + } else if ( + reason && + typeof reason !== 'string' && + // NOTE: `reason` is statically a string here, but the runtime check is + // kept as is for JavaScript callers passing a `String` object. + !((reason as unknown) instanceof String) + ) { + throw new TypeError(`Invalid reason_phrase: ${reason}`); + } + + reason = reason || JsSIP_C.REASON_PHRASE[status_code] || ''; + extraHeaders = cloneArray(extraHeaders); + + if (this.ua.configuration.extra_headers) { + extraHeaders = extraHeaders.concat(this.ua.configuration.extra_headers); + } + + let response = `SIP/2.0 ${status_code} ${reason}\r\n`; + + if ( + this.method === JsSIP_C.INVITE && + status_code > 100 && + status_code <= 200 + ) { + const headers = this.getHeaders('record-route'); + + for (const header of headers) { + response += `Record-Route: ${header}\r\n`; + } + } + + const vias = this.getHeaders('via'); + + for (const via of vias) { + response += `Via: ${via}\r\n`; + } + + if (!this.to_tag && status_code > 100) { + to += `;tag=${newTag()}`; + } else if (this.to_tag && !this.s('to')!.hasParam('tag')) { + to += `;tag=${this.to_tag}`; + } + + response += `To: ${to}\r\n`; + response += `From: ${this.getHeader('From')}\r\n`; + response += `Call-ID: ${this.call_id}\r\n`; + response += `CSeq: ${this.cseq} ${this.method}\r\n`; + + for (const header of extraHeaders) { + response += `${header.trim()}\r\n`; + } + + // Supported. + switch (this.method) { + case JsSIP_C.INVITE: { + if (this.ua.configuration.session_timers) { + supported.push('timer'); + } + if (this.ua.contact.pub_gruu || this.ua.contact.temp_gruu) { + supported.push('gruu'); + } + supported.push('ice', 'replaces'); + break; + } + case JsSIP_C.UPDATE: { + if (this.ua.configuration.session_timers) { + supported.push('timer'); + } + if (body) { + supported.push('ice'); + } + supported.push('replaces'); + } + } + + supported.push('outbound'); + + // Allow and Accept. + if (this.method === JsSIP_C.OPTIONS) { + response += `Allow: ${JsSIP_C.ALLOWED_METHODS}\r\n`; + response += `Accept: ${JsSIP_C.ACCEPTED_BODY_TYPES}\r\n`; + } else if (status_code === 405) { + response += `Allow: ${JsSIP_C.ALLOWED_METHODS}\r\n`; + } else if (status_code === 415) { + response += `Accept: ${JsSIP_C.ACCEPTED_BODY_TYPES}\r\n`; + } + + response += `Supported: ${supported}\r\n`; + + if (body) { + const length = str_utf8_length(body); + + response += 'Content-Type: application/sdp\r\n'; + response += `Content-Length: ${length}\r\n\r\n`; + response += body; + } else { + response += `Content-Length: ${0}\r\n\r\n`; + } + + this.server_transaction!.receiveResponse( + status_code, + response, + onSuccess, + onFailure + ); + } + + /** + * Stateless reply. + * -param {Number} code status code + * -param {String} reason reason phrase + */ + reply_sl(code: number | null = null, reason: string | null = null): void { + const vias = this.getHeaders('via'); + + // Validate code and reason values. + if (!code || code < 100 || code > 699) { + throw new TypeError(`Invalid status_code: ${code}`); + } else if ( + reason && + typeof reason !== 'string' && + // NOTE: `reason` is statically a string here, but the runtime check is + // kept as is for JavaScript callers passing a `String` object. + !((reason as unknown) instanceof String) + ) { + throw new TypeError(`Invalid reason_phrase: ${reason}`); + } + + reason = reason || JsSIP_C.REASON_PHRASE[code] || ''; + + let response = `SIP/2.0 ${code} ${reason}\r\n`; + + for (const via of vias) { + response += `Via: ${via}\r\n`; + } + + let to = this.getHeader('To')!; + + if (!this.to_tag && code > 100) { + to += `;tag=${newTag()}`; + } else if (this.to_tag && !this.s('to')!.hasParam('tag')) { + to += `;tag=${this.to_tag}`; + } + + response += `To: ${to}\r\n`; + response += `From: ${this.getHeader('From')}\r\n`; + response += `Call-ID: ${this.call_id}\r\n`; + response += `CSeq: ${this.cseq} ${this.method}\r\n`; + + if (this.ua.configuration.extra_headers) { + for (const header of this.ua.configuration.extra_headers) { + response += `${header.trim()}\r\n`; + } + } + + response += `Content-Length: ${0}\r\n\r\n`; + + this.transport!.send(response); + } +} + +export class IncomingResponse extends IncomingMessage { + status_code: number | null; + reason_phrase: string | null; + + constructor() { + super(); + + this.headers = {}; + this.status_code = null; + this.reason_phrase = null; + } +} From 7fd834422b607f48851716f342168c8c1e20bc2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Luis=20Mill=C3=A1n?= Date: Tue, 20 Oct 2026 15:46:45 +0200 Subject: [PATCH 2/4] TS: SIPMessage, add IncomingMessageBuilder --- src/DigestAuthentication.ts | 68 +++--- src/NameAddrHeader.ts | 2 +- src/Parser.js | 216 +++------------- src/SIPMessage.ts | 339 +++++++++++++++++--------- src/URI.ts | 4 +- src/test/test-DigestAuthentication.ts | 10 - 6 files changed, 287 insertions(+), 352 deletions(-) diff --git a/src/DigestAuthentication.ts b/src/DigestAuthentication.ts index a89a09b2..35dbbb25 100644 --- a/src/DigestAuthentication.ts +++ b/src/DigestAuthentication.ts @@ -3,20 +3,24 @@ import { calculateMD5, createRandomToken } from './Utils'; const logger = new Logger('DigestAuthentication'); +/* NOTE: All of them are nullable because they come straight from the UA + * configuration, whose defaults are `null`. + */ export type DigestCredentials = { - username?: string; - password?: string; - realm?: string; + username?: string | null; + password?: string | null; + realm?: string | null; ha1?: string | null; }; +// A parsed WWW-Authenticate or Proxy-Authenticate header field. export type DigestChallenge = { - algorithm?: string | null; - realm?: string | null; - nonce?: string | null; - opaque?: string | null; - stale?: boolean | null; - qop?: string | string[] | null; + algorithm?: string; + realm?: string; + nonce?: string; + opaque?: string; + stale?: boolean; + qop?: string | string[]; }; export type DigestAuthenticateRequest = { @@ -27,36 +31,24 @@ export type DigestAuthenticateRequest = { export class DigestAuthentication { #credentials: DigestCredentials; - #cnonce: string | null; - #nc: number; - #ncHex: string; - #algorithm: string | null | undefined; - #realm: string | null | undefined; - #nonce: string | null | undefined; - #opaque: string | null | undefined; + #nc = 0; + #ncHex = '00000000'; + #cnonce?: string; + #algorithm?: string; + #realm?: string; + #nonce?: string; + #opaque?: string; // eslint-disable-next-line no-unused-private-class-members - #stale: boolean | null | undefined; - #qop: 'auth' | 'auth-int' | null; - #method: string | null; - #uri: string | { toString(): string } | null; - #ha1: string | null | undefined; - #response: string | null; + #stale?: boolean; + #qop?: 'auth' | 'auth-int'; + #method?: string; + #uri?: string | { toString(): string }; + // NOTE: Nullable because it may come from the UA configuration. + #ha1?: string | null; + #response?: string; constructor(credentials: DigestCredentials) { this.#credentials = credentials; - this.#cnonce = null; - this.#nc = 0; - this.#ncHex = '00000000'; - this.#algorithm = null; - this.#realm = null; - this.#nonce = null; - this.#opaque = null; - this.#stale = null; - this.#qop = null; - this.#method = null; - this.#uri = null; - this.#ha1 = null; - this.#response = null; } /** @@ -64,7 +56,7 @@ export class DigestAuthentication { * * NOTE: Exposed for testing purposes only. */ - get response(): string | null { + get response(): string | undefined { return this.#response; } @@ -168,7 +160,7 @@ export class DigestAuthentication { return false; } } else { - this.#qop = null; + this.#qop = undefined; } // Fill other attributes. @@ -226,7 +218,7 @@ export class DigestAuthentication { this.#response = calculateMD5( `${this.#ha1}:${this.#nonce}:${this.#ncHex}:${this.#cnonce}:auth-int:${ha2}` ); - } else if (this.#qop === null) { + } else if (this.#qop === undefined) { // HA2 = MD5(A2) = MD5(method:digestURI). a2 = `${this.#method}:${this.#uri}`; ha2 = calculateMD5(a2); diff --git a/src/NameAddrHeader.ts b/src/NameAddrHeader.ts index 2837b77e..87953ce5 100644 --- a/src/NameAddrHeader.ts +++ b/src/NameAddrHeader.ts @@ -19,7 +19,7 @@ class NameAddrHeader { } #uri: URI; - #display_name: string | undefined; + #display_name?: string; #parameters: URI.Parameters; constructor( diff --git a/src/Parser.js b/src/Parser.js index a2e67bec..d2fc29a4 100644 --- a/src/Parser.js +++ b/src/Parser.js @@ -1,6 +1,6 @@ const { Logger } = require('./Logger'); const Grammar = require('./Grammar'); -const SIPMessage = require('./SIPMessage'); +const { IncomingMessageBuilder } = require('./IncomingMessageBuilder'); const logger = new Logger('Parser'); @@ -8,7 +8,6 @@ const logger = new Logger('Parser'); * Parse SIP Message */ exports.parseMessage = (data, ua) => { - let message; let bodyStart; let headerEnd = data.indexOf('\r\n'); @@ -20,7 +19,7 @@ exports.parseMessage = (data, ua) => { // Parse first line. Check if it is a Request or a Reply. const firstLine = data.substring(0, headerEnd); - let parsed = Grammar.parse(firstLine, 'Request_Response'); + const parsed = Grammar.parse(firstLine, 'Request_Response'); if (parsed === -1) { logger.warn( @@ -28,17 +27,11 @@ exports.parseMessage = (data, ua) => { ); return; - } else if (!parsed.status_code) { - message = new SIPMessage.IncomingRequest(ua); - message.method = parsed.method; - message.ruri = parsed.uri; - } else { - message = new SIPMessage.IncomingResponse(); - message.status_code = parsed.status_code; - message.reason_phrase = parsed.reason_phrase; } - message.data = data; + const isResponse = Boolean(parsed.status_code); + const builder = new IncomingMessageBuilder(); + let headerStart = headerEnd + 2; /* Loop over every line in data. Detect the end of each header and parse @@ -59,10 +52,10 @@ exports.parseMessage = (data, ua) => { return; } - parsed = parseHeader(message, data, headerStart, headerEnd); + const result = parseHeader(builder, data, headerStart, headerEnd); - if (parsed !== true) { - logger.warn('parseMessage() |', parsed.error); + if (result !== true) { + logger.warn('parseMessage() |', result.error); return; } @@ -74,15 +67,26 @@ exports.parseMessage = (data, ua) => { * If there are additional bytes in the transport packet * beyond the end of the body, they MUST be discarded. */ - if (message.hasHeader('content-length')) { - const contentLength = message.getHeader('content-length'); + let body; + + if (builder.hasHeader('content-length')) { + const contentLength = builder.getHeader('content-length'); - message.body = data.substr(bodyStart, contentLength); + body = data.substr(bodyStart, contentLength); } else { - message.body = data.substring(bodyStart); + body = data.substring(bodyStart); } - return message; + if (isResponse) { + return builder.buildResponse( + data, + parsed.status_code, + parsed.reason_phrase, + body + ); + } else { + return builder.buildRequest(ua, data, parsed.method, parsed.uri, body); + } }; /** @@ -124,180 +128,16 @@ function getHeader(data, headerStart) { return end; } -function parseHeader(message, data, headerStart, headerEnd) { - let parsed; +function parseHeader(builder, data, headerStart, headerEnd) { const hcolonIndex = data.indexOf(':', headerStart); const headerName = data.substring(headerStart, hcolonIndex).trim(); const headerValue = data.substring(hcolonIndex + 1, headerEnd).trim(); - // If header-field is well-known, parse it. - switch (headerName.toLowerCase()) { - case 'via': - case 'v': { - message.addHeader('via', headerValue); - if (message.getHeaders('via').length === 1) { - parsed = message.parseHeader('Via'); - if (parsed) { - message.via = parsed; - message.via_branch = parsed.branch; - } - } else { - parsed = 0; - } - break; - } - case 'from': - case 'f': { - message.setHeader('from', headerValue); - parsed = message.parseHeader('from'); - if (parsed) { - message.from = parsed; - message.from_tag = parsed.getParam('tag'); - } - break; - } - case 'to': - case 't': { - message.setHeader('to', headerValue); - parsed = message.parseHeader('to'); - if (parsed) { - message.to = parsed; - message.to_tag = parsed.getParam('tag'); - } - break; - } - case 'record-route': { - parsed = Grammar.parse(headerValue, 'Record_Route'); - - if (parsed === -1) { - parsed = undefined; - } else { - for (const header of parsed) { - message.addHeader( - 'record-route', - headerValue.substring(header.possition, header.offset) - ); - message.headers['Record-Route'][ - message.getHeaders('record-route').length - 1 - ].parsed = header.parsed; - } - } - break; - } - case 'call-id': - case 'i': { - message.setHeader('call-id', headerValue); - parsed = message.parseHeader('call-id'); - if (parsed) { - message.call_id = headerValue; - } - break; - } - case 'contact': - case 'm': { - parsed = Grammar.parse(headerValue, 'Contact'); - - if (parsed === -1) { - parsed = undefined; - } else { - for (const header of parsed) { - message.addHeader( - 'contact', - headerValue.substring(header.possition, header.offset) - ); - message.headers.Contact[ - message.getHeaders('contact').length - 1 - ].parsed = header.parsed; - } - } - break; - } - case 'content-length': - case 'l': { - message.setHeader('content-length', headerValue); - parsed = message.parseHeader('content-length'); - break; - } - case 'content-type': - case 'c': { - message.setHeader('content-type', headerValue); - parsed = message.parseHeader('content-type'); - break; - } - case 'cseq': { - message.setHeader('cseq', headerValue); - parsed = message.parseHeader('cseq'); - if (parsed) { - message.cseq = parsed.value; - } - if (message instanceof SIPMessage.IncomingResponse) { - message.method = parsed.method; - } - break; - } - case 'max-forwards': { - message.setHeader('max-forwards', headerValue); - parsed = message.parseHeader('max-forwards'); - break; - } - case 'www-authenticate': { - message.setHeader('www-authenticate', headerValue); - parsed = message.parseHeader('www-authenticate'); - break; - } - case 'proxy-authenticate': { - message.setHeader('proxy-authenticate', headerValue); - parsed = message.parseHeader('proxy-authenticate'); - break; - } - case 'session-expires': - case 'x': { - message.setHeader('session-expires', headerValue); - parsed = message.parseHeader('session-expires'); - if (parsed) { - message.session_expires = parsed.expires; - message.session_expires_refresher = parsed.refresher; - } - break; - } - case 'refer-to': - case 'r': { - message.setHeader('refer-to', headerValue); - parsed = message.parseHeader('refer-to'); - if (parsed) { - message.refer_to = parsed; - } - break; - } - case 'replaces': { - message.setHeader('replaces', headerValue); - parsed = message.parseHeader('replaces'); - if (parsed) { - message.replaces = parsed; - } - break; - } - case 'event': - case 'o': { - message.setHeader('event', headerValue); - parsed = message.parseHeader('event'); - if (parsed) { - message.event = parsed; - } - break; - } - default: { - // Do not parse this header. - message.addHeader(headerName, headerValue); - parsed = 0; - } - } - - if (parsed === undefined) { + if (!builder.addRawHeader(headerName, headerValue)) { return { error: `error parsing header "${headerName}"`, }; - } else { - return true; } + + return true; } diff --git a/src/SIPMessage.ts b/src/SIPMessage.ts index 0b6e0803..d0c78874 100644 --- a/src/SIPMessage.ts +++ b/src/SIPMessage.ts @@ -163,14 +163,11 @@ export interface UALike { * -param {String} [body] */ export class OutgoingRequest { - /* NOTE: Declared in the very same order in which the constructor assigns - * them so that the own property order of the instance is preserved. - */ ua: UALike; headers: Record; method: string; ruri: URI; - body: string | undefined; + body?: string; extraHeaders: string[]; to: NameAddrHeader; from: NameAddrHeader; @@ -515,103 +512,112 @@ export class InitialOutgoingInviteRequest extends OutgoingRequest { } } -export class IncomingMessage { - /* NOTE: Declared in the very same order in which the constructor assigns - * them so that the own property order of the instance is preserved. +export type IncomingHeaders = Record; + +/** + * Everything needed to build an `IncomingMessage`. + */ +export type IncomingMessageParams = { + data: string; + headers: IncomingHeaders; + method: string; + // Undefined when the corresponding header field is missing or unparseable. + via?: Grammar.Grammar; + via_branch?: string; + call_id?: string; + cseq?: number; + from: NameAddrHeader; + /* NOTE: `null` when the tag parameter is present but valueless (`;tag`), + * `undefined` when it is absent altogether. */ - data: string | null; - headers: Record; - method: string | null; - via: Grammar.Grammar | null; - via_branch: string | null; - call_id: string | null; - cseq: number | null; - from: NameAddrHeader | null; - from_tag: string | null; - to: NameAddrHeader | null; - to_tag: string | null; - body: string | null; - sdp: SDP | null; + from_tag?: string | null; + to: NameAddrHeader; + to_tag?: string | null; + body: string; + session_expires?: number; + session_expires_refresher?: string; + refer_to?: Grammar.Grammar; + replaces?: Grammar.Grammar; + event?: Grammar.Grammar; +}; - /* NOTE: `declare`d (and so not emitted) because the original implementation - * does not create these own properties. They are assigned by `Parser.js` - * when the corresponding header field is present. +export type IncomingRequestParams = IncomingMessageParams & { + ua: UALike; + ruri: URI; +}; + +export type IncomingResponseParams = IncomingMessageParams & { + status_code: number; + reason_phrase: string; +}; + +export class IncomingMessage { + /* Header store operations. + * + * They are static so that `IncomingMessageBuilder` can collect and parse + * header fields before any `IncomingMessage` exists. The instance methods + * of the same name delegate to them. */ - declare session_expires?: number; - declare session_expires_refresher?: string; - declare refer_to?: Grammar.Grammar; - declare replaces?: Grammar.Grammar; - declare event?: Grammar.Grammar; - - constructor() { - this.data = null; - // NOTE: The original implementation sets `null` here and both subclasses - // immediately replace it with `{}`. An empty object is used instead so - // that the type does not have to be nullable. Nothing can observe the - // difference since `IncomingMessage` is never instantiated directly. - this.headers = {}; - this.method = null; - this.via = null; - this.via_branch = null; - this.call_id = null; - this.cseq = null; - this.from = null; - this.from_tag = null; - this.to = null; - this.to_tag = null; - this.body = null; - this.sdp = null; - } /** * Insert a header of the given name and value into the last position of the - * header array. + * given header store. */ - addHeader(name: string, value: string): void { + static addHeader( + headers: IncomingHeaders, + name: string, + value: string + ): void { const header: IncomingHeader = { raw: value }; name = headerize(name); - const headers = this.headers[name]; + const existing = headers[name]; - if (headers) { - headers.push(header); + if (existing) { + existing.push(header); } else { - this.headers[name] = [header]; + headers[name] = [header]; } } /** - * Get the value of the given header name at the given position. + * Replace the value of the given header by the value. */ - getHeader(name: string): string | undefined { - const header = this.headers[headerize(name)]; + static setHeader( + headers: IncomingHeaders, + name: string, + value: string + ): void { + headers[headerize(name)] = [{ raw: value }]; + } - if (header) { - if (header[0]) { - return header[0].raw; - } - } else { - return; + /** + * Get the value of the given header name at the first position, or undefined + * if the header field is not present. + */ + static getHeader(headers: IncomingHeaders, name: string): string | undefined { + const header = headers[headerize(name)]; + + if (header?.[0]) { + return header[0].raw; } - // NOTE: Explicit (and otherwise implicit) `undefined` for - // `noImplicitReturns`. - return; + return undefined; } /** * Get the header/s of the given name. */ - getHeaders(name: string): string[] { - const headers = this.headers[headerize(name)]; + static getHeaders(headers: IncomingHeaders, name: string): string[] { + const found = headers[headerize(name)]; const result: string[] = []; - if (!headers) { + if (!found) { return []; } - for (const header of headers) { + for (const header of found) { result.push(header.raw); } @@ -621,33 +627,37 @@ export class IncomingMessage { /** * Verify the existence of the given header. */ - hasHeader(name: string): boolean { - return this.headers[headerize(name)] ? true : false; + static hasHeader(headers: IncomingHeaders, name: string): boolean { + return headers[headerize(name)] ? true : false; } /** - * Parse the given header on the given index. - * -param {String} name header name - * -param {Number} [idx=0] header index - * -returns {Object|undefined} Parsed header object, undefined if the header - * is not present or in case of a parsing error. + * Parse the given header on the given index, caching the result. */ - parseHeader(name: string, idx = 0): T | undefined { + /** + * Parse the given header on the given index, caching the result. Returns + * undefined if the header field is not present or cannot be parsed. + */ + static parseHeader( + headers: IncomingHeaders, + name: string, + idx = 0 + ): T | undefined { name = headerize(name); - const headers = this.headers[name]; + const found = headers[name]; - if (!headers) { + if (!found) { logger.debug(`header "${name}" not present`); - return; - } else if (idx >= headers.length) { + return undefined; + } else if (idx >= found.length) { logger.debug(`not so many "${name}" headers present`); - return; + return undefined; } - const header = headers[idx]!; + const header = found[idx]!; const value = header.raw; if (header.parsed) { @@ -658,12 +668,12 @@ export class IncomingMessage { const parsed = Grammar.parse(value, name.replace(/-/g, '_')); if (parsed === -1) { - headers.splice(idx, 1); // delete from headers + found.splice(idx, 1); // delete from headers logger.debug( `error parsing "${name}" header field with value "${value}"` ); - return; + return undefined; } else { header.parsed = parsed; @@ -671,6 +681,106 @@ export class IncomingMessage { } } + data: string; + headers: IncomingHeaders; + method: string; + via?: Grammar.Grammar; + via_branch?: string; + call_id?: string; + cseq?: number; + from: NameAddrHeader; + from_tag?: string | null; + to: NameAddrHeader; + to_tag?: string | null; + body: string; + sdp: SDP | null; + + // Only set when the corresponding header field is present. + session_expires?: number; + session_expires_refresher?: string; + refer_to?: Grammar.Grammar; + replaces?: Grammar.Grammar; + event?: Grammar.Grammar; + + constructor({ + data, + headers, + method, + via, + via_branch, + call_id, + cseq, + from, + from_tag, + to, + to_tag, + body, + session_expires, + session_expires_refresher, + refer_to, + replaces, + event, + }: IncomingMessageParams) { + this.data = data; + this.headers = headers; + this.method = method; + this.via = via; + this.via_branch = via_branch; + this.call_id = call_id; + this.cseq = cseq; + this.from = from; + this.from_tag = from_tag; + this.to = to; + this.to_tag = to_tag; + this.body = body; + this.sdp = null; + this.session_expires = session_expires; + this.session_expires_refresher = session_expires_refresher; + this.refer_to = refer_to; + this.replaces = replaces; + this.event = event; + } + + /** + * Insert a header of the given name and value into the last position of the + * header array. + */ + addHeader(name: string, value: string): void { + IncomingMessage.addHeader(this.headers, name, value); + } + + /** + * Get the value of the given header name at the given position. + */ + getHeader(name: string): string | undefined { + return IncomingMessage.getHeader(this.headers, name); + } + + /** + * Get the header/s of the given name. + */ + getHeaders(name: string): string[] { + return IncomingMessage.getHeaders(this.headers, name); + } + + /** + * Verify the existence of the given header. + */ + hasHeader(name: string): boolean { + return IncomingMessage.hasHeader(this.headers, name); + } + + /** + * Parse the given header on the given index. + * -param {String} name header name + * -param {Number} [idx=0] header index + * -returns {Object|undefined} Parsed header object, undefined if the header + * is not present or in case of a parsing error. + */ + parseHeader(name: string, idx = 0): T | undefined { + return IncomingMessage.parseHeader(this.headers, name, idx); + } + /** * Message Header attribute selector. Alias of parseHeader. * -param {String} name header name @@ -691,9 +801,7 @@ export class IncomingMessage { * -param {String} value header value */ setHeader(name: string, value: string): void { - const header: IncomingHeader = { raw: value }; - - this.headers[headerize(name)] = [header]; + IncomingMessage.setHeader(this.headers, name, value); } /** @@ -713,28 +821,22 @@ export class IncomingMessage { } } - toString(): string | null { + toString(): string { return this.data; } } export class IncomingRequest extends IncomingMessage { - /* NOTE: Declared in the very same order in which the constructor assigns - * them so that the own property order of the instance is preserved. - * `headers` is not redeclared because it is already created by the parent - * constructor. - */ ua: UALike; - ruri: URI | null; + ruri: URI; transport: TransportLike | null; server_transaction: ServerTransactionLike | null; - constructor(ua: UALike) { - super(); + constructor({ ua, ruri, ...params }: IncomingRequestParams) { + super(params); this.ua = ua; - this.headers = {}; - this.ruri = null; + this.ruri = ruri; this.transport = null; this.server_transaction = null; } @@ -757,7 +859,11 @@ export class IncomingRequest extends IncomingMessage { onFailure?: () => void ): void { const supported: string[] = []; - let to = this.getHeader('To')!; + /* NOTE: `getHeader()` may return undefined, but a To header field is + * guaranteed here: `IncomingMessageBuilder` refuses to build a message + * whose To header field is missing or unparseable. + */ + let to = this.getHeader('To'); // NOTE: `code` is normalized into a local (instead of onto the parameter // itself) so that the `TypeError` message below is identical to the one @@ -808,7 +914,7 @@ export class IncomingRequest extends IncomingMessage { if (!this.to_tag && status_code > 100) { to += `;tag=${newTag()}`; - } else if (this.to_tag && !this.s('to')!.hasParam('tag')) { + } else if (this.to_tag && !this.to.hasParam('tag')) { to += `;tag=${this.to_tag}`; } @@ -905,11 +1011,15 @@ export class IncomingRequest extends IncomingMessage { response += `Via: ${via}\r\n`; } - let to = this.getHeader('To')!; + /* NOTE: `getHeader()` may return undefined, but a To header field is + * guaranteed here: `IncomingMessageBuilder` refuses to build a message + * whose To header field is missing or unparseable. + */ + let to = this.getHeader('To'); if (!this.to_tag && code > 100) { to += `;tag=${newTag()}`; - } else if (this.to_tag && !this.s('to')!.hasParam('tag')) { + } else if (this.to_tag && !this.to.hasParam('tag')) { to += `;tag=${this.to_tag}`; } @@ -931,14 +1041,17 @@ export class IncomingRequest extends IncomingMessage { } export class IncomingResponse extends IncomingMessage { - status_code: number | null; - reason_phrase: string | null; - - constructor() { - super(); - - this.headers = {}; - this.status_code = null; - this.reason_phrase = null; + status_code: number; + reason_phrase: string; + + constructor({ + status_code, + reason_phrase, + ...params + }: IncomingResponseParams) { + super(params); + + this.status_code = status_code; + this.reason_phrase = reason_phrase; } } diff --git a/src/URI.ts b/src/URI.ts index 371d0973..e3ebb2fa 100644 --- a/src/URI.ts +++ b/src/URI.ts @@ -27,9 +27,9 @@ class URI { } #scheme: string; - #user: string | undefined; + #user?: string; #host: string; - #port: number | null | undefined; + #port?: number | null; #parameters: URI.Parameters; #headers: URI.Headers; diff --git a/src/test/test-DigestAuthentication.ts b/src/test/test-DigestAuthentication.ts index 3acad502..360c207d 100644 --- a/src/test/test-DigestAuthentication.ts +++ b/src/test/test-DigestAuthentication.ts @@ -20,7 +20,6 @@ describe('DigestAuthentication', () => { realm: 'testrealm@host.com', nonce: 'dcd98b7102dd2f0e8b11d0f600bfb0c093', opaque: '5ccc069c403ebaf9f0171e9517f40e41', - stale: null, qop: 'auth', }; @@ -44,9 +43,6 @@ describe('DigestAuthentication', () => { algorithm: 'MD5', realm: 'testrealm@host.com', nonce: '5a071f75353f667787615249c62dcc7b15a4828f', - opaque: null, - stale: null, - qop: null, }; const digest = new DigestAuthentication(credentials); @@ -70,8 +66,6 @@ describe('DigestAuthentication', () => { algorithm: 'MD5', realm: 'testrealm@host.com', nonce: '5a071f75353f667787615249c62dcc7b15a4828f', - opaque: null, - stale: null, qop: 'auth', }; @@ -96,8 +90,6 @@ describe('DigestAuthentication', () => { algorithm: 'MD5', realm: 'testrealm@host.com', nonce: '5a071f75353f667787615249c62dcc7b15a4828f', - opaque: null, - stale: null, qop: 'auth-int', }; @@ -123,8 +115,6 @@ describe('DigestAuthentication', () => { algorithm: 'MD5', realm: 'testrealm@host.com', nonce: '5a071f75353f667787615249c62dcc7b15a4828f', - opaque: null, - stale: null, qop: 'auth-int', }; From 379feb96c3c1dfb6c728f2e2996e0cd13439cf41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Luis=20Mill=C3=A1n?= Date: Tue, 20 Oct 2026 15:53:09 +0200 Subject: [PATCH 3/4] IncomingMessageBuilder --- src/IncomingMessageBuilder.ts | 356 ++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 src/IncomingMessageBuilder.ts diff --git a/src/IncomingMessageBuilder.ts b/src/IncomingMessageBuilder.ts new file mode 100644 index 00000000..33e933e2 --- /dev/null +++ b/src/IncomingMessageBuilder.ts @@ -0,0 +1,356 @@ +import { Logger } from './Logger'; +import * as Grammar from './Grammar'; +import NameAddrHeader = require('./NameAddrHeader'); +import URI = require('./URI'); +import { + IncomingMessage, + IncomingMessageParams, + IncomingRequest, + IncomingResponse, + UALike, +} from './SIPMessage'; +import type { IncomingHeaders } from './SIPMessage'; + +const logger = new Logger('IncomingMessageBuilder'); + +/** + * Collects the header fields of an incoming SIP message as they are scanned + * and, once the whole message has been read, builds the corresponding + * `IncomingRequest` or `IncomingResponse`. + * + * It exists so that every mandatory field of an `IncomingMessage` is provided + * at construction time, rather than assigned onto a half built instance. + */ +export class IncomingMessageBuilder { + #headers: IncomingHeaders = {}; + + #via?: Grammar.Grammar; + #via_branch?: string; + #call_id?: string; + #cseq?: number; + /* NOTE: The method as given by the CSeq header field. A response takes its + * `method` from here, a request takes it from the request line. + */ + #cseq_method?: string; + #from?: NameAddrHeader; + #to?: NameAddrHeader; + /* NOTE: `null` when the tag parameter is present but valueless (`;tag`), + * `undefined` when it is absent altogether. + */ + #from_tag?: string | null; + #to_tag?: string | null; + + // Only set when the corresponding header field is present. + #session_expires?: number; + #session_expires_refresher?: string; + #refer_to?: Grammar.Grammar; + #replaces?: Grammar.Grammar; + #event?: Grammar.Grammar; + + /** + * Whether the given header field is present. + */ + hasHeader(name: string): boolean { + return IncomingMessage.hasHeader(this.#headers, name); + } + + /** + * Value of the given header field, or undefined if not present. + */ + getHeader(name: string): string | undefined { + return IncomingMessage.getHeader(this.#headers, name); + } + + /** + * Interpret a single raw header field, storing it and, when it is a well + * known one, extracting the message attributes it carries. + * + * Returns false if the header field is present but cannot be parsed. + */ + addRawHeader(name: string, value: string): boolean { + let parsed; + + // If header-field is well-known, parse it. + switch (name.toLowerCase()) { + case 'via': + case 'v': { + IncomingMessage.addHeader(this.#headers, 'via', value); + if (IncomingMessage.getHeaders(this.#headers, 'via').length === 1) { + parsed = IncomingMessage.parseHeader( + this.#headers, + 'Via' + ); + if (parsed) { + this.#via = parsed; + this.#via_branch = parsed.branch; + } + } else { + parsed = 0; + } + break; + } + case 'from': + case 'f': { + IncomingMessage.setHeader(this.#headers, 'from', value); + parsed = IncomingMessage.parseHeader( + this.#headers, + 'from' + ); + if (parsed) { + this.#from = parsed; + this.#from_tag = parsed.getParam('tag'); + } + break; + } + case 'to': + case 't': { + IncomingMessage.setHeader(this.#headers, 'to', value); + parsed = IncomingMessage.parseHeader( + this.#headers, + 'to' + ); + if (parsed) { + this.#to = parsed; + this.#to_tag = parsed.getParam('tag'); + } + break; + } + case 'record-route': { + parsed = Grammar.parse(value, 'Record_Route'); + + if (parsed === -1) { + parsed = undefined; + } else { + for (const header of parsed) { + IncomingMessage.addHeader( + this.#headers, + 'record-route', + value.substring(header.possition, header.offset) + ); + this.#headers['Record-Route']![ + IncomingMessage.getHeaders(this.#headers, 'record-route').length - + 1 + ]!.parsed = header.parsed; + } + } + break; + } + case 'call-id': + case 'i': { + IncomingMessage.setHeader(this.#headers, 'call-id', value); + parsed = IncomingMessage.parseHeader(this.#headers, 'call-id'); + if (parsed) { + this.#call_id = value; + } + break; + } + case 'contact': + case 'm': { + parsed = Grammar.parse(value, 'Contact'); + + if (parsed === -1) { + parsed = undefined; + } else { + for (const header of parsed) { + IncomingMessage.addHeader( + this.#headers, + 'contact', + value.substring(header.possition, header.offset) + ); + this.#headers['Contact']![ + IncomingMessage.getHeaders(this.#headers, 'contact').length - 1 + ]!.parsed = header.parsed; + } + } + break; + } + case 'content-length': + case 'l': { + IncomingMessage.setHeader(this.#headers, 'content-length', value); + parsed = IncomingMessage.parseHeader(this.#headers, 'content-length'); + break; + } + case 'content-type': + case 'c': { + IncomingMessage.setHeader(this.#headers, 'content-type', value); + parsed = IncomingMessage.parseHeader(this.#headers, 'content-type'); + break; + } + case 'cseq': { + IncomingMessage.setHeader(this.#headers, 'cseq', value); + parsed = IncomingMessage.parseHeader( + this.#headers, + 'cseq' + ); + if (parsed) { + this.#cseq = parsed.value; + + /* NOTE: Only a response uses it, but it is recorded + * unconditionally so that the builder does not need to know + * which kind of message is being built. + */ + this.#cseq_method = parsed.method; + } + break; + } + case 'max-forwards': { + IncomingMessage.setHeader(this.#headers, 'max-forwards', value); + parsed = IncomingMessage.parseHeader(this.#headers, 'max-forwards'); + break; + } + case 'www-authenticate': { + IncomingMessage.setHeader(this.#headers, 'www-authenticate', value); + parsed = IncomingMessage.parseHeader(this.#headers, 'www-authenticate'); + break; + } + case 'proxy-authenticate': { + IncomingMessage.setHeader(this.#headers, 'proxy-authenticate', value); + parsed = IncomingMessage.parseHeader( + this.#headers, + 'proxy-authenticate' + ); + break; + } + case 'session-expires': + case 'x': { + IncomingMessage.setHeader(this.#headers, 'session-expires', value); + parsed = IncomingMessage.parseHeader( + this.#headers, + 'session-expires' + ); + if (parsed) { + this.#session_expires = parsed.expires; + this.#session_expires_refresher = parsed.refresher; + } + break; + } + case 'refer-to': + case 'r': { + IncomingMessage.setHeader(this.#headers, 'refer-to', value); + parsed = IncomingMessage.parseHeader( + this.#headers, + 'refer-to' + ); + if (parsed) { + this.#refer_to = parsed; + } + break; + } + case 'replaces': { + IncomingMessage.setHeader(this.#headers, 'replaces', value); + parsed = IncomingMessage.parseHeader( + this.#headers, + 'replaces' + ); + if (parsed) { + this.#replaces = parsed; + } + break; + } + case 'event': + case 'o': { + IncomingMessage.setHeader(this.#headers, 'event', value); + parsed = IncomingMessage.parseHeader( + this.#headers, + 'event' + ); + if (parsed) { + this.#event = parsed; + } + break; + } + default: { + // Do not parse this header. + IncomingMessage.addHeader(this.#headers, name, value); + parsed = 0; + } + } + + return parsed !== undefined; + } + + /** + * Build the `IncomingRequest`, or undefined if a mandatory header field is + * missing. + */ + buildRequest( + ua: UALike, + data: string, + method: string, + ruri: URI, + body: string + ): IncomingRequest | undefined { + const params = this.#params(data, method, body); + + if (!params) { + return undefined; + } + + return new IncomingRequest({ ...params, ua, ruri }); + } + + /** + * Build the `IncomingResponse`, or undefined if a mandatory header field is + * missing. + */ + buildResponse( + data: string, + status_code: number, + reason_phrase: string, + body: string + ): IncomingResponse | undefined { + // RFC 3261 8.1.3.3. A response takes its method from its CSeq. + if (this.#cseq_method === undefined) { + logger.debug('missing mandatory header field : cseq, dropping message'); + + return undefined; + } + + const params = this.#params(data, this.#cseq_method, body); + + if (!params) { + return undefined; + } + + return new IncomingResponse({ ...params, status_code, reason_phrase }); + } + + #params( + data: string, + method: string, + body: string + ): IncomingMessageParams | undefined { + // RFC 3261 8.1.1. From and To are mandatory in every SIP message. + if (this.#from === undefined) { + logger.debug('missing mandatory header field : from, dropping message'); + + return undefined; + } + + if (this.#to === undefined) { + logger.debug('missing mandatory header field : to, dropping message'); + + return undefined; + } + + return { + data, + headers: this.#headers, + method, + via: this.#via, + via_branch: this.#via_branch, + call_id: this.#call_id, + cseq: this.#cseq, + from: this.#from, + from_tag: this.#from_tag, + to: this.#to, + to_tag: this.#to_tag, + body, + session_expires: this.#session_expires, + session_expires_refresher: this.#session_expires_refresher, + refer_to: this.#refer_to, + replaces: this.#replaces, + event: this.#event, + }; + } +} From 2b3c588dcd3d401a9911cc685d9a023a2bb36389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Luis=20Mill=C3=A1n?= Date: Tue, 20 Oct 2026 16:04:15 +0200 Subject: [PATCH 4/4] More types --- src/Grammar.d.ts | 74 +++++++++++++++++++++++++++++++++++ src/IncomingMessageBuilder.ts | 20 +++++----- src/SIPMessage.ts | 16 ++++---- 3 files changed, 92 insertions(+), 18 deletions(-) diff --git a/src/Grammar.d.ts b/src/Grammar.d.ts index 3baa70c1..6f69b8c0 100644 --- a/src/Grammar.d.ts +++ b/src/Grammar.d.ts @@ -1,6 +1,80 @@ +import NameAddrHeader = require('./NameAddrHeader'); + +/** + * The result of `parse()`, whose shape depends on the start rule used. + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Grammar = any; +/** + * Generic header field parameters, as collected by the `generic_param` rule. + */ +export type GenericParams = Record; + +/** + * How a host was written, as set by the `hostname`, `IPv4address` and + * `IPv6address` rules. + */ +export type HostType = 'domain' | 'IPv4' | 'IPv6'; + +/** + * Result of the `Via` start rule. + */ +export type ViaHeader = { + protocol: string; + transport: string; + host: string; + host_type: HostType; + port?: number; + ttl?: number; + maddr?: string; + received?: string; + branch?: string; + rport?: number; + params?: GenericParams; +}; + +/** + * Result of the `CSeq` start rule. + */ +export type CSeqHeader = { + value: number; + method: string; +}; + +/** + * Result of the `Session_Expires` start rule. + */ +export type SessionExpiresHeader = { + expires: number; + refresher?: 'uac' | 'uas'; + params?: GenericParams; +}; + +/** + * Result of the `Replaces` start rule. + */ +export type ReplacesHeader = { + call_id: string; + to_tag?: string; + from_tag?: string; + early_only?: true; + params?: GenericParams; +}; + +/** + * Result of the `Event` start rule. + */ +export type EventHeader = { + event: string; + params?: GenericParams; +}; + +/** + * Result of the `Refer_To` start rule. + */ +export type ReferToHeader = NameAddrHeader; + export function parse(input: string, startRule?: string): Grammar; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/IncomingMessageBuilder.ts b/src/IncomingMessageBuilder.ts index 33e933e2..a75280f7 100644 --- a/src/IncomingMessageBuilder.ts +++ b/src/IncomingMessageBuilder.ts @@ -24,7 +24,7 @@ const logger = new Logger('IncomingMessageBuilder'); export class IncomingMessageBuilder { #headers: IncomingHeaders = {}; - #via?: Grammar.Grammar; + #via?: Grammar.ViaHeader; #via_branch?: string; #call_id?: string; #cseq?: number; @@ -43,9 +43,9 @@ export class IncomingMessageBuilder { // Only set when the corresponding header field is present. #session_expires?: number; #session_expires_refresher?: string; - #refer_to?: Grammar.Grammar; - #replaces?: Grammar.Grammar; - #event?: Grammar.Grammar; + #refer_to?: Grammar.ReferToHeader; + #replaces?: Grammar.ReplacesHeader; + #event?: Grammar.EventHeader; /** * Whether the given header field is present. @@ -76,7 +76,7 @@ export class IncomingMessageBuilder { case 'v': { IncomingMessage.addHeader(this.#headers, 'via', value); if (IncomingMessage.getHeaders(this.#headers, 'via').length === 1) { - parsed = IncomingMessage.parseHeader( + parsed = IncomingMessage.parseHeader( this.#headers, 'Via' ); @@ -178,7 +178,7 @@ export class IncomingMessageBuilder { } case 'cseq': { IncomingMessage.setHeader(this.#headers, 'cseq', value); - parsed = IncomingMessage.parseHeader( + parsed = IncomingMessage.parseHeader( this.#headers, 'cseq' ); @@ -214,7 +214,7 @@ export class IncomingMessageBuilder { case 'session-expires': case 'x': { IncomingMessage.setHeader(this.#headers, 'session-expires', value); - parsed = IncomingMessage.parseHeader( + parsed = IncomingMessage.parseHeader( this.#headers, 'session-expires' ); @@ -227,7 +227,7 @@ export class IncomingMessageBuilder { case 'refer-to': case 'r': { IncomingMessage.setHeader(this.#headers, 'refer-to', value); - parsed = IncomingMessage.parseHeader( + parsed = IncomingMessage.parseHeader( this.#headers, 'refer-to' ); @@ -238,7 +238,7 @@ export class IncomingMessageBuilder { } case 'replaces': { IncomingMessage.setHeader(this.#headers, 'replaces', value); - parsed = IncomingMessage.parseHeader( + parsed = IncomingMessage.parseHeader( this.#headers, 'replaces' ); @@ -250,7 +250,7 @@ export class IncomingMessageBuilder { case 'event': case 'o': { IncomingMessage.setHeader(this.#headers, 'event', value); - parsed = IncomingMessage.parseHeader( + parsed = IncomingMessage.parseHeader( this.#headers, 'event' ); diff --git a/src/SIPMessage.ts b/src/SIPMessage.ts index d0c78874..7e632b71 100644 --- a/src/SIPMessage.ts +++ b/src/SIPMessage.ts @@ -522,7 +522,7 @@ export type IncomingMessageParams = { headers: IncomingHeaders; method: string; // Undefined when the corresponding header field is missing or unparseable. - via?: Grammar.Grammar; + via?: Grammar.ViaHeader; via_branch?: string; call_id?: string; cseq?: number; @@ -536,9 +536,9 @@ export type IncomingMessageParams = { body: string; session_expires?: number; session_expires_refresher?: string; - refer_to?: Grammar.Grammar; - replaces?: Grammar.Grammar; - event?: Grammar.Grammar; + refer_to?: Grammar.ReferToHeader; + replaces?: Grammar.ReplacesHeader; + event?: Grammar.EventHeader; }; export type IncomingRequestParams = IncomingMessageParams & { @@ -684,7 +684,7 @@ export class IncomingMessage { data: string; headers: IncomingHeaders; method: string; - via?: Grammar.Grammar; + via?: Grammar.ViaHeader; via_branch?: string; call_id?: string; cseq?: number; @@ -698,9 +698,9 @@ export class IncomingMessage { // Only set when the corresponding header field is present. session_expires?: number; session_expires_refresher?: string; - refer_to?: Grammar.Grammar; - replaces?: Grammar.Grammar; - event?: Grammar.Grammar; + refer_to?: Grammar.ReferToHeader; + replaces?: Grammar.ReplacesHeader; + event?: Grammar.EventHeader; constructor({ data,