Commit 3cf59dc for jssip.net

commit 3cf59dc7cb0ae47ea8180b912ca3e5b38e0b3f86
Author: José Luis Millán <jmillan@aliax.net>
Date:   Tue Oct 20 12:21:52 2026 +0200

    TS: move Utils, URI, NameAddress, Socket, WebSocketInterface, DigestAuthentication (#984)

diff --git a/eslint.config.mjs b/eslint.config.mjs
index 96fed9a..d8122a8 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -135,6 +135,10 @@ const config = tsEslint.config(
 			],
 			'@typescript-eslint/prefer-function-type': 0,
 			'@typescript-eslint/dot-notation': 0,
+			// `import X = require('./X')` is the only way to consume a module that
+			// uses `export =` while `esModuleInterop` is disabled. Bare `require()`
+			// calls remain forbidden.
+			'@typescript-eslint/no-require-imports': [2, { allowAsImport: true }],
 			'@typescript-eslint/no-unused-vars': [
 				2,
 				{
diff --git a/src/JsSIP.d.ts b/src/JsSIP.d.ts
index e9a896e..fbf377e 100644
--- a/src/JsSIP.d.ts
+++ b/src/JsSIP.d.ts
@@ -7,11 +7,13 @@ import * as Utils from './Utils';

 export { C, Exceptions, Grammar, Utils };

+import URI = require('./URI');
+import NameAddrHeader = require('./NameAddrHeader');
+
 export { UA } from './UA';
-export { URI } from './URI';
-export { NameAddrHeader } from './NameAddrHeader';
+export { URI, NameAddrHeader };
 export { WebSocketInterface } from './WebSocketInterface';
-export { Socket, WeightedSocket } from './Socket';
+export type { Socket, WeightedSocket } from './Socket';

 export const debug: Debug;
 export const name: string;
diff --git a/src/JsSIP.js b/src/JsSIP.js
index 39481d1..ccd80dd 100644
--- a/src/JsSIP.js
+++ b/src/JsSIP.js
@@ -6,7 +6,7 @@ const UA = require('./UA');
 const URI = require('./URI');
 const NameAddrHeader = require('./NameAddrHeader');
 const Grammar = require('./Grammar');
-const WebSocketInterface = require('./WebSocketInterface');
+const { WebSocketInterface } = require('./WebSocketInterface');
 const debug = require('debug')('JsSIP');
 const RTCSession = require('./RTCSession');

diff --git a/src/Message.d.ts b/src/Message.d.ts
index e0cc24b..7b18260 100644
--- a/src/Message.d.ts
+++ b/src/Message.d.ts
@@ -8,7 +8,7 @@ import {
 	TerminateOptions,
 } from './RTCSession';
 import { IncomingResponse } from './SIPMessage';
-import { NameAddrHeader } from './NameAddrHeader';
+import NameAddrHeader = require('./NameAddrHeader');
 import { causes } from './Constants';

 export interface AcceptOptions extends ExtraHeaders {
diff --git a/src/NameAddrHeader.d.ts b/src/NameAddrHeader.d.ts
deleted file mode 100644
index c897ae4..0000000
--- a/src/NameAddrHeader.d.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { Parameters, URI } from './URI';
-import { Grammar } from './Grammar';
-
-export class NameAddrHeader {
-	get display_name(): string;
-	set display_name(value: string);
-
-	get uri(): URI;
-
-	constructor(uri: URI, display_name?: string, parameters?: Parameters);
-
-	setParam(key: string, value?: string | number | null): void;
-
-	// eslint-disable-next-line @typescript-eslint/no-explicit-any
-	getParam<T = any>(key: string): T;
-
-	hasParam(key: string): boolean;
-
-	deleteParam(key: string): void;
-
-	clearParams(): void;
-
-	clone(): this;
-
-	toString(): string;
-
-	static parse(uri: string): Grammar | undefined;
-}
diff --git a/src/NameAddrHeader.js b/src/NameAddrHeader.js
deleted file mode 100644
index 8119331..0000000
--- a/src/NameAddrHeader.js
+++ /dev/null
@@ -1,118 +0,0 @@
-const URI = require('./URI');
-const Grammar = require('./Grammar');
-
-module.exports = class NameAddrHeader {
-	/**
-	 * Parse the given string and returns a NameAddrHeader instance or undefined if
-	 * it is an invalid NameAddrHeader.
-	 */
-	static parse(name_addr_header) {
-		name_addr_header = Grammar.parse(name_addr_header, 'Name_Addr_Header');
-
-		if (name_addr_header !== -1) {
-			return name_addr_header;
-		} else {
-			return undefined;
-		}
-	}
-
-	constructor(uri, display_name, parameters) {
-		// Checks.
-		if (!uri || !(uri instanceof URI)) {
-			throw new TypeError('missing or invalid "uri" parameter');
-		}
-
-		// Initialize parameters.
-		this._uri = uri;
-		this._parameters = {};
-		this.display_name = display_name;
-
-		for (const param in parameters) {
-			if (Object.prototype.hasOwnProperty.call(parameters, param)) {
-				this.setParam(param, parameters[param]);
-			}
-		}
-	}
-
-	get uri() {
-		return this._uri;
-	}
-
-	get display_name() {
-		return this._display_name;
-	}
-
-	set display_name(value) {
-		this._display_name = value === 0 ? '0' : value;
-	}
-
-	setParam(key, value) {
-		if (key) {
-			this._parameters[key.toLowerCase()] =
-				typeof value === 'undefined' || value === null
-					? null
-					: value.toString();
-		}
-	}
-
-	getParam(key) {
-		if (key) {
-			return this._parameters[key.toLowerCase()];
-		}
-	}
-
-	hasParam(key) {
-		if (key) {
-			return (
-				(this._parameters.hasOwnProperty(key.toLowerCase()) && true) || false
-			);
-		}
-	}
-
-	deleteParam(parameter) {
-		parameter = parameter.toLowerCase();
-		if (this._parameters.hasOwnProperty(parameter)) {
-			const value = this._parameters[parameter];
-
-			delete this._parameters[parameter];
-
-			return value;
-		}
-	}
-
-	clearParams() {
-		this._parameters = {};
-	}
-
-	clone() {
-		return new NameAddrHeader(
-			this._uri.clone(),
-			this._display_name,
-			JSON.parse(JSON.stringify(this._parameters))
-		);
-	}
-
-	_quote(str) {
-		return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
-	}
-
-	toString() {
-		let body = this._display_name
-			? `"${this._quote(this._display_name)}" `
-			: '';
-
-		body += `<${this._uri.toString()}>`;
-
-		for (const parameter in this._parameters) {
-			if (Object.prototype.hasOwnProperty.call(this._parameters, parameter)) {
-				body += `;${parameter}`;
-
-				if (this._parameters[parameter] !== null) {
-					body += `=${this._parameters[parameter]}`;
-				}
-			}
-		}
-
-		return body;
-	}
-};
diff --git a/src/NameAddrHeader.ts b/src/NameAddrHeader.ts
new file mode 100644
index 0000000..2837b77
--- /dev/null
+++ b/src/NameAddrHeader.ts
@@ -0,0 +1,144 @@
+import * as Grammar from './Grammar';
+import URI = require('./URI');
+
+class NameAddrHeader {
+	/**
+	 * Parse the given string and returns a NameAddrHeader instance or undefined if
+	 * it is an invalid NameAddrHeader.
+	 */
+	static parse(name_addr_header: string): NameAddrHeader | undefined {
+		const parsed = Grammar.parse(name_addr_header, 'Name_Addr_Header') as
+			| NameAddrHeader
+			| -1;
+
+		if (parsed !== -1) {
+			return parsed;
+		} else {
+			return undefined;
+		}
+	}
+
+	#uri: URI;
+	#display_name: string | undefined;
+	#parameters: URI.Parameters;
+
+	constructor(
+		uri: URI,
+		display_name?: string,
+		parameters?: URI.Parameters | null
+	) {
+		// Checks.
+		if (!uri || !(uri instanceof URI)) {
+			throw new TypeError('missing or invalid "uri" parameter');
+		}
+
+		// Initialize parameters.
+		this.#uri = uri;
+		this.#parameters = {};
+		this.display_name = display_name;
+
+		for (const param in parameters) {
+			if (Object.prototype.hasOwnProperty.call(parameters, param)) {
+				this.setParam(param, parameters[param]);
+			}
+		}
+	}
+
+	get uri(): URI {
+		return this.#uri;
+	}
+
+	get display_name(): string | undefined {
+		return this.#display_name;
+	}
+
+	set display_name(value: string | number | undefined) {
+		this.#display_name = value === 0 ? '0' : (value as string | undefined);
+	}
+
+	setParam(key: string, value?: string | number | null): void {
+		if (key) {
+			this.#parameters[key.toLowerCase()] =
+				typeof value === 'undefined' || value === null
+					? null
+					: value.toString();
+		}
+	}
+
+	getParam(key: string): string | null | undefined {
+		if (key) {
+			return this.#parameters[key.toLowerCase()];
+		}
+
+		return undefined;
+	}
+
+	hasParam(key: string): boolean | undefined {
+		if (key) {
+			return (
+				(Object.prototype.hasOwnProperty.call(
+					this.#parameters,
+					key.toLowerCase()
+				) &&
+					true) ||
+				false
+			);
+		}
+
+		return undefined;
+	}
+
+	deleteParam(parameter: string): string | null | undefined {
+		parameter = parameter.toLowerCase();
+		if (Object.prototype.hasOwnProperty.call(this.#parameters, parameter)) {
+			const value = this.#parameters[parameter];
+
+			delete this.#parameters[parameter];
+
+			return value;
+		}
+
+		return undefined;
+	}
+
+	clearParams(): void {
+		this.#parameters = {};
+	}
+
+	clone(): NameAddrHeader {
+		return new NameAddrHeader(
+			this.#uri.clone(),
+			this.#display_name,
+			JSON.parse(JSON.stringify(this.#parameters)) as URI.Parameters
+		);
+	}
+
+	#quote(str: string): string {
+		return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+	}
+
+	toString(): string {
+		let body = this.#display_name
+			? `"${this.#quote(this.#display_name)}" `
+			: '';
+
+		body += `<${this.#uri.toString()}>`;
+
+		for (const parameter in this.#parameters) {
+			if (Object.prototype.hasOwnProperty.call(this.#parameters, parameter)) {
+				body += `;${parameter}`;
+
+				if (this.#parameters[parameter] !== null) {
+					body += `=${this.#parameters[parameter]}`;
+				}
+			}
+		}
+
+		return body;
+	}
+}
+
+// NOTE: Exported with `export =` because the pegjs generated `Grammar.js` does
+// `var NameAddrHeader = require('./NameAddrHeader')` and then
+// `new NameAddrHeader(...)`.
+export = NameAddrHeader;
diff --git a/src/RTCSession.d.ts b/src/RTCSession.d.ts
index 4e03d92..232669e 100644
--- a/src/RTCSession.d.ts
+++ b/src/RTCSession.d.ts
@@ -5,8 +5,8 @@ import {
 	IncomingResponse,
 	OutgoingRequest,
 } from './SIPMessage';
-import { NameAddrHeader } from './NameAddrHeader';
-import { URI } from './URI';
+import NameAddrHeader = require('./NameAddrHeader');
+import URI = require('./URI');
 import { UA } from './UA';
 import { causes, DTMF_TRANSPORT } from './Constants';

diff --git a/src/RTCSession/ReferSubscriber.js b/src/RTCSession/ReferSubscriber.js
index e8aca9b..91d4065 100644
--- a/src/RTCSession/ReferSubscriber.js
+++ b/src/RTCSession/ReferSubscriber.js
@@ -53,7 +53,7 @@ module.exports = class ReferSubscriber extends EventEmitter {
 				header.toLowerCase().startsWith('referred-by:')
 			)
 		) {
-			const referredBy = `Referred-By: <${this._session._ua._configuration.uri._scheme}:${this._session._ua._configuration.uri._user}@${this._session._ua._configuration.uri._host}>`;
+			const referredBy = `Referred-By: <${this._session._ua._configuration.uri.scheme}:${this._session._ua._configuration.uri.user}@${this._session._ua._configuration.uri.host}>`;

 			extraHeaders.push(referredBy);
 		}
diff --git a/src/SIPMessage.d.ts b/src/SIPMessage.d.ts
index 31a20a9..40da3cd 100644
--- a/src/SIPMessage.d.ts
+++ b/src/SIPMessage.d.ts
@@ -1,5 +1,5 @@
-import { NameAddrHeader } from './NameAddrHeader';
-import { URI } from './URI';
+import NameAddrHeader = require('./NameAddrHeader');
+import URI = require('./URI');

 declare class IncomingMessage {
 	method: string;
diff --git a/src/Socket.d.ts b/src/Socket.d.ts
deleted file mode 100644
index 034ac62..0000000
--- a/src/Socket.d.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-export interface WeightedSocket {
-	socket: Socket;
-	weight: number;
-}
-
-export class Socket {
-	get via_transport(): string;
-	set via_transport(value: string);
-
-	get url(): string;
-
-	get sip_uri(): string;
-
-	connect(): void;
-
-	disconnect(): void;
-
-	send(message: string | ArrayBufferLike | Blob | ArrayBufferView): boolean;
-
-	isConnected(): boolean;
-
-	isConnecting(): boolean;
-
-	onconnect(): void;
-
-	ondisconnect(error: boolean, code?: number, reason?: string): void;
-
-	ondata<T>(event: T): void;
-}
diff --git a/src/Socket.js b/src/Socket.ts
similarity index 55%
rename from src/Socket.js
rename to src/Socket.ts
index 5c9ffc1..5c6e5a0 100644
--- a/src/Socket.js
+++ b/src/Socket.ts
@@ -1,29 +1,42 @@
-const { Logger } = require('./Logger');
-const Utils = require('./Utils');
-const Grammar = require('./Grammar');
+import { Logger } from './Logger';
+import { isFunction, isString } from './Utils';
+import * as Grammar from './Grammar';

 const logger = new Logger('Socket');

 /**
  * Interface documentation: https://jssip.net/documentation/$last_version/api/socket/
- *
- * interface Socket {
- *  attribute String via_transport
- *  attribute String url
- *  attribute String sip_uri
- *
- *  method connect();
- *  method disconnect();
- *  method send(data);
- *
- *  attribute EventHandler onconnect
- *  attribute EventHandler ondisconnect
- *  attribute EventHandler ondata
- * }
- *
  */
+export interface Socket {
+	readonly url: string;

-exports.isSocket = socket => {
+	readonly sip_uri: string;
+
+	via_transport: string;
+
+	connect(): void;
+
+	disconnect(): void;
+
+	send(message: string | ArrayBufferLike | Blob | ArrayBufferView): boolean;
+
+	isConnected(): boolean;
+
+	isConnecting(): boolean;
+
+	onconnect(): void;
+
+	ondisconnect(error: boolean, code?: number, reason?: string): void;
+
+	ondata<T>(event: T): void;
+}
+
+export interface WeightedSocket {
+	socket: Socket;
+	weight: number;
+}
+
+export const isSocket = (socket: unknown): socket is Socket => {
 	// Ignore if an array is given.
 	if (Array.isArray(socket)) {
 		return false;
@@ -35,19 +48,21 @@ exports.isSocket = socket => {
 		return false;
 	}

+	const candidate = socket as Partial<Socket> & Record<string, unknown>;
+
 	// Check Properties.
 	try {
-		if (!Utils.isString(socket.url)) {
+		if (!isString(candidate.url)) {
 			logger.warn('missing or invalid JsSIP.Socket url property');
 			throw new Error('Missing or invalid JsSIP.Socket url property');
 		}

-		if (!Utils.isString(socket.via_transport)) {
+		if (!isString(candidate.via_transport)) {
 			logger.warn('missing or invalid JsSIP.Socket via_transport property');
 			throw new Error('Missing or invalid JsSIP.Socket via_transport property');
 		}

-		if (Grammar.parse(socket.sip_uri, 'SIP_URI') === -1) {
+		if (Grammar.parse(candidate.sip_uri!, 'SIP_URI') === -1) {
 			logger.warn('missing or invalid JsSIP.Socket sip_uri property');
 			throw new Error('missing or invalid JsSIP.Socket sip_uri property');
 		}
@@ -58,7 +73,7 @@ exports.isSocket = socket => {
 	// Check Methods.
 	try {
 		['connect', 'disconnect', 'send'].forEach(method => {
-			if (!Utils.isFunction(socket[method])) {
+			if (!isFunction(candidate[method])) {
 				logger.warn(`missing or invalid JsSIP.Socket method: ${method}`);
 				throw new Error(`Missing or invalid JsSIP.Socket method: ${method}`);
 			}
diff --git a/src/Transport.d.ts b/src/Transport.d.ts
index 0829a9b..8f05346 100644
--- a/src/Transport.d.ts
+++ b/src/Transport.d.ts
@@ -5,6 +5,30 @@ export interface RecoveryOptions {
 	max_interval: number;
 }

-export class Transport extends Socket {
+export class Transport {
 	constructor(sockets: Socket | Socket[], recovery_options?: RecoveryOptions);
+
+	get via_transport(): string;
+
+	get url(): string;
+
+	get sip_uri(): string;
+
+	connect(): void;
+
+	disconnect(): void;
+
+	send(data: string | ArrayBufferLike | Blob | ArrayBufferView): boolean;
+
+	isConnected(): boolean;
+
+	isConnecting(): boolean;
+
+	onconnecting(attempts: number): void;
+
+	onconnect(): void;
+
+	ondisconnect(error: boolean, code?: number, reason?: string): void;
+
+	ondata<T>(event: T): void;
 }
diff --git a/src/UA.d.ts b/src/UA.d.ts
index ae562cc..0bffad4 100644
--- a/src/UA.d.ts
+++ b/src/UA.d.ts
@@ -17,7 +17,7 @@ import { Message, SendMessageOptions } from './Message';
 import { Registrator } from './Registrator';
 import { Notifier } from './Notifier';
 import { Subscriber } from './Subscriber';
-import { URI } from './URI';
+import URI = require('./URI');
 import { causes } from './Constants';

 export interface UnRegisterOptions {
diff --git a/src/URI.d.ts b/src/URI.d.ts
deleted file mode 100644
index eb1502c..0000000
--- a/src/URI.d.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { Grammar } from './Grammar';
-
-export type URIScheme = 'sip' | string;
-
-export type Parameters = Record<string, string | null>;
-
-export type Headers = Record<string, string | string[]>;
-
-export class URI {
-	scheme: URIScheme;
-	user: string;
-	host: string;
-	port: number;
-
-	constructor(
-		scheme: URIScheme,
-		user: string,
-		host: string,
-		port?: number,
-		parameters?: Parameters,
-		headers?: Headers
-	);
-
-	setParam(key: string, value?: string | number | null): void;
-
-	getParam<T = unknown>(key: string): T;
-
-	hasParam(key: string): boolean;
-
-	deleteParam(key: string): void;
-
-	clearParams(): void;
-
-	setHeader(key: string, value: string | string[]): void;
-
-	getHeader(key: string): string[];
-
-	hasHeader(key: string): boolean;
-
-	deleteHeader(key: string): void;
-
-	clearHeaders(): void;
-
-	clone(): this;
-
-	toString(): string;
-
-	toAor(show_port?: boolean): string;
-
-	static parse(uri: string): Grammar | undefined;
-}
diff --git a/src/URI.js b/src/URI.js
deleted file mode 100644
index 8e629a9..0000000
--- a/src/URI.js
+++ /dev/null
@@ -1,224 +0,0 @@
-const JsSIP_C = require('./Constants');
-const Utils = require('./Utils');
-const Grammar = require('./Grammar');
-
-/**
- * -param {String} [scheme]
- * -param {String} [user]
- * -param {String} host
- * -param {String} [port]
- * -param {Object} [parameters]
- * -param {Object} [headers]
- *
- */
-module.exports = class URI {
-	/**
-	 * Parse the given string and returns a JsSIP.URI instance or undefined if
-	 * it is an invalid URI.
-	 */
-	static parse(uri) {
-		uri = Grammar.parse(uri, 'SIP_URI');
-
-		if (uri !== -1) {
-			return uri;
-		} else {
-			return undefined;
-		}
-	}
-
-	constructor(scheme, user, host, port, parameters = {}, headers = {}) {
-		// Checks.
-		if (!host) {
-			throw new TypeError('missing or invalid "host" parameter');
-		}
-
-		// Initialize parameters.
-		this._parameters = {};
-		this._headers = {};
-
-		this._scheme = scheme || JsSIP_C.SIP;
-		this._user = user;
-		this._host = host;
-		this._port = port;
-
-		for (const param in parameters) {
-			if (Object.prototype.hasOwnProperty.call(parameters, param)) {
-				this.setParam(param, parameters[param]);
-			}
-		}
-
-		for (const header in headers) {
-			if (Object.prototype.hasOwnProperty.call(headers, header)) {
-				this.setHeader(header, headers[header]);
-			}
-		}
-	}
-
-	get scheme() {
-		return this._scheme;
-	}
-
-	set scheme(value) {
-		this._scheme = value.toLowerCase();
-	}
-
-	get user() {
-		return this._user;
-	}
-
-	set user(value) {
-		this._user = value;
-	}
-
-	get host() {
-		return this._host;
-	}
-
-	set host(value) {
-		this._host = value.toLowerCase();
-	}
-
-	get port() {
-		return this._port;
-	}
-
-	set port(value) {
-		this._port = value === 0 ? value : parseInt(value, 10) || null;
-	}
-
-	setParam(key, value) {
-		if (key) {
-			this._parameters[key.toLowerCase()] =
-				typeof value === 'undefined' || value === null
-					? null
-					: value.toString();
-		}
-	}
-
-	getParam(key) {
-		if (key) {
-			return this._parameters[key.toLowerCase()];
-		}
-	}
-
-	hasParam(key) {
-		if (key) {
-			return (
-				(this._parameters.hasOwnProperty(key.toLowerCase()) && true) || false
-			);
-		}
-	}
-
-	deleteParam(parameter) {
-		parameter = parameter.toLowerCase();
-		if (this._parameters.hasOwnProperty(parameter)) {
-			const value = this._parameters[parameter];
-
-			delete this._parameters[parameter];
-
-			return value;
-		}
-	}
-
-	clearParams() {
-		this._parameters = {};
-	}
-
-	setHeader(name, value) {
-		this._headers[Utils.headerize(name)] = Array.isArray(value)
-			? value
-			: [value];
-	}
-
-	getHeader(name) {
-		if (name) {
-			return this._headers[Utils.headerize(name)];
-		}
-	}
-
-	hasHeader(name) {
-		if (name) {
-			return (
-				(this._headers.hasOwnProperty(Utils.headerize(name)) && true) || false
-			);
-		}
-	}
-
-	deleteHeader(header) {
-		header = Utils.headerize(header);
-		if (this._headers.hasOwnProperty(header)) {
-			const value = this._headers[header];
-
-			delete this._headers[header];
-
-			return value;
-		}
-	}
-
-	clearHeaders() {
-		this._headers = {};
-	}
-
-	clone() {
-		return new URI(
-			this._scheme,
-			this._user,
-			this._host,
-			this._port,
-			JSON.parse(JSON.stringify(this._parameters)),
-			JSON.parse(JSON.stringify(this._headers))
-		);
-	}
-
-	toString() {
-		const headers = [];
-
-		let uri = `${this._scheme}:`;
-
-		if (this._user) {
-			uri += `${Utils.escapeUser(this._user)}@`;
-		}
-		uri += this._host;
-		if (this._port || this._port === 0) {
-			uri += `:${this._port}`;
-		}
-
-		for (const parameter in this._parameters) {
-			if (Object.prototype.hasOwnProperty.call(this._parameters, parameter)) {
-				uri += `;${parameter}`;
-
-				if (this._parameters[parameter] !== null) {
-					uri += `=${this._parameters[parameter]}`;
-				}
-			}
-		}
-
-		for (const header in this._headers) {
-			if (Object.prototype.hasOwnProperty.call(this._headers, header)) {
-				for (const item of this._headers[header]) {
-					headers.push(`${header}=${item}`);
-				}
-			}
-		}
-
-		if (headers.length > 0) {
-			uri += `?${headers.join('&')}`;
-		}
-
-		return uri;
-	}
-
-	toAor(show_port) {
-		let aor = `${this._scheme}:`;
-
-		if (this._user) {
-			aor += `${Utils.escapeUser(this._user)}@`;
-		}
-		aor += this._host;
-		if (show_port && (this._port || this._port === 0)) {
-			aor += `:${this._port}`;
-		}
-
-		return aor;
-	}
-};
diff --git a/src/URI.ts b/src/URI.ts
new file mode 100644
index 0000000..371d097
--- /dev/null
+++ b/src/URI.ts
@@ -0,0 +1,270 @@
+import { SIP } from './Constants';
+import { escapeUser, headerize } from './Utils';
+import * as Grammar from './Grammar';
+
+/**
+ * -param {String} [scheme]
+ * -param {String} [user]
+ * -param {String} host
+ * -param {String} [port]
+ * -param {Object} [parameters]
+ * -param {Object} [headers]
+ *
+ */
+class URI {
+	/**
+	 * Parse the given string and returns a JsSIP.URI instance or undefined if
+	 * it is an invalid URI.
+	 */
+	static parse(uri: string): URI | undefined {
+		const parsed = Grammar.parse(uri, 'SIP_URI') as URI | -1;
+
+		if (parsed !== -1) {
+			return parsed;
+		} else {
+			return undefined;
+		}
+	}
+
+	#scheme: string;
+	#user: string | undefined;
+	#host: string;
+	#port: number | null | undefined;
+	#parameters: URI.Parameters;
+	#headers: URI.Headers;
+
+	constructor(
+		scheme: URI.URIScheme | undefined,
+		user: string | undefined,
+		host: string,
+		port?: number | null,
+		parameters: URI.Parameters = {},
+		headers: URI.Headers = {}
+	) {
+		// Checks.
+		if (!host) {
+			throw new TypeError('missing or invalid "host" parameter');
+		}
+
+		// Initialize parameters.
+		this.#parameters = {};
+		this.#headers = {};
+
+		this.#scheme = scheme || SIP;
+		this.#user = user;
+		this.#host = host;
+		this.#port = port;
+
+		for (const param in parameters) {
+			if (Object.prototype.hasOwnProperty.call(parameters, param)) {
+				this.setParam(param, parameters[param]);
+			}
+		}
+
+		for (const header in headers) {
+			if (Object.prototype.hasOwnProperty.call(headers, header)) {
+				this.setHeader(header, headers[header]!);
+			}
+		}
+	}
+
+	get scheme(): string {
+		return this.#scheme;
+	}
+
+	set scheme(value: string) {
+		this.#scheme = value.toLowerCase();
+	}
+
+	get user(): string | undefined {
+		return this.#user;
+	}
+
+	set user(value: string | undefined) {
+		this.#user = value;
+	}
+
+	get host(): string {
+		return this.#host;
+	}
+
+	set host(value: string) {
+		this.#host = value.toLowerCase();
+	}
+
+	get port(): number | null | undefined {
+		return this.#port;
+	}
+
+	set port(value: number | string | null | undefined) {
+		this.#port = value === 0 ? value : parseInt(String(value), 10) || null;
+	}
+
+	setParam(key: string, value?: string | number | null): void {
+		if (key) {
+			this.#parameters[key.toLowerCase()] =
+				typeof value === 'undefined' || value === null
+					? null
+					: value.toString();
+		}
+	}
+
+	getParam(key: string): string | null | undefined {
+		if (key) {
+			return this.#parameters[key.toLowerCase()];
+		}
+
+		return undefined;
+	}
+
+	hasParam(key: string): boolean | undefined {
+		if (key) {
+			return (
+				(Object.prototype.hasOwnProperty.call(
+					this.#parameters,
+					key.toLowerCase()
+				) &&
+					true) ||
+				false
+			);
+		}
+
+		return undefined;
+	}
+
+	deleteParam(parameter: string): string | null | undefined {
+		parameter = parameter.toLowerCase();
+		if (Object.prototype.hasOwnProperty.call(this.#parameters, parameter)) {
+			const value = this.#parameters[parameter];
+
+			delete this.#parameters[parameter];
+
+			return value;
+		}
+
+		return undefined;
+	}
+
+	clearParams(): void {
+		this.#parameters = {};
+	}
+
+	setHeader(name: string, value: string | string[]): void {
+		this.#headers[headerize(name)] = Array.isArray(value) ? value : [value];
+	}
+
+	getHeader(name: string): string[] | undefined {
+		if (name) {
+			return this.#headers[headerize(name)];
+		}
+
+		return undefined;
+	}
+
+	hasHeader(name: string): boolean | undefined {
+		if (name) {
+			return (
+				(Object.prototype.hasOwnProperty.call(this.#headers, headerize(name)) &&
+					true) ||
+				false
+			);
+		}
+
+		return undefined;
+	}
+
+	deleteHeader(header: string): string[] | undefined {
+		header = headerize(header);
+		if (Object.prototype.hasOwnProperty.call(this.#headers, header)) {
+			const value = this.#headers[header];
+
+			delete this.#headers[header];
+
+			return value;
+		}
+
+		return undefined;
+	}
+
+	clearHeaders(): void {
+		this.#headers = {};
+	}
+
+	clone(): URI {
+		return new URI(
+			this.#scheme,
+			this.#user,
+			this.#host,
+			this.#port,
+			JSON.parse(JSON.stringify(this.#parameters)) as URI.Parameters,
+			JSON.parse(JSON.stringify(this.#headers)) as URI.Headers
+		);
+	}
+
+	toString(): string {
+		const headers = [];
+
+		let uri = `${this.#scheme}:`;
+
+		if (this.#user) {
+			uri += `${escapeUser(this.#user)}@`;
+		}
+		uri += this.#host;
+		if (this.#port || this.#port === 0) {
+			uri += `:${this.#port}`;
+		}
+
+		for (const parameter in this.#parameters) {
+			if (Object.prototype.hasOwnProperty.call(this.#parameters, parameter)) {
+				uri += `;${parameter}`;
+
+				if (this.#parameters[parameter] !== null) {
+					uri += `=${this.#parameters[parameter]}`;
+				}
+			}
+		}
+
+		for (const header in this.#headers) {
+			if (Object.prototype.hasOwnProperty.call(this.#headers, header)) {
+				for (const item of this.#headers[header] ?? []) {
+					headers.push(`${header}=${item}`);
+				}
+			}
+		}
+
+		if (headers.length > 0) {
+			uri += `?${headers.join('&')}`;
+		}
+
+		return uri;
+	}
+
+	toAor(show_port?: boolean): string {
+		let aor = `${this.#scheme}:`;
+
+		if (this.#user) {
+			aor += `${escapeUser(this.#user)}@`;
+		}
+		aor += this.#host;
+		if (show_port && (this.#port || this.#port === 0)) {
+			aor += `:${this.#port}`;
+		}
+
+		return aor;
+	}
+}
+
+// NOTE: `URI` is exported with `export =` because the pegjs generated
+// `Grammar.js` does `var URI = require('./URI')` and then `new URI(...)`.
+// Types that used to be named exports of this module are merged into the
+// `URI` namespace so that they remain reachable.
+// eslint-disable-next-line @typescript-eslint/no-namespace
+declare namespace URI {
+	type URIScheme = 'sip' | string;
+
+	type Parameters = Record<string, string | null>;
+
+	type Headers = Record<string, string[]>;
+}
+
+export = URI;
diff --git a/src/Utils.d.ts b/src/Utils.d.ts
deleted file mode 100644
index 9eb0f30..0000000
--- a/src/Utils.d.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { URI } from './URI';
-import { causes } from './Constants';
-
-export function str_utf8_length(str: string): number;
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export function isFunction(fn: unknown): fn is (...args: any[]) => any;
-
-export function isString(str: unknown): str is string;
-
-export function isDecimal(num: unknown): num is number;
-
-export function isEmpty(value: unknown): boolean;
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export function hasMethods(obj: any, ...methodNames: string[]): boolean;
-
-export function createRandomToken(size: number, base?: number): string;
-
-export function newTag(): string;
-
-export function newUUID(): string;
-
-export function hostType(host: string): string;
-
-export function escapeUser(user: string): string;
-
-export function normalizeTarget(
-	target: URI | string,
-	domain?: string
-): URI | undefined;
-
-export function headerize(str: string): string;
-
-export function sipErrorCause(status_code: number): causes;
-
-export function getRandomTestNetIP(): string;
-
-export function calculateMD5(str: string): string;
-
-export function closeMediaStream(stream?: MediaStream): void;
-
-export function cloneArray<T = unknown>(arr: T[]): T[];
-
-export function cloneObject<T>(obj: T, fallback?: T): T;
diff --git a/src/Utils.js b/src/Utils.js
deleted file mode 100644
index 1ed5aac..0000000
--- a/src/Utils.js
+++ /dev/null
@@ -1,495 +0,0 @@
-const JsSIP_C = require('./Constants');
-const URI = require('./URI');
-const Grammar = require('./Grammar');
-
-exports.str_utf8_length = string => unescape(encodeURIComponent(string)).length;
-
-// Used by 'hasMethods'.
-const isFunction = (exports.isFunction = fn => {
-	if (fn !== undefined) {
-		return Object.prototype.toString.call(fn) === '[object Function]'
-			? true
-			: false;
-	} else {
-		return false;
-	}
-});
-
-exports.isString = str => {
-	if (str !== undefined) {
-		return Object.prototype.toString.call(str) === '[object String]'
-			? true
-			: false;
-	} else {
-		return false;
-	}
-};
-
-exports.isDecimal = num => !isNaN(num) && parseFloat(num) === parseInt(num, 10);
-
-exports.isEmpty = value => {
-	return (
-		value === null ||
-		value === '' ||
-		value === undefined ||
-		(Array.isArray(value) && value.length === 0) ||
-		(typeof value === 'number' && isNaN(value))
-	);
-};
-
-exports.hasMethods = function (obj, ...methodNames) {
-	for (const methodName of methodNames) {
-		if (isFunction(obj[methodName])) {
-			return false;
-		}
-	}
-
-	return true;
-};
-
-// Used by 'newTag'.
-const createRandomToken = (exports.createRandomToken = (size, base = 32) => {
-	let i,
-		r,
-		token = '';
-
-	for (i = 0; i < size; i++) {
-		r = (Math.random() * base) | 0;
-		token += r.toString(base);
-	}
-
-	return token;
-});
-
-exports.newTag = () => createRandomToken(10);
-
-// https://stackoverflow.com/users/109538/broofa.
-exports.newUUID = () => {
-	const UUID = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
-		const r = (Math.random() * 16) | 0,
-			v = c === 'x' ? r : (r & 0x3) | 0x8;
-
-		return v.toString(16);
-	});
-
-	return UUID;
-};
-
-exports.hostType = host => {
-	if (!host) {
-		return;
-	} else {
-		host = Grammar.parse(host, 'host');
-		if (host !== -1) {
-			return host.host_type;
-		}
-	}
-};
-
-/**
- * Hex-escape a SIP URI user.
- * Don't hex-escape ':' (%3A), '+' (%2B), '?' (%3F"), '/' (%2F).
- *
- * Used by 'normalizeTarget'.
- */
-const escapeUser = (exports.escapeUser = user =>
-	encodeURIComponent(decodeURIComponent(user))
-		.replace(/%3A/gi, ':')
-		.replace(/%2B/gi, '+')
-		.replace(/%3F/gi, '?')
-		.replace(/%2F/gi, '/'));
-
-/**
- * Normalize SIP URI.
- * NOTE: It does not allow a SIP URI without username.
- * Accepts 'sip', 'sips' and 'tel' URIs and convert them into 'sip'.
- * Detects the domain part (if given) and properly hex-escapes the user portion.
- * If the user portion has only 'tel' number symbols the user portion is clean of 'tel' visual separators.
- */
-exports.normalizeTarget = (target, domain) => {
-	// If no target is given then raise an error.
-	if (!target) {
-		return;
-		// If a URI instance is given then return it.
-	} else if (target instanceof URI) {
-		return target;
-
-		// If a string is given split it by '@':
-		// - Last fragment is the desired domain.
-		// - Otherwise append the given domain argument.
-	} else if (typeof target === 'string') {
-		const target_array = target.split('@');
-		let target_user;
-		let target_domain;
-
-		switch (target_array.length) {
-			case 1: {
-				if (!domain) {
-					return;
-				}
-				target_user = target;
-				target_domain = domain;
-				break;
-			}
-			case 2: {
-				target_user = target_array[0];
-				target_domain = target_array[1];
-				break;
-			}
-			default: {
-				target_user = target_array.slice(0, target_array.length - 1).join('@');
-				target_domain = target_array[target_array.length - 1];
-			}
-		}
-
-		// Remove the URI scheme (if present).
-		target_user = target_user.replace(/^(sips?|tel):/i, '');
-
-		// Remove 'tel' visual separators if the user portion just contains 'tel' number symbols.
-		if (/^[-.()]*\+?[0-9\-.()]+$/.test(target_user)) {
-			target_user = target_user.replace(/[-.()]/g, '');
-		}
-
-		// Build the complete SIP URI.
-		target = `${JsSIP_C.SIP}:${escapeUser(target_user)}@${target_domain}`;
-
-		// Finally parse the resulting URI.
-		let uri;
-
-		if ((uri = URI.parse(target))) {
-			return uri;
-		} else {
-			return;
-		}
-	} else {
-		return;
-	}
-};
-
-exports.headerize = string => {
-	const exceptions = {
-		'Call-Id': 'Call-ID',
-		Cseq: 'CSeq',
-		'Www-Authenticate': 'WWW-Authenticate',
-	};
-
-	const name = string.toLowerCase().replace(/_/g, '-').split('-');
-	let hname = '';
-	const parts = name.length;
-	let part;
-
-	for (part = 0; part < parts; part++) {
-		if (part !== 0) {
-			hname += '-';
-		}
-		hname += name[part].charAt(0).toUpperCase() + name[part].substring(1);
-	}
-	if (exceptions[hname]) {
-		hname = exceptions[hname];
-	}
-
-	return hname;
-};
-
-exports.sipErrorCause = status_code => {
-	for (const cause in JsSIP_C.SIP_ERROR_CAUSES) {
-		if (JsSIP_C.SIP_ERROR_CAUSES[cause].indexOf(status_code) !== -1) {
-			return JsSIP_C.causes[cause];
-		}
-	}
-
-	return JsSIP_C.causes.SIP_FAILURE_CODE;
-};
-
-/**
- * Generate a random Test-Net IP (https://tools.ietf.org/html/rfc5735)
- */
-exports.getRandomTestNetIP = () => {
-	function getOctet(from, to) {
-		return Math.floor(Math.random() * (to - from + 1) + from);
-	}
-
-	return `192.0.2.${getOctet(1, 254)}`;
-};
-
-// MD5 (Message-Digest Algorithm) https://www.webtoolkit.info.
-exports.calculateMD5 = string => {
-	function rotateLeft(lValue, iShiftBits) {
-		return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits));
-	}
-
-	function addUnsigned(lX, lY) {
-		const lX8 = lX & 0x80000000;
-		const lY8 = lY & 0x80000000;
-		const lX4 = lX & 0x40000000;
-		const lY4 = lY & 0x40000000;
-		const lResult = (lX & 0x3fffffff) + (lY & 0x3fffffff);
-
-		if (lX4 & lY4) {
-			return lResult ^ 0x80000000 ^ lX8 ^ lY8;
-		}
-		if (lX4 | lY4) {
-			if (lResult & 0x40000000) {
-				return lResult ^ 0xc0000000 ^ lX8 ^ lY8;
-			} else {
-				return lResult ^ 0x40000000 ^ lX8 ^ lY8;
-			}
-		} else {
-			return lResult ^ lX8 ^ lY8;
-		}
-	}
-
-	function doF(x, y, z) {
-		return (x & y) | (~x & z);
-	}
-
-	function doG(x, y, z) {
-		return (x & z) | (y & ~z);
-	}
-
-	function doH(x, y, z) {
-		return x ^ y ^ z;
-	}
-
-	function doI(x, y, z) {
-		return y ^ (x | ~z);
-	}
-
-	function doFF(a, b, c, d, x, s, ac) {
-		a = addUnsigned(a, addUnsigned(addUnsigned(doF(b, c, d), x), ac));
-
-		return addUnsigned(rotateLeft(a, s), b);
-	}
-
-	function doGG(a, b, c, d, x, s, ac) {
-		a = addUnsigned(a, addUnsigned(addUnsigned(doG(b, c, d), x), ac));
-
-		return addUnsigned(rotateLeft(a, s), b);
-	}
-
-	function doHH(a, b, c, d, x, s, ac) {
-		a = addUnsigned(a, addUnsigned(addUnsigned(doH(b, c, d), x), ac));
-
-		return addUnsigned(rotateLeft(a, s), b);
-	}
-
-	function doII(a, b, c, d, x, s, ac) {
-		a = addUnsigned(a, addUnsigned(addUnsigned(doI(b, c, d), x), ac));
-
-		return addUnsigned(rotateLeft(a, s), b);
-	}
-
-	function convertToWordArray(str) {
-		let lWordCount;
-		const lMessageLength = str.length;
-		const lNumberOfWords_temp1 = lMessageLength + 8;
-		const lNumberOfWords_temp2 =
-			(lNumberOfWords_temp1 - (lNumberOfWords_temp1 % 64)) / 64;
-		const lNumberOfWords = (lNumberOfWords_temp2 + 1) * 16;
-		const lWordArray = new Array(lNumberOfWords - 1);
-		let lBytePosition = 0;
-		let lByteCount = 0;
-
-		while (lByteCount < lMessageLength) {
-			lWordCount = (lByteCount - (lByteCount % 4)) / 4;
-			lBytePosition = (lByteCount % 4) * 8;
-			lWordArray[lWordCount] =
-				lWordArray[lWordCount] | (str.charCodeAt(lByteCount) << lBytePosition);
-			lByteCount++;
-		}
-		lWordCount = (lByteCount - (lByteCount % 4)) / 4;
-		lBytePosition = (lByteCount % 4) * 8;
-		lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition);
-		lWordArray[lNumberOfWords - 2] = lMessageLength << 3;
-		lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29;
-
-		return lWordArray;
-	}
-
-	function wordToHex(lValue) {
-		let wordToHexValue = '',
-			wordToHexValue_temp = '',
-			lByte,
-			lCount;
-
-		for (lCount = 0; lCount <= 3; lCount++) {
-			lByte = (lValue >>> (lCount * 8)) & 255;
-			wordToHexValue_temp = `0${lByte.toString(16)}`;
-			wordToHexValue =
-				wordToHexValue +
-				wordToHexValue_temp.substr(wordToHexValue_temp.length - 2, 2);
-		}
-
-		return wordToHexValue;
-	}
-
-	function utf8Encode(str) {
-		let utftext = '';
-
-		for (let n = 0; n < str.length; n++) {
-			const c = str.charCodeAt(n);
-
-			if (c < 128) {
-				utftext += String.fromCharCode(c);
-			} else if (c > 127 && c < 2048) {
-				utftext += String.fromCharCode((c >> 6) | 192);
-				utftext += String.fromCharCode((c & 63) | 128);
-			} else {
-				utftext += String.fromCharCode((c >> 12) | 224);
-				utftext += String.fromCharCode(((c >> 6) & 63) | 128);
-				utftext += String.fromCharCode((c & 63) | 128);
-			}
-		}
-
-		return utftext;
-	}
-
-	let x = [];
-	let k, AA, BB, CC, DD, a, b, c, d;
-	const S11 = 7,
-		S12 = 12,
-		S13 = 17,
-		S14 = 22;
-	const S21 = 5,
-		S22 = 9,
-		S23 = 14,
-		S24 = 20;
-	const S31 = 4,
-		S32 = 11,
-		S33 = 16,
-		S34 = 23;
-	const S41 = 6,
-		S42 = 10,
-		S43 = 15,
-		S44 = 21;
-
-	string = utf8Encode(string);
-
-	x = convertToWordArray(string);
-
-	a = 0x67452301;
-	b = 0xefcdab89;
-	c = 0x98badcfe;
-	d = 0x10325476;
-
-	for (k = 0; k < x.length; k += 16) {
-		AA = a;
-		BB = b;
-		CC = c;
-		DD = d;
-		a = doFF(a, b, c, d, x[k + 0], S11, 0xd76aa478);
-		d = doFF(d, a, b, c, x[k + 1], S12, 0xe8c7b756);
-		c = doFF(c, d, a, b, x[k + 2], S13, 0x242070db);
-		b = doFF(b, c, d, a, x[k + 3], S14, 0xc1bdceee);
-		a = doFF(a, b, c, d, x[k + 4], S11, 0xf57c0faf);
-		d = doFF(d, a, b, c, x[k + 5], S12, 0x4787c62a);
-		c = doFF(c, d, a, b, x[k + 6], S13, 0xa8304613);
-		b = doFF(b, c, d, a, x[k + 7], S14, 0xfd469501);
-		a = doFF(a, b, c, d, x[k + 8], S11, 0x698098d8);
-		d = doFF(d, a, b, c, x[k + 9], S12, 0x8b44f7af);
-		c = doFF(c, d, a, b, x[k + 10], S13, 0xffff5bb1);
-		b = doFF(b, c, d, a, x[k + 11], S14, 0x895cd7be);
-		a = doFF(a, b, c, d, x[k + 12], S11, 0x6b901122);
-		d = doFF(d, a, b, c, x[k + 13], S12, 0xfd987193);
-		c = doFF(c, d, a, b, x[k + 14], S13, 0xa679438e);
-		b = doFF(b, c, d, a, x[k + 15], S14, 0x49b40821);
-		a = doGG(a, b, c, d, x[k + 1], S21, 0xf61e2562);
-		d = doGG(d, a, b, c, x[k + 6], S22, 0xc040b340);
-		c = doGG(c, d, a, b, x[k + 11], S23, 0x265e5a51);
-		b = doGG(b, c, d, a, x[k + 0], S24, 0xe9b6c7aa);
-		a = doGG(a, b, c, d, x[k + 5], S21, 0xd62f105d);
-		d = doGG(d, a, b, c, x[k + 10], S22, 0x2441453);
-		c = doGG(c, d, a, b, x[k + 15], S23, 0xd8a1e681);
-		b = doGG(b, c, d, a, x[k + 4], S24, 0xe7d3fbc8);
-		a = doGG(a, b, c, d, x[k + 9], S21, 0x21e1cde6);
-		d = doGG(d, a, b, c, x[k + 14], S22, 0xc33707d6);
-		c = doGG(c, d, a, b, x[k + 3], S23, 0xf4d50d87);
-		b = doGG(b, c, d, a, x[k + 8], S24, 0x455a14ed);
-		a = doGG(a, b, c, d, x[k + 13], S21, 0xa9e3e905);
-		d = doGG(d, a, b, c, x[k + 2], S22, 0xfcefa3f8);
-		c = doGG(c, d, a, b, x[k + 7], S23, 0x676f02d9);
-		b = doGG(b, c, d, a, x[k + 12], S24, 0x8d2a4c8a);
-		a = doHH(a, b, c, d, x[k + 5], S31, 0xfffa3942);
-		d = doHH(d, a, b, c, x[k + 8], S32, 0x8771f681);
-		c = doHH(c, d, a, b, x[k + 11], S33, 0x6d9d6122);
-		b = doHH(b, c, d, a, x[k + 14], S34, 0xfde5380c);
-		a = doHH(a, b, c, d, x[k + 1], S31, 0xa4beea44);
-		d = doHH(d, a, b, c, x[k + 4], S32, 0x4bdecfa9);
-		c = doHH(c, d, a, b, x[k + 7], S33, 0xf6bb4b60);
-		b = doHH(b, c, d, a, x[k + 10], S34, 0xbebfbc70);
-		a = doHH(a, b, c, d, x[k + 13], S31, 0x289b7ec6);
-		d = doHH(d, a, b, c, x[k + 0], S32, 0xeaa127fa);
-		c = doHH(c, d, a, b, x[k + 3], S33, 0xd4ef3085);
-		b = doHH(b, c, d, a, x[k + 6], S34, 0x4881d05);
-		a = doHH(a, b, c, d, x[k + 9], S31, 0xd9d4d039);
-		d = doHH(d, a, b, c, x[k + 12], S32, 0xe6db99e5);
-		c = doHH(c, d, a, b, x[k + 15], S33, 0x1fa27cf8);
-		b = doHH(b, c, d, a, x[k + 2], S34, 0xc4ac5665);
-		a = doII(a, b, c, d, x[k + 0], S41, 0xf4292244);
-		d = doII(d, a, b, c, x[k + 7], S42, 0x432aff97);
-		c = doII(c, d, a, b, x[k + 14], S43, 0xab9423a7);
-		b = doII(b, c, d, a, x[k + 5], S44, 0xfc93a039);
-		a = doII(a, b, c, d, x[k + 12], S41, 0x655b59c3);
-		d = doII(d, a, b, c, x[k + 3], S42, 0x8f0ccc92);
-		c = doII(c, d, a, b, x[k + 10], S43, 0xffeff47d);
-		b = doII(b, c, d, a, x[k + 1], S44, 0x85845dd1);
-		a = doII(a, b, c, d, x[k + 8], S41, 0x6fa87e4f);
-		d = doII(d, a, b, c, x[k + 15], S42, 0xfe2ce6e0);
-		c = doII(c, d, a, b, x[k + 6], S43, 0xa3014314);
-		b = doII(b, c, d, a, x[k + 13], S44, 0x4e0811a1);
-		a = doII(a, b, c, d, x[k + 4], S41, 0xf7537e82);
-		d = doII(d, a, b, c, x[k + 11], S42, 0xbd3af235);
-		c = doII(c, d, a, b, x[k + 2], S43, 0x2ad7d2bb);
-		b = doII(b, c, d, a, x[k + 9], S44, 0xeb86d391);
-		a = addUnsigned(a, AA);
-		b = addUnsigned(b, BB);
-		c = addUnsigned(c, CC);
-		d = addUnsigned(d, DD);
-	}
-
-	const temp = wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d);
-
-	return temp.toLowerCase();
-};
-
-exports.closeMediaStream = stream => {
-	if (!stream) {
-		return;
-	}
-
-	// Latest spec states that MediaStream has no stop() method and instead must
-	// call stop() on every MediaStreamTrack.
-	try {
-		let tracks;
-
-		if (stream.getTracks) {
-			tracks = stream.getTracks();
-			for (const track of tracks) {
-				track.stop();
-			}
-		} else {
-			tracks = stream.getAudioTracks();
-			for (const track of tracks) {
-				track.stop();
-			}
-			tracks = stream.getVideoTracks();
-			for (const track of tracks) {
-				track.stop();
-			}
-		}
-	} catch (error) {
-		// Deprecated by the spec, but still in use.
-		// NOTE: In Temasys IE plugin stream.stop is a callable 'object'.
-		if (typeof stream.stop === 'function' || typeof stream.stop === 'object') {
-			stream.stop();
-		}
-	}
-};
-
-exports.cloneArray = array => {
-	return (array && array.slice()) || [];
-};
-
-exports.cloneObject = (obj, fallback = {}) => {
-	return (obj && Object.assign({}, obj)) || fallback;
-};
diff --git a/src/Utils.ts b/src/Utils.ts
new file mode 100644
index 0000000..0bcd054
--- /dev/null
+++ b/src/Utils.ts
@@ -0,0 +1,558 @@
+import { SIP, SIP_ERROR_CAUSES, causes } from './Constants';
+import * as Grammar from './Grammar';
+// `URI` is exported with `export =` (CommonJS style) because the pegjs generated
+// `Grammar.js` does `var URI = require('./URI'); new URI(...)`.
+
+import URI = require('./URI');
+
+export const str_utf8_length = (string: string): number =>
+	unescape(encodeURIComponent(string)).length;
+
+// Used by 'hasMethods'.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const isFunction = (fn: unknown): fn is (...args: any[]) => any => {
+	if (fn !== undefined) {
+		return Object.prototype.toString.call(fn) === '[object Function]'
+			? true
+			: false;
+	} else {
+		return false;
+	}
+};
+
+export const isString = (str: unknown): str is string => {
+	if (str !== undefined) {
+		return Object.prototype.toString.call(str) === '[object String]'
+			? true
+			: false;
+	} else {
+		return false;
+	}
+};
+
+export const isDecimal = (num: unknown): boolean =>
+	!isNaN(num as number) &&
+	parseFloat(num as string) === parseInt(num as string, 10);
+
+export const isEmpty = (value: unknown): boolean => {
+	return (
+		value === null ||
+		value === '' ||
+		value === undefined ||
+		(Array.isArray(value) && value.length === 0) ||
+		(typeof value === 'number' && isNaN(value))
+	);
+};
+
+export const hasMethods = function (
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	obj: any,
+	...methodNames: string[]
+): boolean {
+	for (const methodName of methodNames) {
+		if (isFunction(obj[methodName])) {
+			return false;
+		}
+	}
+
+	return true;
+};
+
+// Used by 'newTag'.
+export const createRandomToken = (size: number, base = 32): string => {
+	let i,
+		r,
+		token = '';
+
+	for (i = 0; i < size; i++) {
+		r = (Math.random() * base) | 0;
+		token += r.toString(base);
+	}
+
+	return token;
+};
+
+export const newTag = (): string => createRandomToken(10);
+
+// https://stackoverflow.com/users/109538/broofa.
+export const newUUID = (): string => {
+	const UUID = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
+		const r = (Math.random() * 16) | 0,
+			v = c === 'x' ? r : (r & 0x3) | 0x8;
+
+		return v.toString(16);
+	});
+
+	return UUID;
+};
+
+export const hostType = (host?: string): string | undefined => {
+	if (!host) {
+		return undefined;
+	} else {
+		const parsed = Grammar.parse(host, 'host');
+
+		if (parsed !== -1) {
+			return parsed.host_type;
+		}
+
+		return undefined;
+	}
+};
+
+/**
+ * Hex-escape a SIP URI user.
+ * Don't hex-escape ':' (%3A), '+' (%2B), '?' (%3F"), '/' (%2F).
+ *
+ * Used by 'normalizeTarget'.
+ */
+export const escapeUser = (user: string): string =>
+	encodeURIComponent(decodeURIComponent(user))
+		.replace(/%3A/gi, ':')
+		.replace(/%2B/gi, '+')
+		.replace(/%3F/gi, '?')
+		.replace(/%2F/gi, '/');
+
+/**
+ * Normalize SIP URI.
+ * NOTE: It does not allow a SIP URI without username.
+ * Accepts 'sip', 'sips' and 'tel' URIs and convert them into 'sip'.
+ * Detects the domain part (if given) and properly hex-escapes the user portion.
+ * If the user portion has only 'tel' number symbols the user portion is clean of 'tel' visual separators.
+ */
+export const normalizeTarget = (
+	target?: URI | string,
+	domain?: string
+): URI | undefined => {
+	// If no target is given then raise an error.
+	if (!target) {
+		return undefined;
+		// If a URI instance is given then return it.
+	} else if (target instanceof URI) {
+		return target;
+
+		// If a string is given split it by '@':
+		// - Last fragment is the desired domain.
+		// - Otherwise append the given domain argument.
+	} else if (typeof target === 'string') {
+		const target_array = target.split('@');
+		let target_user: string;
+		let target_domain: string;
+
+		switch (target_array.length) {
+			case 1: {
+				if (!domain) {
+					return undefined;
+				}
+				target_user = target;
+				target_domain = domain;
+				break;
+			}
+			case 2: {
+				target_user = target_array[0]!;
+				target_domain = target_array[1]!;
+				break;
+			}
+			default: {
+				target_user = target_array.slice(0, target_array.length - 1).join('@');
+				target_domain = target_array[target_array.length - 1]!;
+			}
+		}
+
+		// Remove the URI scheme (if present).
+		target_user = target_user.replace(/^(sips?|tel):/i, '');
+
+		// Remove 'tel' visual separators if the user portion just contains 'tel' number symbols.
+		if (/^[-.()]*\+?[0-9\-.()]+$/.test(target_user)) {
+			target_user = target_user.replace(/[-.()]/g, '');
+		}
+
+		// Build the complete SIP URI.
+		target = `${SIP}:${escapeUser(target_user)}@${target_domain}`;
+
+		// Finally parse the resulting URI.
+		let uri;
+
+		if ((uri = URI.parse(target))) {
+			return uri;
+		} else {
+			return undefined;
+		}
+	} else {
+		return undefined;
+	}
+};
+
+export const headerize = (string: string): string => {
+	const exceptions: Record<string, string> = {
+		'Call-Id': 'Call-ID',
+		Cseq: 'CSeq',
+		'Www-Authenticate': 'WWW-Authenticate',
+	};
+
+	const name = string.toLowerCase().replace(/_/g, '-').split('-');
+	let hname = '';
+	const parts = name.length;
+	let part: number;
+
+	for (part = 0; part < parts; part++) {
+		if (part !== 0) {
+			hname += '-';
+		}
+		hname += name[part]!.charAt(0).toUpperCase() + name[part]!.substring(1);
+	}
+	if (exceptions[hname]) {
+		hname = exceptions[hname]!;
+	}
+
+	return hname;
+};
+
+export const sipErrorCause = (status_code: number): causes => {
+	for (const cause in SIP_ERROR_CAUSES) {
+		if (SIP_ERROR_CAUSES[cause]!.includes(status_code)) {
+			return (causes as Record<string, causes>)[cause]!;
+		}
+	}
+
+	return causes.SIP_FAILURE_CODE;
+};
+
+/**
+ * Generate a random Test-Net IP (https://tools.ietf.org/html/rfc5735)
+ */
+export const getRandomTestNetIP = (): string => {
+	function getOctet(from: number, to: number): number {
+		return Math.floor(Math.random() * (to - from + 1) + from);
+	}
+
+	return `192.0.2.${getOctet(1, 254)}`;
+};
+
+// MD5 (Message-Digest Algorithm) https://www.webtoolkit.info.
+export const calculateMD5 = (string: string): string => {
+	function rotateLeft(lValue: number, iShiftBits: number): number {
+		return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits));
+	}
+
+	function addUnsigned(lX: number, lY: number): number {
+		const lX8 = lX & 0x80000000;
+		const lY8 = lY & 0x80000000;
+		const lX4 = lX & 0x40000000;
+		const lY4 = lY & 0x40000000;
+		const lResult = (lX & 0x3fffffff) + (lY & 0x3fffffff);
+
+		if (lX4 & lY4) {
+			return lResult ^ 0x80000000 ^ lX8 ^ lY8;
+		}
+		if (lX4 | lY4) {
+			if (lResult & 0x40000000) {
+				return lResult ^ 0xc0000000 ^ lX8 ^ lY8;
+			} else {
+				return lResult ^ 0x40000000 ^ lX8 ^ lY8;
+			}
+		} else {
+			return lResult ^ lX8 ^ lY8;
+		}
+	}
+
+	function doF(x: number, y: number, z: number): number {
+		return (x & y) | (~x & z);
+	}
+
+	function doG(x: number, y: number, z: number): number {
+		return (x & z) | (y & ~z);
+	}
+
+	function doH(x: number, y: number, z: number): number {
+		return x ^ y ^ z;
+	}
+
+	function doI(x: number, y: number, z: number): number {
+		return y ^ (x | ~z);
+	}
+
+	function doFF(
+		a: number,
+		b: number,
+		c: number,
+		d: number,
+		x: number,
+		s: number,
+		ac: number
+	): number {
+		a = addUnsigned(a, addUnsigned(addUnsigned(doF(b, c, d), x), ac));
+
+		return addUnsigned(rotateLeft(a, s), b);
+	}
+
+	function doGG(
+		a: number,
+		b: number,
+		c: number,
+		d: number,
+		x: number,
+		s: number,
+		ac: number
+	): number {
+		a = addUnsigned(a, addUnsigned(addUnsigned(doG(b, c, d), x), ac));
+
+		return addUnsigned(rotateLeft(a, s), b);
+	}
+
+	function doHH(
+		a: number,
+		b: number,
+		c: number,
+		d: number,
+		x: number,
+		s: number,
+		ac: number
+	): number {
+		a = addUnsigned(a, addUnsigned(addUnsigned(doH(b, c, d), x), ac));
+
+		return addUnsigned(rotateLeft(a, s), b);
+	}
+
+	function doII(
+		a: number,
+		b: number,
+		c: number,
+		d: number,
+		x: number,
+		s: number,
+		ac: number
+	): number {
+		a = addUnsigned(a, addUnsigned(addUnsigned(doI(b, c, d), x), ac));
+
+		return addUnsigned(rotateLeft(a, s), b);
+	}
+
+	function convertToWordArray(str: string): number[] {
+		let lWordCount: number;
+		const lMessageLength = str.length;
+		const lNumberOfWords_temp1 = lMessageLength + 8;
+		const lNumberOfWords_temp2 =
+			(lNumberOfWords_temp1 - (lNumberOfWords_temp1 % 64)) / 64;
+		const lNumberOfWords = (lNumberOfWords_temp2 + 1) * 16;
+		const lWordArray: number[] = new Array(lNumberOfWords - 1);
+		let lBytePosition = 0;
+		let lByteCount = 0;
+
+		while (lByteCount < lMessageLength) {
+			lWordCount = (lByteCount - (lByteCount % 4)) / 4;
+			lBytePosition = (lByteCount % 4) * 8;
+			lWordArray[lWordCount] =
+				(lWordArray[lWordCount] ?? 0) |
+				(str.charCodeAt(lByteCount) << lBytePosition);
+			lByteCount++;
+		}
+		lWordCount = (lByteCount - (lByteCount % 4)) / 4;
+		lBytePosition = (lByteCount % 4) * 8;
+		lWordArray[lWordCount] =
+			(lWordArray[lWordCount] ?? 0) | (0x80 << lBytePosition);
+		lWordArray[lNumberOfWords - 2] = lMessageLength << 3;
+		lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29;
+
+		return lWordArray;
+	}
+
+	function wordToHex(lValue: number): string {
+		let wordToHexValue = '',
+			wordToHexValue_temp = '',
+			lByte,
+			lCount;
+
+		for (lCount = 0; lCount <= 3; lCount++) {
+			lByte = (lValue >>> (lCount * 8)) & 255;
+			wordToHexValue_temp = `0${lByte.toString(16)}`;
+			wordToHexValue =
+				wordToHexValue +
+				wordToHexValue_temp.substr(wordToHexValue_temp.length - 2, 2);
+		}
+
+		return wordToHexValue;
+	}
+
+	function utf8Encode(str: string): string {
+		let utftext = '';
+
+		for (let n = 0; n < str.length; n++) {
+			const c = str.charCodeAt(n);
+
+			if (c < 128) {
+				utftext += String.fromCharCode(c);
+			} else if (c > 127 && c < 2048) {
+				utftext += String.fromCharCode((c >> 6) | 192);
+				utftext += String.fromCharCode((c & 63) | 128);
+			} else {
+				utftext += String.fromCharCode((c >> 12) | 224);
+				utftext += String.fromCharCode(((c >> 6) & 63) | 128);
+				utftext += String.fromCharCode((c & 63) | 128);
+			}
+		}
+
+		return utftext;
+	}
+
+	let x: number[] = [];
+	let k, AA, BB, CC, DD, a, b, c, d;
+	const S11 = 7,
+		S12 = 12,
+		S13 = 17,
+		S14 = 22;
+	const S21 = 5,
+		S22 = 9,
+		S23 = 14,
+		S24 = 20;
+	const S31 = 4,
+		S32 = 11,
+		S33 = 16,
+		S34 = 23;
+	const S41 = 6,
+		S42 = 10,
+		S43 = 15,
+		S44 = 21;
+	// NOTE: `x` is always allocated with the exact required length, so the `?? 0`
+	// fallback is never taken at runtime. It just satisfies
+	// `noUncheckedIndexedAccess`.
+	const w = (i: number): number => x[i] ?? 0;
+
+	string = utf8Encode(string);
+
+	x = convertToWordArray(string);
+
+	a = 0x67452301;
+	b = 0xefcdab89;
+	c = 0x98badcfe;
+	d = 0x10325476;
+
+	for (k = 0; k < x.length; k += 16) {
+		AA = a;
+		BB = b;
+		CC = c;
+		DD = d;
+		a = doFF(a, b, c, d, w(k + 0), S11, 0xd76aa478);
+		d = doFF(d, a, b, c, w(k + 1), S12, 0xe8c7b756);
+		c = doFF(c, d, a, b, w(k + 2), S13, 0x242070db);
+		b = doFF(b, c, d, a, w(k + 3), S14, 0xc1bdceee);
+		a = doFF(a, b, c, d, w(k + 4), S11, 0xf57c0faf);
+		d = doFF(d, a, b, c, w(k + 5), S12, 0x4787c62a);
+		c = doFF(c, d, a, b, w(k + 6), S13, 0xa8304613);
+		b = doFF(b, c, d, a, w(k + 7), S14, 0xfd469501);
+		a = doFF(a, b, c, d, w(k + 8), S11, 0x698098d8);
+		d = doFF(d, a, b, c, w(k + 9), S12, 0x8b44f7af);
+		c = doFF(c, d, a, b, w(k + 10), S13, 0xffff5bb1);
+		b = doFF(b, c, d, a, w(k + 11), S14, 0x895cd7be);
+		a = doFF(a, b, c, d, w(k + 12), S11, 0x6b901122);
+		d = doFF(d, a, b, c, w(k + 13), S12, 0xfd987193);
+		c = doFF(c, d, a, b, w(k + 14), S13, 0xa679438e);
+		b = doFF(b, c, d, a, w(k + 15), S14, 0x49b40821);
+		a = doGG(a, b, c, d, w(k + 1), S21, 0xf61e2562);
+		d = doGG(d, a, b, c, w(k + 6), S22, 0xc040b340);
+		c = doGG(c, d, a, b, w(k + 11), S23, 0x265e5a51);
+		b = doGG(b, c, d, a, w(k + 0), S24, 0xe9b6c7aa);
+		a = doGG(a, b, c, d, w(k + 5), S21, 0xd62f105d);
+		d = doGG(d, a, b, c, w(k + 10), S22, 0x2441453);
+		c = doGG(c, d, a, b, w(k + 15), S23, 0xd8a1e681);
+		b = doGG(b, c, d, a, w(k + 4), S24, 0xe7d3fbc8);
+		a = doGG(a, b, c, d, w(k + 9), S21, 0x21e1cde6);
+		d = doGG(d, a, b, c, w(k + 14), S22, 0xc33707d6);
+		c = doGG(c, d, a, b, w(k + 3), S23, 0xf4d50d87);
+		b = doGG(b, c, d, a, w(k + 8), S24, 0x455a14ed);
+		a = doGG(a, b, c, d, w(k + 13), S21, 0xa9e3e905);
+		d = doGG(d, a, b, c, w(k + 2), S22, 0xfcefa3f8);
+		c = doGG(c, d, a, b, w(k + 7), S23, 0x676f02d9);
+		b = doGG(b, c, d, a, w(k + 12), S24, 0x8d2a4c8a);
+		a = doHH(a, b, c, d, w(k + 5), S31, 0xfffa3942);
+		d = doHH(d, a, b, c, w(k + 8), S32, 0x8771f681);
+		c = doHH(c, d, a, b, w(k + 11), S33, 0x6d9d6122);
+		b = doHH(b, c, d, a, w(k + 14), S34, 0xfde5380c);
+		a = doHH(a, b, c, d, w(k + 1), S31, 0xa4beea44);
+		d = doHH(d, a, b, c, w(k + 4), S32, 0x4bdecfa9);
+		c = doHH(c, d, a, b, w(k + 7), S33, 0xf6bb4b60);
+		b = doHH(b, c, d, a, w(k + 10), S34, 0xbebfbc70);
+		a = doHH(a, b, c, d, w(k + 13), S31, 0x289b7ec6);
+		d = doHH(d, a, b, c, w(k + 0), S32, 0xeaa127fa);
+		c = doHH(c, d, a, b, w(k + 3), S33, 0xd4ef3085);
+		b = doHH(b, c, d, a, w(k + 6), S34, 0x4881d05);
+		a = doHH(a, b, c, d, w(k + 9), S31, 0xd9d4d039);
+		d = doHH(d, a, b, c, w(k + 12), S32, 0xe6db99e5);
+		c = doHH(c, d, a, b, w(k + 15), S33, 0x1fa27cf8);
+		b = doHH(b, c, d, a, w(k + 2), S34, 0xc4ac5665);
+		a = doII(a, b, c, d, w(k + 0), S41, 0xf4292244);
+		d = doII(d, a, b, c, w(k + 7), S42, 0x432aff97);
+		c = doII(c, d, a, b, w(k + 14), S43, 0xab9423a7);
+		b = doII(b, c, d, a, w(k + 5), S44, 0xfc93a039);
+		a = doII(a, b, c, d, w(k + 12), S41, 0x655b59c3);
+		d = doII(d, a, b, c, w(k + 3), S42, 0x8f0ccc92);
+		c = doII(c, d, a, b, w(k + 10), S43, 0xffeff47d);
+		b = doII(b, c, d, a, w(k + 1), S44, 0x85845dd1);
+		a = doII(a, b, c, d, w(k + 8), S41, 0x6fa87e4f);
+		d = doII(d, a, b, c, w(k + 15), S42, 0xfe2ce6e0);
+		c = doII(c, d, a, b, w(k + 6), S43, 0xa3014314);
+		b = doII(b, c, d, a, w(k + 13), S44, 0x4e0811a1);
+		a = doII(a, b, c, d, w(k + 4), S41, 0xf7537e82);
+		d = doII(d, a, b, c, w(k + 11), S42, 0xbd3af235);
+		c = doII(c, d, a, b, w(k + 2), S43, 0x2ad7d2bb);
+		b = doII(b, c, d, a, w(k + 9), S44, 0xeb86d391);
+		a = addUnsigned(a, AA);
+		b = addUnsigned(b, BB);
+		c = addUnsigned(c, CC);
+		d = addUnsigned(d, DD);
+	}
+
+	const temp = wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d);
+
+	return temp.toLowerCase();
+};
+
+export const closeMediaStream = (stream?: MediaStream): void => {
+	if (!stream) {
+		return;
+	}
+
+	// Latest spec states that MediaStream has no stop() method and instead must
+	// call stop() on every MediaStreamTrack.
+	try {
+		let tracks;
+
+		if (stream.getTracks) {
+			tracks = stream.getTracks();
+			for (const track of tracks) {
+				track.stop();
+			}
+		} else {
+			tracks = stream.getAudioTracks();
+			for (const track of tracks) {
+				track.stop();
+			}
+			tracks = stream.getVideoTracks();
+			for (const track of tracks) {
+				track.stop();
+			}
+		}
+	} catch (error) {
+		// Deprecated by the spec, but still in use.
+		// NOTE: In Temasys IE plugin stream.stop is a callable 'object'.
+		const legacyStream = stream as MediaStream & { stop?: unknown };
+
+		if (
+			typeof legacyStream.stop === 'function' ||
+			typeof legacyStream.stop === 'object'
+		) {
+			(legacyStream.stop as () => void)();
+		}
+	}
+};
+
+export const cloneArray = <T = unknown>(array?: T[] | null): T[] => {
+	return array?.slice() || [];
+};
+
+export const cloneObject = <T extends object>(
+	obj?: T | null,
+	fallback: T = {} as T
+): T => {
+	return (obj && Object.assign({}, obj)) || fallback;
+};
diff --git a/src/WebSocketInterface.d.ts b/src/WebSocketInterface.d.ts
deleted file mode 100644
index 4e37a16..0000000
--- a/src/WebSocketInterface.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { Socket } from './Socket';
-
-export class WebSocketInterface extends Socket {
-	constructor(url: string);
-}
diff --git a/src/WebSocketInterface.js b/src/WebSocketInterface.js
deleted file mode 100644
index 852de85..0000000
--- a/src/WebSocketInterface.js
+++ /dev/null
@@ -1,144 +0,0 @@
-const { Logger } = require('./Logger');
-const Grammar = require('./Grammar');
-
-const logger = new Logger('WebSocketInterface');
-
-module.exports = class WebSocketInterface {
-	constructor(url) {
-		logger.debug('new() [url:"%s"]', url);
-
-		this._url = url;
-		this._sip_uri = null;
-		this._via_transport = null;
-		this._ws = null;
-
-		const parsed_url = Grammar.parse(url, 'absoluteURI');
-
-		if (parsed_url === -1) {
-			logger.warn(`invalid WebSocket URI: ${url}`);
-			throw new TypeError(`Invalid argument: ${url}`);
-		} else if (parsed_url.scheme !== 'wss' && parsed_url.scheme !== 'ws') {
-			logger.warn(`invalid WebSocket URI scheme: ${parsed_url.scheme}`);
-			throw new TypeError(`Invalid argument: ${url}`);
-		} else {
-			this._sip_uri = `sip:${parsed_url.host}${parsed_url.port ? `:${parsed_url.port}` : ''};transport=ws`;
-			this._via_transport = parsed_url.scheme.toUpperCase();
-		}
-	}
-
-	get via_transport() {
-		return this._via_transport;
-	}
-
-	set via_transport(value) {
-		this._via_transport = value.toUpperCase();
-	}
-
-	get sip_uri() {
-		return this._sip_uri;
-	}
-
-	get url() {
-		return this._url;
-	}
-
-	connect() {
-		logger.debug('connect()');
-
-		if (this.isConnected()) {
-			logger.debug(`WebSocket ${this._url} is already connected`);
-
-			return;
-		} else if (this.isConnecting()) {
-			logger.debug(`WebSocket ${this._url} is connecting`);
-
-			return;
-		}
-
-		if (this._ws) {
-			this.disconnect();
-		}
-
-		logger.debug(`connecting to WebSocket ${this._url}`);
-
-		try {
-			this._ws = new WebSocket(this._url, 'sip');
-
-			this._ws.binaryType = 'arraybuffer';
-
-			this._ws.onopen = this._onOpen.bind(this);
-			this._ws.onclose = this._onClose.bind(this);
-			this._ws.onmessage = this._onMessage.bind(this);
-			this._ws.onerror = this._onError.bind(this);
-		} catch (error) {
-			this._onError(error);
-		}
-	}
-
-	disconnect() {
-		logger.debug('disconnect()');
-
-		if (this._ws) {
-			// Unbind websocket event callbacks.
-			this._ws.onopen = () => {};
-			this._ws.onclose = () => {};
-			this._ws.onmessage = () => {};
-			this._ws.onerror = () => {};
-
-			this._ws.close();
-			this._ws = null;
-		}
-	}
-
-	send(message) {
-		logger.debug('send()');
-
-		if (this.isConnected()) {
-			this._ws.send(message);
-
-			return true;
-		} else {
-			logger.warn('unable to send message, WebSocket is not open');
-
-			return false;
-		}
-	}
-
-	isConnected() {
-		return this._ws && this._ws.readyState === this._ws.OPEN;
-	}
-
-	isConnecting() {
-		return this._ws && this._ws.readyState === this._ws.CONNECTING;
-	}
-
-	/**
-	 * WebSocket Event Handlers
-	 */
-
-	_onOpen() {
-		logger.debug(`WebSocket ${this._url} connected`);
-
-		this.onconnect();
-	}
-
-	_onClose({ wasClean, code, reason }) {
-		logger.debug(`WebSocket ${this._url} closed`);
-
-		if (wasClean === false) {
-			logger.debug('WebSocket abrupt disconnection');
-		}
-
-		this.ondisconnect(!wasClean, code, reason);
-	}
-
-	_onMessage({ data }) {
-		logger.debug('received WebSocket message');
-
-		this.ondata(data);
-	}
-
-	_onError(e) {
-		logger.warn(`WebSocket ${this._url} error: `, e);
-	}
-};
diff --git a/src/WebSocketInterface.ts b/src/WebSocketInterface.ts
new file mode 100644
index 0000000..733f824
--- /dev/null
+++ b/src/WebSocketInterface.ts
@@ -0,0 +1,157 @@
+import { Logger } from './Logger';
+import * as Grammar from './Grammar';
+import type { Socket } from './Socket';
+
+const logger = new Logger('WebSocketInterface');
+
+export class WebSocketInterface implements Socket {
+	#url: string;
+	#sip_uri: string;
+	#via_transport: string;
+	#ws: WebSocket | null;
+
+	// NOTE: These are assigned by `Transport` once the socket is in use.
+	declare onconnect: () => void;
+	declare ondisconnect: (
+		error: boolean,
+		code?: number,
+		reason?: string
+	) => void;
+	declare ondata: <T>(event: T) => void;
+
+	constructor(url: string) {
+		logger.debug('new() [url:"%s"]', url);
+
+		this.#url = url;
+		this.#ws = null;
+
+		const parsed_url = Grammar.parse(url, 'absoluteURI');
+
+		if (parsed_url === -1) {
+			logger.warn(`invalid WebSocket URI: ${url}`);
+			throw new TypeError(`Invalid argument: ${url}`);
+		} else if (parsed_url.scheme !== 'wss' && parsed_url.scheme !== 'ws') {
+			logger.warn(`invalid WebSocket URI scheme: ${parsed_url.scheme}`);
+			throw new TypeError(`Invalid argument: ${url}`);
+		} else {
+			this.#sip_uri = `sip:${parsed_url.host}${parsed_url.port ? `:${parsed_url.port}` : ''};transport=ws`;
+			this.#via_transport = parsed_url.scheme.toUpperCase();
+		}
+	}
+
+	get via_transport(): string {
+		return this.#via_transport;
+	}
+
+	set via_transport(value: string) {
+		this.#via_transport = value.toUpperCase();
+	}
+
+	get sip_uri(): string {
+		return this.#sip_uri;
+	}
+
+	get url(): string {
+		return this.#url;
+	}
+
+	connect(): void {
+		logger.debug('connect()');
+
+		if (this.isConnected()) {
+			logger.debug(`WebSocket ${this.#url} is already connected`);
+
+			return;
+		} else if (this.isConnecting()) {
+			logger.debug(`WebSocket ${this.#url} is connecting`);
+
+			return;
+		}
+
+		if (this.#ws) {
+			this.disconnect();
+		}
+
+		logger.debug(`connecting to WebSocket ${this.#url}`);
+
+		try {
+			this.#ws = new WebSocket(this.#url, 'sip');
+
+			this.#ws.binaryType = 'arraybuffer';
+
+			this.#ws.onopen = this.#onOpen.bind(this);
+			this.#ws.onclose = this.#onClose.bind(this);
+			this.#ws.onmessage = this.#onMessage.bind(this);
+			this.#ws.onerror = this.#onError.bind(this);
+		} catch (error) {
+			this.#onError(error);
+		}
+	}
+
+	disconnect(): void {
+		logger.debug('disconnect()');
+
+		if (this.#ws) {
+			// Unbind websocket event callbacks.
+			this.#ws.onopen = () => {};
+			this.#ws.onclose = () => {};
+			this.#ws.onmessage = () => {};
+			this.#ws.onerror = () => {};
+
+			this.#ws.close();
+			this.#ws = null;
+		}
+	}
+
+	send(message: string | ArrayBufferLike | Blob | ArrayBufferView): boolean {
+		logger.debug('send()');
+
+		if (this.#ws && this.isConnected()) {
+			this.#ws.send(message);
+
+			return true;
+		} else {
+			logger.warn('unable to send message, WebSocket is not open');
+
+			return false;
+		}
+	}
+
+	isConnected(): boolean {
+		return this.#ws !== null && this.#ws.readyState === this.#ws.OPEN;
+	}
+
+	isConnecting(): boolean {
+		return this.#ws !== null && this.#ws.readyState === this.#ws.CONNECTING;
+	}
+
+	/**
+	 * WebSocket Event Handlers
+	 */
+
+	#onOpen(): void {
+		logger.debug(`WebSocket ${this.#url} connected`);
+
+		this.onconnect();
+	}
+
+	#onClose({ wasClean, code, reason }: CloseEvent): void {
+		logger.debug(`WebSocket ${this.#url} closed`);
+
+		if (wasClean === false) {
+			logger.debug('WebSocket abrupt disconnection');
+		}
+
+		this.ondisconnect(!wasClean, code, reason);
+	}
+
+	#onMessage({ data }: MessageEvent): void {
+		logger.debug('received WebSocket message');
+
+		this.ondata(data);
+	}
+
+	#onError(e: unknown): void {
+		logger.warn(`WebSocket ${this.#url} error: `, e);
+	}
+}