Commit acfb7fb for jssip.net
commit acfb7fb836967574d636529d1078979c9b573358
Author: José Luis Millán <jmillan@aliax.net>
Date: Tue Oct 20 16:22:29 2026 +0200
TS: Transport (#986)
diff --git a/src/SIPMessage.ts b/src/SIPMessage.ts
index 7e632b7..37d589e 100644
--- a/src/SIPMessage.ts
+++ b/src/SIPMessage.ts
@@ -3,6 +3,7 @@ import type { EventEmitter } from 'events';
import * as JsSIP_C from './Constants';
import { Logger } from './Logger';
import * as Grammar from './Grammar';
+import type { Transport } from './Transport';
import {
cloneArray,
createRandomToken,
@@ -84,18 +85,6 @@ export type OutgoingRequestParams = {
* 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.
*
@@ -149,7 +138,7 @@ export interface UALike {
pub_gruu?: string | null;
temp_gruu?: string | null;
};
- readonly transport: TransportLike;
+ readonly transport: Transport;
}
/**
@@ -829,7 +818,7 @@ export class IncomingMessage {
export class IncomingRequest extends IncomingMessage {
ua: UALike;
ruri: URI;
- transport: TransportLike | null;
+ transport: Transport | null;
server_transaction: ServerTransactionLike | null;
constructor({ ua, ruri, ...params }: IncomingRequestParams) {
diff --git a/src/Socket.ts b/src/Socket.ts
index 5c6e5a0..00047c1 100644
--- a/src/Socket.ts
+++ b/src/Socket.ts
@@ -28,12 +28,12 @@ export interface Socket {
ondisconnect(error: boolean, code?: number, reason?: string): void;
- ondata<T>(event: T): void;
+ ondata(data: string | ArrayBuffer): void;
}
export interface WeightedSocket {
socket: Socket;
- weight: number;
+ weight?: number;
}
export const isSocket = (socket: unknown): socket is Socket => {
diff --git a/src/Transport.d.ts b/src/Transport.d.ts
deleted file mode 100644
index 8f05346..0000000
--- a/src/Transport.d.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { Socket } from './Socket';
-
-export interface RecoveryOptions {
- min_interval: number;
- max_interval: number;
-}
-
-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/Transport.js b/src/Transport.js
deleted file mode 100644
index 008d335..0000000
--- a/src/Transport.js
+++ /dev/null
@@ -1,345 +0,0 @@
-const { Logger } = require('./Logger');
-const Socket = require('./Socket');
-const JsSIP_C = require('./Constants');
-
-const logger = new Logger('Transport');
-
-/**
- * Constants
- */
-const C = {
- // Transport status.
- STATUS_CONNECTED: 0,
- STATUS_CONNECTING: 1,
- STATUS_DISCONNECTED: 2,
-
- // Socket status.
- SOCKET_STATUS_READY: 0,
- SOCKET_STATUS_ERROR: 1,
-
- // Recovery options.
- recovery_options: {
- // minimum interval in seconds between recover attempts.
- min_interval: JsSIP_C.CONNECTION_RECOVERY_MIN_INTERVAL,
- // maximum interval in seconds between recover attempts.
- max_interval: JsSIP_C.CONNECTION_RECOVERY_MAX_INTERVAL,
- },
-};
-
-/*
- * Manages one or multiple JsSIP.Socket instances.
- * Is reponsible for transport recovery logic among all socket instances.
- *
- * @socket JsSIP::Socket instance
- */
-module.exports = class Transport {
- constructor(sockets, recovery_options = C.recovery_options) {
- logger.debug('new()');
-
- this.status = C.STATUS_DISCONNECTED;
-
- // Current socket.
- this.socket = null;
-
- // Socket collection.
- this.sockets = [];
-
- this.recovery_options = recovery_options;
- this.recover_attempts = 0;
- this.recovery_timer = null;
-
- this.close_requested = false;
-
- // It seems that TextDecoder is not available in some versions of React-Native.
- // See https://github.com/versatica/JsSIP/issues/695
- try {
- this.textDecoder = new TextDecoder('utf8');
- } catch (error) {
- logger.warn(`cannot use TextDecoder: ${error}`);
- }
-
- if (typeof sockets === 'undefined') {
- throw new TypeError("Invalid argument. undefined 'sockets' argument");
- }
-
- if (!(sockets instanceof Array)) {
- sockets = [sockets];
- }
-
- sockets.forEach(function (socket) {
- if (!Socket.isSocket(socket.socket)) {
- throw new TypeError(
- "Invalid argument. invalid 'JsSIP.Socket' instance"
- );
- }
-
- if (socket.weight && !Number(socket.weight)) {
- throw new TypeError(
- "Invalid argument. 'weight' attribute is not a number"
- );
- }
-
- this.sockets.push({
- socket: socket.socket,
- weight: socket.weight || 0,
- status: C.SOCKET_STATUS_READY,
- });
- }, this);
-
- // Get the socket with higher weight.
- this._getSocket();
- }
-
- /**
- * Instance Methods
- */
-
- get via_transport() {
- return this.socket.via_transport;
- }
-
- get url() {
- return this.socket.url;
- }
-
- get sip_uri() {
- return this.socket.sip_uri;
- }
-
- connect() {
- logger.debug('connect()');
-
- if (this.isConnected()) {
- logger.debug('Transport is already connected');
-
- return;
- } else if (this.isConnecting()) {
- logger.debug('Transport is connecting');
-
- return;
- }
-
- this.close_requested = false;
- this.status = C.STATUS_CONNECTING;
- this.onconnecting({ socket: this.socket, attempts: this.recover_attempts });
-
- if (!this.close_requested) {
- // Bind socket event callbacks.
- this.socket.onconnect = this._onConnect.bind(this);
- this.socket.ondisconnect = this._onDisconnect.bind(this);
- this.socket.ondata = this._onData.bind(this);
-
- this.socket.connect();
- }
-
- return;
- }
-
- disconnect() {
- logger.debug('close()');
-
- this.close_requested = true;
- this.recover_attempts = 0;
- this.status = C.STATUS_DISCONNECTED;
-
- // Clear recovery_timer.
- if (this.recovery_timer !== null) {
- clearTimeout(this.recovery_timer);
- this.recovery_timer = null;
- }
-
- // Unbind socket event callbacks.
- this.socket.onconnect = () => {};
- this.socket.ondisconnect = () => {};
- this.socket.ondata = () => {};
-
- this.socket.disconnect();
- this.ondisconnect({
- socket: this.socket,
- error: false,
- });
- }
-
- send(data) {
- logger.debug('send()');
-
- if (!this.isConnected()) {
- logger.warn('unable to send message, transport is not connected');
-
- return false;
- }
-
- const message = data.toString();
-
- logger.debug(`sending message:\n\n${message}\n`);
-
- return this.socket.send(message);
- }
-
- isConnected() {
- return this.status === C.STATUS_CONNECTED;
- }
-
- isConnecting() {
- return this.status === C.STATUS_CONNECTING;
- }
-
- /**
- * Private API.
- */
-
- _reconnect() {
- this.recover_attempts += 1;
-
- let k = Math.floor(Math.random() * Math.pow(2, this.recover_attempts) + 1);
-
- if (k < this.recovery_options.min_interval) {
- k = this.recovery_options.min_interval;
- } else if (k > this.recovery_options.max_interval) {
- k = this.recovery_options.max_interval;
- }
-
- logger.debug(
- `reconnection attempt: ${this.recover_attempts}. next connection attempt in ${k} seconds`
- );
-
- this.recovery_timer = setTimeout(() => {
- if (
- !this.close_requested &&
- !(this.isConnected() || this.isConnecting())
- ) {
- // Get the next available socket with higher weight.
- this._getSocket();
-
- // Connect the socket.
- this.connect();
- }
- }, k * 1000);
- }
-
- /**
- * get the next available socket with higher weight
- */
- _getSocket() {
- let candidates = [];
-
- this.sockets.forEach(socket => {
- if (socket.status === C.SOCKET_STATUS_ERROR) {
- return; // continue the array iteration
- } else if (candidates.length === 0) {
- candidates.push(socket);
- } else if (socket.weight > candidates[0].weight) {
- candidates = [socket];
- } else if (socket.weight === candidates[0].weight) {
- candidates.push(socket);
- }
- });
-
- if (candidates.length === 0) {
- // All sockets have failed. reset sockets status.
- this.sockets.forEach(socket => {
- socket.status = C.SOCKET_STATUS_READY;
- });
-
- // Get next available socket.
- this._getSocket();
-
- return;
- }
-
- const idx = Math.floor(Math.random() * candidates.length);
-
- this.socket = candidates[idx].socket;
- }
-
- /**
- * Socket Event Handlers
- */
-
- _onConnect() {
- this.recover_attempts = 0;
- this.status = C.STATUS_CONNECTED;
-
- // Clear recovery_timer.
- if (this.recovery_timer !== null) {
- clearTimeout(this.recovery_timer);
- this.recovery_timer = null;
- }
-
- this.onconnect({ socket: this });
- }
-
- _onDisconnect(error, code, reason) {
- this.status = C.STATUS_DISCONNECTED;
- this.ondisconnect({
- socket: this.socket,
- error,
- code,
- reason,
- });
-
- if (this.close_requested) {
- return;
- }
-
- // Update socket status.
- else {
- this.sockets.forEach(function (socket) {
- if (this.socket === socket.socket) {
- socket.status = C.SOCKET_STATUS_ERROR;
- }
- }, this);
- }
-
- this._reconnect(error);
- }
-
- _onData(data) {
- // CRLF Keep Alive request from server, reply.
- if (data === '\r\n\r\n') {
- logger.debug('received message with double-CRLF Keep Alive request');
-
- try {
- // Reply with single CRLF.
- this.socket.send('\r\n');
- } catch (error) {
- logger.warn(`error sending Keep Alive response: ${error}`);
- }
-
- return;
- }
-
- // CRLF Keep Alive response from server, ignore it.
- if (data === '\r\n') {
- logger.debug('received message with CRLF Keep Alive response');
-
- return;
- }
-
- // Binary message.
- else if (typeof data !== 'string') {
- try {
- if (this.textDecoder) {
- data = this.textDecoder.decode(data);
- } else {
- data = String.fromCharCode.apply(null, new Uint8Array(data));
- }
- } catch (error) {
- logger.debug(
- `received binary message failed to be converted into string: ${error}`
- );
-
- return;
- }
-
- logger.debug(`received binary message:\n\n${data}\n`);
- }
-
- // Text message.
- else {
- logger.debug(`received text message:\n\n${data}\n`);
- }
-
- this.ondata({ transport: this, message: data });
- }
-};
diff --git a/src/Transport.ts b/src/Transport.ts
new file mode 100644
index 0000000..87f7d88
--- /dev/null
+++ b/src/Transport.ts
@@ -0,0 +1,412 @@
+import { Logger } from './Logger';
+import { isSocket } from './Socket';
+import type { Socket, WeightedSocket } from './Socket';
+import {
+ CONNECTION_RECOVERY_MAX_INTERVAL,
+ CONNECTION_RECOVERY_MIN_INTERVAL,
+} from './Constants';
+
+const logger = new Logger('Transport');
+
+/**
+ * Constants
+ */
+const C = {
+ // Transport status.
+ STATUS_CONNECTED: 0,
+ STATUS_CONNECTING: 1,
+ STATUS_DISCONNECTED: 2,
+
+ // Socket status.
+ SOCKET_STATUS_READY: 0,
+ SOCKET_STATUS_ERROR: 1,
+
+ // Recovery options.
+ recovery_options: {
+ // minimum interval in seconds between recover attempts.
+ min_interval: CONNECTION_RECOVERY_MIN_INTERVAL,
+ // maximum interval in seconds between recover attempts.
+ max_interval: CONNECTION_RECOVERY_MAX_INTERVAL,
+ },
+};
+
+export type RecoveryOptions = {
+ min_interval: number;
+ max_interval: number;
+};
+
+/**
+ * A socket of the collection, along with its weight and current status.
+ */
+export type TransportSocket = {
+ socket: Socket;
+ weight: number;
+ status: number;
+};
+
+export type TransportConnectingEvent = {
+ socket: Socket;
+ attempts: number;
+};
+
+/**
+ * NOTE: `socket` holds the `Transport` itself, not the underlying socket.
+ */
+export type TransportConnectedEvent = {
+ socket: Transport;
+};
+
+export type TransportDisconnectedEvent = {
+ socket: Socket;
+ error: boolean;
+ code?: number;
+ reason?: string;
+};
+
+export type TransportDataEvent = {
+ transport: Transport;
+ message: string;
+};
+
+/*
+ * Manages one or multiple JsSIP.Socket instances.
+ * Is reponsible for transport recovery logic among all socket instances.
+ *
+ * @socket JsSIP::Socket instance
+ */
+export class Transport {
+ #status: number;
+ // Current socket.
+ #socket: Socket;
+ // Socket collection.
+ #sockets: TransportSocket[] = [];
+ #recovery_options: RecoveryOptions;
+ #recover_attempts = 0;
+ #recovery_timer?: ReturnType<typeof setTimeout>;
+ #close_requested = false;
+ #textDecoder?: TextDecoder;
+
+ // NOTE: These are assigned by `UA` once the transport is in use.
+ declare onconnecting: (event: TransportConnectingEvent) => void;
+ declare onconnect: (event: TransportConnectedEvent) => void;
+ declare ondisconnect: (event: TransportDisconnectedEvent) => void;
+ declare ondata: (event: TransportDataEvent) => void;
+
+ constructor(
+ sockets: WeightedSocket | WeightedSocket[],
+ recovery_options: RecoveryOptions = C.recovery_options
+ ) {
+ logger.debug('new()');
+
+ this.#status = C.STATUS_DISCONNECTED;
+ this.#recovery_options = recovery_options;
+
+ // It seems that TextDecoder is not available in some versions of React-Native.
+ // See https://github.com/versatica/JsSIP/issues/695
+ try {
+ this.#textDecoder = new TextDecoder('utf8');
+ } catch (error) {
+ logger.warn(`cannot use TextDecoder: ${error}`);
+ }
+
+ if (typeof sockets === 'undefined') {
+ throw new TypeError("Invalid argument. undefined 'sockets' argument");
+ }
+
+ if (!(sockets instanceof Array)) {
+ sockets = [sockets];
+ }
+
+ sockets.forEach(socket => {
+ if (!isSocket(socket.socket)) {
+ throw new TypeError(
+ "Invalid argument. invalid 'JsSIP.Socket' instance"
+ );
+ }
+
+ if (socket.weight && !Number(socket.weight)) {
+ throw new TypeError(
+ "Invalid argument. 'weight' attribute is not a number"
+ );
+ }
+
+ this.#sockets.push({
+ socket: socket.socket,
+ weight: socket.weight ?? 0,
+ status: C.SOCKET_STATUS_READY,
+ });
+ });
+
+ // Get the socket with higher weight.
+ this.#socket = this.#getSocket();
+ }
+
+ /**
+ * Instance Methods
+ */
+
+ get via_transport(): string {
+ return this.#socket.via_transport;
+ }
+
+ get url(): string {
+ return this.#socket.url;
+ }
+
+ get sip_uri(): string {
+ return this.#socket.sip_uri;
+ }
+
+ get sockets(): TransportSocket[] {
+ return this.#sockets;
+ }
+
+ get recovery_options(): RecoveryOptions {
+ return this.#recovery_options;
+ }
+
+ connect(): void {
+ logger.debug('connect()');
+
+ if (this.isConnected()) {
+ logger.debug('Transport is already connected');
+
+ return;
+ } else if (this.isConnecting()) {
+ logger.debug('Transport is connecting');
+
+ return;
+ }
+
+ this.#close_requested = false;
+ this.#status = C.STATUS_CONNECTING;
+ this.onconnecting({
+ socket: this.#socket,
+ attempts: this.#recover_attempts,
+ });
+
+ if (!this.#close_requested) {
+ // Bind socket event callbacks.
+ this.#socket.onconnect = this.#onConnect.bind(this);
+ this.#socket.ondisconnect = this.#onDisconnect.bind(this);
+ this.#socket.ondata = this.#onData.bind(this);
+
+ this.#socket.connect();
+ }
+
+ return;
+ }
+
+ disconnect(): void {
+ logger.debug('close()');
+
+ this.#close_requested = true;
+ this.#recover_attempts = 0;
+ this.#status = C.STATUS_DISCONNECTED;
+
+ // Clear recovery_timer.
+ if (this.#recovery_timer !== undefined) {
+ clearTimeout(this.#recovery_timer);
+ this.#recovery_timer = undefined;
+ }
+
+ // Unbind socket event callbacks.
+ this.#socket.onconnect = () => {};
+ this.#socket.ondisconnect = () => {};
+ this.#socket.ondata = () => {};
+
+ this.#socket.disconnect();
+ this.ondisconnect({
+ socket: this.#socket,
+ error: false,
+ });
+ }
+
+ send(data: { toString(): string }): boolean {
+ logger.debug('send()');
+
+ if (!this.isConnected()) {
+ logger.warn('unable to send message, transport is not connected');
+
+ return false;
+ }
+
+ const message = data.toString();
+
+ logger.debug(`sending message:\n\n${message}\n`);
+
+ return this.#socket.send(message);
+ }
+
+ isConnected(): boolean {
+ return this.#status === C.STATUS_CONNECTED;
+ }
+
+ isConnecting(): boolean {
+ return this.#status === C.STATUS_CONNECTING;
+ }
+
+ /**
+ * Private API.
+ */
+
+ #reconnect(): void {
+ this.#recover_attempts += 1;
+
+ let k = Math.floor(Math.random() * Math.pow(2, this.#recover_attempts) + 1);
+
+ if (k < this.#recovery_options.min_interval) {
+ k = this.#recovery_options.min_interval;
+ } else if (k > this.#recovery_options.max_interval) {
+ k = this.#recovery_options.max_interval;
+ }
+
+ logger.debug(
+ `reconnection attempt: ${this.#recover_attempts}. next connection attempt in ${k} seconds`
+ );
+
+ this.#recovery_timer = setTimeout(() => {
+ if (
+ !this.#close_requested &&
+ !(this.isConnected() || this.isConnecting())
+ ) {
+ // Get the next available socket with higher weight.
+ this.#socket = this.#getSocket();
+
+ // Connect the socket.
+ this.connect();
+ }
+ }, k * 1000);
+ }
+
+ /**
+ * get the next available socket with higher weight
+ */
+ #getSocket(): Socket {
+ let candidates: TransportSocket[] = [];
+
+ this.#sockets.forEach(socket => {
+ if (socket.status === C.SOCKET_STATUS_ERROR) {
+ return; // continue the array iteration
+ } else if (candidates.length === 0) {
+ candidates.push(socket);
+ } else if (socket.weight > candidates[0]!.weight) {
+ candidates = [socket];
+ } else if (socket.weight === candidates[0]!.weight) {
+ candidates.push(socket);
+ }
+ });
+
+ if (candidates.length === 0) {
+ // All sockets have failed. reset sockets status.
+ this.#sockets.forEach(socket => {
+ socket.status = C.SOCKET_STATUS_READY;
+ });
+
+ // Get next available socket.
+ return this.#getSocket();
+ }
+
+ const idx = Math.floor(Math.random() * candidates.length);
+
+ return candidates[idx]!.socket;
+ }
+
+ /**
+ * Socket Event Handlers
+ */
+
+ #onConnect(): void {
+ this.#recover_attempts = 0;
+ this.#status = C.STATUS_CONNECTED;
+
+ // Clear recovery_timer.
+ if (this.#recovery_timer !== undefined) {
+ clearTimeout(this.#recovery_timer);
+ this.#recovery_timer = undefined;
+ }
+
+ this.onconnect({ socket: this });
+ }
+
+ #onDisconnect(error: boolean, code?: number, reason?: string): void {
+ this.#status = C.STATUS_DISCONNECTED;
+ this.ondisconnect({
+ socket: this.#socket,
+ error,
+ code,
+ reason,
+ });
+
+ if (this.#close_requested) {
+ return;
+ }
+
+ // Update socket status.
+ else {
+ this.#sockets.forEach(socket => {
+ if (this.#socket === socket.socket) {
+ socket.status = C.SOCKET_STATUS_ERROR;
+ }
+ });
+ }
+
+ this.#reconnect();
+ }
+
+ #onData(data: string | ArrayBuffer): void {
+ // CRLF Keep Alive request from server, reply.
+ if (data === '\r\n\r\n') {
+ logger.debug('received message with double-CRLF Keep Alive request');
+
+ try {
+ // Reply with single CRLF.
+ this.#socket.send('\r\n');
+ } catch (error) {
+ logger.warn(`error sending Keep Alive response: ${error}`);
+ }
+
+ return;
+ }
+
+ // CRLF Keep Alive response from server, ignore it.
+ if (data === '\r\n') {
+ logger.debug('received message with CRLF Keep Alive response');
+
+ return;
+ }
+
+ let message: string;
+
+ // Binary message.
+ if (typeof data !== 'string') {
+ try {
+ if (this.#textDecoder) {
+ message = this.#textDecoder.decode(data);
+ } else {
+ message = String.fromCharCode.apply(
+ null,
+ Array.from(new Uint8Array(data))
+ );
+ }
+ } catch (error) {
+ logger.debug(
+ `received binary message failed to be converted into string: ${error}`
+ );
+
+ return;
+ }
+
+ logger.debug(`received binary message:\n\n${message}\n`);
+ }
+
+ // Text message.
+ else {
+ message = data;
+
+ logger.debug(`received text message:\n\n${message}\n`);
+ }
+
+ this.ondata({ transport: this, message });
+ }
+}
diff --git a/src/UA.js b/src/UA.js
index 2c51bb6..e330e34 100644
--- a/src/UA.js
+++ b/src/UA.js
@@ -10,7 +10,7 @@ const Notifier = require('./Notifier');
const Message = require('./Message');
const Options = require('./Options');
const Transactions = require('./Transactions');
-const Transport = require('./Transport');
+const { Transport } = require('./Transport');
const Utils = require('./Utils');
const Exceptions = require('./Exceptions');
const URI = require('./URI');
diff --git a/src/WebSocketInterface.ts b/src/WebSocketInterface.ts
index 733f824..33d2315 100644
--- a/src/WebSocketInterface.ts
+++ b/src/WebSocketInterface.ts
@@ -17,7 +17,7 @@ export class WebSocketInterface implements Socket {
code?: number,
reason?: string
) => void;
- declare ondata: <T>(event: T) => void;
+ declare ondata: (data: string | ArrayBuffer) => void;
constructor(url: string) {
logger.debug('new() [url:"%s"]', url);