Commit 1729a27 for jssip.net

commit 1729a2749647c46e3cf48b0ee71820b399c79eed
Author: José Luis Millán <jmillan@aliax.net>
Date:   Tue Oct 20 16:10:34 2026 +0200

    TS: move SIPMessage (#985)

diff --git a/src/DigestAuthentication.ts b/src/DigestAuthentication.ts
index a89a09b..35dbbb2 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/Grammar.d.ts b/src/Grammar.d.ts
index 3baa70c..6f69b8c 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<string, string | null>;
+
+/**
+ * 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
new file mode 100644
index 0000000..a75280f
--- /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.ViaHeader;
+	#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.ReferToHeader;
+	#replaces?: Grammar.ReplacesHeader;
+	#event?: Grammar.EventHeader;
+
+	/**
+	 * 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<Grammar.ViaHeader>(
+						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<NameAddrHeader>(
+					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<NameAddrHeader>(
+					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<Grammar.CSeqHeader>(
+					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<Grammar.SessionExpiresHeader>(
+					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<Grammar.ReferToHeader>(
+					this.#headers,
+					'refer-to'
+				);
+				if (parsed) {
+					this.#refer_to = parsed;
+				}
+				break;
+			}
+			case 'replaces': {
+				IncomingMessage.setHeader(this.#headers, 'replaces', value);
+				parsed = IncomingMessage.parseHeader<Grammar.ReplacesHeader>(
+					this.#headers,
+					'replaces'
+				);
+				if (parsed) {
+					this.#replaces = parsed;
+				}
+				break;
+			}
+			case 'event':
+			case 'o': {
+				IncomingMessage.setHeader(this.#headers, 'event', value);
+				parsed = IncomingMessage.parseHeader<Grammar.EventHeader>(
+					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,
+		};
+	}
+}
diff --git a/src/NameAddrHeader.ts b/src/NameAddrHeader.ts
index 2837b77..87953ce 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 a2e67be..d2fc29a 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.d.ts b/src/SIPMessage.d.ts
deleted file mode 100644
index 40da3cd..0000000
--- 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<T = unknown>(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 44b08e7..0000000
--- 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:
- * <br>
- *  - 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 0000000..7e632b7
--- /dev/null
+++ b/src/SIPMessage.ts
@@ -0,0 +1,1057 @@
+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:
+ * <br>
+ *  - 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 {
+	ua: UALike;
+	headers: Record<string, OutgoingHeaderValue[]>;
+	method: string;
+	ruri: URI;
+	body?: string;
+	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 type IncomingHeaders = Record<string, IncomingHeader[]>;
+
+/**
+ * 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.ViaHeader;
+	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.
+	 */
+	from_tag?: string | null;
+	to: NameAddrHeader;
+	to_tag?: string | null;
+	body: string;
+	session_expires?: number;
+	session_expires_refresher?: string;
+	refer_to?: Grammar.ReferToHeader;
+	replaces?: Grammar.ReplacesHeader;
+	event?: Grammar.EventHeader;
+};
+
+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.
+	 */
+
+	/**
+	 * Insert a header of the given name and value into the last position of the
+	 * given header store.
+	 */
+	static addHeader(
+		headers: IncomingHeaders,
+		name: string,
+		value: string
+	): void {
+		const header: IncomingHeader = { raw: value };
+
+		name = headerize(name);
+
+		const existing = headers[name];
+
+		if (existing) {
+			existing.push(header);
+		} else {
+			headers[name] = [header];
+		}
+	}
+
+	/**
+	 * Replace the value of the given header by the value.
+	 */
+	static setHeader(
+		headers: IncomingHeaders,
+		name: string,
+		value: string
+	): void {
+		headers[headerize(name)] = [{ raw: value }];
+	}
+
+	/**
+	 * 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;
+		}
+
+		return undefined;
+	}
+
+	/**
+	 * Get the header/s of the given name.
+	 */
+	static getHeaders(headers: IncomingHeaders, name: string): string[] {
+		const found = headers[headerize(name)];
+		const result: string[] = [];
+
+		if (!found) {
+			return [];
+		}
+
+		for (const header of found) {
+			result.push(header.raw);
+		}
+
+		return result;
+	}
+
+	/**
+	 * Verify the existence of the given header.
+	 */
+	static hasHeader(headers: IncomingHeaders, name: string): boolean {
+		return headers[headerize(name)] ? true : false;
+	}
+
+	/**
+	 * Parse the given header on the given index, caching the result.
+	 */
+	/**
+	 * 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<T = unknown>(
+		headers: IncomingHeaders,
+		name: string,
+		idx = 0
+	): T | undefined {
+		name = headerize(name);
+
+		const found = headers[name];
+
+		if (!found) {
+			logger.debug(`header "${name}" not present`);
+
+			return undefined;
+		} else if (idx >= found.length) {
+			logger.debug(`not so many "${name}" headers present`);
+
+			return undefined;
+		}
+
+		const header = found[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) {
+			found.splice(idx, 1); // delete from headers
+			logger.debug(
+				`error parsing "${name}" header field with value "${value}"`
+			);
+
+			return undefined;
+		} else {
+			header.parsed = parsed;
+
+			return parsed as T;
+		}
+	}
+
+	data: string;
+	headers: IncomingHeaders;
+	method: string;
+	via?: Grammar.ViaHeader;
+	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.ReferToHeader;
+	replaces?: Grammar.ReplacesHeader;
+	event?: Grammar.EventHeader;
+
+	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<T = unknown>(name: string, idx = 0): T | undefined {
+		return IncomingMessage.parseHeader<T>(this.headers, name, idx);
+	}
+
+	/**
+	 * 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<T = unknown>(name: string, idx?: number): T | undefined {
+		return this.parseHeader<T>(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 {
+		IncomingMessage.setHeader(this.headers, name, value);
+	}
+
+	/**
+	 * 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 {
+		return this.data;
+	}
+}
+
+export class IncomingRequest extends IncomingMessage {
+	ua: UALike;
+	ruri: URI;
+	transport: TransportLike | null;
+	server_transaction: ServerTransactionLike | null;
+
+	constructor({ ua, ruri, ...params }: IncomingRequestParams) {
+		super(params);
+
+		this.ua = ua;
+		this.ruri = ruri;
+		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[] = [];
+		/* 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
+		// 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.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`;
+		}
+
+		/* 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.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;
+	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 371d097..e3ebb2f 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 3acad50..360c207 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',
 		};