sip.js
Version:
A SIP library for JavaScript
649 lines (648 loc) • 26.5 kB
JavaScript
import { EmitterImpl } from "../../../api/emitter.js";
import { StateTransitionError } from "../../../api/exceptions/state-transition.js";
import { TransportState } from "../../../api/transport-state.js";
import { Grammar } from "../../../grammar/grammar.js";
/**
* Transport for SIP over secure WebSocket (WSS).
* @public
*/
export class Transport {
constructor(logger, options) {
this._state = TransportState.Disconnected;
this.transitioningState = false;
// state emitter
this._stateEventEmitter = new EmitterImpl();
// logger
this.logger = logger;
// guard deprecated options (remove this in version 16.x)
if (options) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const optionsDeprecated = options;
const wsServersDeprecated = optionsDeprecated === null || optionsDeprecated === void 0 ? void 0 : optionsDeprecated.wsServers;
const maxReconnectionAttemptsDeprecated = optionsDeprecated === null || optionsDeprecated === void 0 ? void 0 : optionsDeprecated.maxReconnectionAttempts;
if (wsServersDeprecated !== undefined) {
const deprecatedMessage = `The transport option "wsServers" as has apparently been specified and has been deprecated. ` +
"It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.";
this.logger.warn(deprecatedMessage);
}
if (maxReconnectionAttemptsDeprecated !== undefined) {
const deprecatedMessage = `The transport option "maxReconnectionAttempts" as has apparently been specified and has been deprecated. ` +
"It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.";
this.logger.warn(deprecatedMessage);
}
// hack
if (wsServersDeprecated && !options.server) {
if (typeof wsServersDeprecated === "string") {
options.server = wsServersDeprecated;
}
if (wsServersDeprecated instanceof Array) {
options.server = wsServersDeprecated[0];
}
}
}
// initialize configuration
this.configuration = Object.assign(Object.assign({}, Transport.defaultOptions), options);
// validate server URL
const url = this.configuration.server;
const parsed = Grammar.parse(url, "absoluteURI");
if (parsed === -1) {
this.logger.error(`Invalid WebSocket Server URL "${url}"`);
throw new Error("Invalid WebSocket Server URL");
}
if (!["wss", "ws", "udp"].includes(parsed.scheme)) {
this.logger.error(`Invalid scheme in WebSocket Server URL "${url}"`);
throw new Error("Invalid scheme in WebSocket Server URL");
}
this._protocol = parsed.scheme.toUpperCase();
}
dispose() {
return this.disconnect();
}
/**
* The protocol.
*
* @remarks
* Formatted as defined for the Via header sent-protocol transport.
* https://tools.ietf.org/html/rfc3261#section-20.42
*/
get protocol() {
return this._protocol;
}
/**
* The URL of the WebSocket Server.
*/
get server() {
return this.configuration.server;
}
/**
* Transport state.
*/
get state() {
return this._state;
}
/**
* Transport state change emitter.
*/
get stateChange() {
return this._stateEventEmitter;
}
/**
* The WebSocket.
*/
get ws() {
return this._ws;
}
/**
* Connect to network.
* Resolves once connected. Otherwise rejects with an Error.
*/
connect() {
return this._connect();
}
/**
* Disconnect from network.
* Resolves once disconnected. Otherwise rejects with an Error.
*/
disconnect() {
return this._disconnect();
}
/**
* Returns true if the `state` equals "Connected".
* @remarks
* This is equivalent to `state === TransportState.Connected`.
*/
isConnected() {
return this.state === TransportState.Connected;
}
/**
* Sends a message.
* Resolves once message is sent. Otherwise rejects with an Error.
* @param message - Message to send.
*/
send(message) {
// Error handling is independent of whether the message was a request or
// response.
//
// If the transport user asks for a message to be sent over an
// unreliable transport, and the result is an ICMP error, the behavior
// depends on the type of ICMP error. Host, network, port or protocol
// unreachable errors, or parameter problem errors SHOULD cause the
// transport layer to inform the transport user of a failure in sending.
// Source quench and TTL exceeded ICMP errors SHOULD be ignored.
//
// If the transport user asks for a request to be sent over a reliable
// transport, and the result is a connection failure, the transport
// layer SHOULD inform the transport user of a failure in sending.
// https://tools.ietf.org/html/rfc3261#section-18.4
return this._send(message);
}
_connect() {
this.logger.log(`Connecting ${this.server}`);
switch (this.state) {
case TransportState.Connecting:
// If `state` is "Connecting", `state` MUST NOT transition before returning.
if (this.transitioningState) {
return Promise.reject(this.transitionLoopDetectedError(TransportState.Connecting));
}
if (!this.connectPromise) {
throw new Error("Connect promise must be defined.");
}
return this.connectPromise; // Already connecting
case TransportState.Connected:
// If `state` is "Connected", `state` MUST NOT transition before returning.
if (this.transitioningState) {
return Promise.reject(this.transitionLoopDetectedError(TransportState.Connecting));
}
if (this.connectPromise) {
throw new Error("Connect promise must not be defined.");
}
return Promise.resolve(); // Already connected
case TransportState.Disconnecting:
// If `state` is "Disconnecting", `state` MUST transition to "Connecting" before returning
if (this.connectPromise) {
throw new Error("Connect promise must not be defined.");
}
try {
this.transitionState(TransportState.Connecting);
}
catch (e) {
if (e instanceof StateTransitionError) {
return Promise.reject(e); // Loop detected
}
throw e;
}
break;
case TransportState.Disconnected:
// If `state` is "Disconnected" `state` MUST transition to "Connecting" before returning
if (this.connectPromise) {
throw new Error("Connect promise must not be defined.");
}
try {
this.transitionState(TransportState.Connecting);
}
catch (e) {
if (e instanceof StateTransitionError) {
return Promise.reject(e); // Loop detected
}
throw e;
}
break;
default:
throw new Error("Unknown state");
}
let ws;
try {
// WebSocket()
// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket
ws = new WebSocket(this.server, "sip");
ws.binaryType = "arraybuffer"; // set data type of received binary messages
ws.addEventListener("close", (ev) => this.onWebSocketClose(ev, ws));
ws.addEventListener("error", (ev) => this.onWebSocketError(ev, ws));
ws.addEventListener("open", (ev) => this.onWebSocketOpen(ev, ws));
ws.addEventListener("message", (ev) => this.onWebSocketMessage(ev, ws));
this._ws = ws;
}
catch (error) {
this._ws = undefined;
this.logger.error("WebSocket construction failed.");
this.logger.error(error.toString());
return new Promise((resolve, reject) => {
this.connectResolve = resolve;
this.connectReject = reject;
// The `state` MUST transition to "Disconnecting" or "Disconnected" before rejecting
this.transitionState(TransportState.Disconnected, error);
});
}
this.connectPromise = new Promise((resolve, reject) => {
this.connectResolve = resolve;
this.connectReject = reject;
this.connectTimeout = setTimeout(() => {
this.logger.warn("Connect timed out. " +
"Exceeded time set in configuration.connectionTimeout: " +
this.configuration.connectionTimeout +
"s.");
ws.close(1000); // careful here to use a local reference instead of this._ws
}, this.configuration.connectionTimeout * 1000);
});
return this.connectPromise;
}
_disconnect() {
this.logger.log(`Disconnecting ${this.server}`);
switch (this.state) {
case TransportState.Connecting:
// If `state` is "Connecting", `state` MUST transition to "Disconnecting" before returning.
if (this.disconnectPromise) {
throw new Error("Disconnect promise must not be defined.");
}
try {
this.transitionState(TransportState.Disconnecting);
}
catch (e) {
if (e instanceof StateTransitionError) {
return Promise.reject(e); // Loop detected
}
throw e;
}
break;
case TransportState.Connected:
// If `state` is "Connected", `state` MUST transition to "Disconnecting" before returning.
if (this.disconnectPromise) {
throw new Error("Disconnect promise must not be defined.");
}
try {
this.transitionState(TransportState.Disconnecting);
}
catch (e) {
if (e instanceof StateTransitionError) {
return Promise.reject(e); // Loop detected
}
throw e;
}
break;
case TransportState.Disconnecting:
// If `state` is "Disconnecting", `state` MUST NOT transition before returning.
if (this.transitioningState) {
return Promise.reject(this.transitionLoopDetectedError(TransportState.Disconnecting));
}
if (!this.disconnectPromise) {
throw new Error("Disconnect promise must be defined.");
}
return this.disconnectPromise; // Already disconnecting
case TransportState.Disconnected:
// If `state` is "Disconnected", `state` MUST NOT transition before returning.
if (this.transitioningState) {
return Promise.reject(this.transitionLoopDetectedError(TransportState.Disconnecting));
}
if (this.disconnectPromise) {
throw new Error("Disconnect promise must not be defined.");
}
return Promise.resolve(); // Already disconnected
default:
throw new Error("Unknown state");
}
if (!this._ws) {
throw new Error("WebSocket must be defined.");
}
const ws = this._ws;
this.disconnectPromise = new Promise((resolve, reject) => {
this.disconnectResolve = resolve;
this.disconnectReject = reject;
try {
// WebSocket.close()
// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close
ws.close(1000); // careful here to use a local reference instead of this._ws
}
catch (error) {
// Treating this as a coding error as it apparently can only happen
// if you pass close() invalid parameters (so it should never happen)
this.logger.error("WebSocket close failed.");
this.logger.error(error.toString());
throw error;
}
});
return this.disconnectPromise;
}
_send(message) {
if (this.configuration.traceSip === true) {
this.logger.log("Sending WebSocket message:\n\n" + message + "\n");
}
if (this._state !== TransportState.Connected) {
return Promise.reject(new Error("Not connected."));
}
if (!this._ws) {
throw new Error("WebSocket undefined.");
}
try {
// WebSocket.send()
// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send
this._ws.send(message);
}
catch (error) {
if (error instanceof Error) {
return Promise.reject(error);
}
return Promise.reject(new Error("WebSocket send failed."));
}
return Promise.resolve();
}
/**
* WebSocket "onclose" event handler.
* @param ev - Event.
*/
onWebSocketClose(ev, ws) {
if (ws !== this._ws) {
return;
}
const message = `WebSocket closed ${this.server} (code: ${ev.code})`;
const error = !this.disconnectPromise ? new Error(message) : undefined;
if (error) {
this.logger.warn("WebSocket closed unexpectedly");
}
this.logger.log(message);
// We are about to transition to disconnected, so clear our web socket
this._ws = undefined;
// The `state` MUST transition to "Disconnected" before resolving (assuming `state` is not already "Disconnected").
this.transitionState(TransportState.Disconnected, error);
}
/**
* WebSocket "onerror" event handler.
* @param ev - Event.
*/
onWebSocketError(ev, ws) {
if (ws !== this._ws) {
return;
}
this.logger.error("WebSocket error occurred.");
}
/**
* WebSocket "onmessage" event handler.
* @param ev - Event.
*/
onWebSocketMessage(ev, ws) {
if (ws !== this._ws) {
return;
}
const data = ev.data;
let finishedData;
// CRLF Keep Alive response from server. Clear our keep alive timeout.
if (/^(\r\n)+$/.test(data)) {
this.clearKeepAliveTimeout();
if (this.configuration.traceSip === true) {
this.logger.log("Received WebSocket message with CRLF Keep Alive response");
}
return;
}
if (!data) {
this.logger.warn("Received empty message, discarding...");
return;
}
if (typeof data !== "string") {
// WebSocket binary message.
try {
finishedData = new TextDecoder().decode(new Uint8Array(data));
// TextDecoder (above) is not supported by old browsers, but it correctly decodes UTF-8.
// The line below is an ISO 8859-1 (Latin 1) decoder, so just UTF-8 code points that are 1 byte.
// It's old code and works in old browsers (IE), so leaving it here in a comment in case someone needs it.
// finishedData = String.fromCharCode.apply(null, (new Uint8Array(data) as unknown as Array<number>));
}
catch (err) {
this.logger.error(err.toString());
this.logger.error("Received WebSocket binary message failed to be converted into string, message discarded");
return;
}
if (this.configuration.traceSip === true) {
this.logger.log("Received WebSocket binary message:\n\n" + finishedData + "\n");
}
}
else {
// WebSocket text message.
finishedData = data;
if (this.configuration.traceSip === true) {
this.logger.log("Received WebSocket text message:\n\n" + finishedData + "\n");
}
}
if (this.state !== TransportState.Connected) {
this.logger.warn("Received message while not connected, discarding...");
return;
}
if (this.onMessage) {
try {
this.onMessage(finishedData);
}
catch (e) {
this.logger.error(e.toString());
this.logger.error("Exception thrown by onMessage callback");
throw e; // rethrow unhandled exception
}
}
}
/**
* WebSocket "onopen" event handler.
* @param ev - Event.
*/
onWebSocketOpen(ev, ws) {
if (ws !== this._ws) {
return;
}
if (this._state === TransportState.Connecting) {
this.logger.log(`WebSocket opened ${this.server}`);
this.transitionState(TransportState.Connected);
}
}
/**
* Helper function to generate an Error.
* @param state - State transitioning to.
*/
transitionLoopDetectedError(state) {
let message = `A state transition loop has been detected.`;
message += ` An attempt to transition from ${this._state} to ${state} before the prior transition completed.`;
message += ` Perhaps you are synchronously calling connect() or disconnect() from a callback or state change handler?`;
this.logger.error(message);
return new StateTransitionError("Loop detected.");
}
/**
* Transition transport state.
* @internal
*/
transitionState(newState, error) {
const invalidTransition = () => {
throw new Error(`Invalid state transition from ${this._state} to ${newState}`);
};
if (this.transitioningState) {
throw this.transitionLoopDetectedError(newState);
}
this.transitioningState = true;
// Validate state transition
switch (this._state) {
case TransportState.Connecting:
if (newState !== TransportState.Connected &&
newState !== TransportState.Disconnecting &&
newState !== TransportState.Disconnected) {
invalidTransition();
}
break;
case TransportState.Connected:
if (newState !== TransportState.Disconnecting && newState !== TransportState.Disconnected) {
invalidTransition();
}
break;
case TransportState.Disconnecting:
if (newState !== TransportState.Connecting && newState !== TransportState.Disconnected) {
invalidTransition();
}
break;
case TransportState.Disconnected:
if (newState !== TransportState.Connecting) {
invalidTransition();
}
break;
default:
throw new Error("Unknown state.");
}
// Update state
const oldState = this._state;
this._state = newState;
// Local copies of connect promises (guarding against callbacks changing them indirectly)
// const connectPromise = this.connectPromise;
const connectResolve = this.connectResolve;
const connectReject = this.connectReject;
// Reset connect promises if no longer connecting
if (oldState === TransportState.Connecting) {
this.connectPromise = undefined;
this.connectResolve = undefined;
this.connectReject = undefined;
}
// Local copies of disconnect promises (guarding against callbacks changing them indirectly)
// const disconnectPromise = this.disconnectPromise;
const disconnectResolve = this.disconnectResolve;
const disconnectReject = this.disconnectReject;
// Reset disconnect promises if no longer disconnecting
if (oldState === TransportState.Disconnecting) {
this.disconnectPromise = undefined;
this.disconnectResolve = undefined;
this.disconnectReject = undefined;
}
// Clear any outstanding connect timeout
if (this.connectTimeout) {
clearTimeout(this.connectTimeout);
this.connectTimeout = undefined;
}
this.logger.log(`Transitioned from ${oldState} to ${this._state}`);
this._stateEventEmitter.emit(this._state);
// Transition to Connected
if (newState === TransportState.Connected) {
this.startSendingKeepAlives();
if (this.onConnect) {
try {
this.onConnect();
}
catch (e) {
this.logger.error(e.toString());
this.logger.error("Exception thrown by onConnect callback");
throw e; // rethrow unhandled exception
}
}
}
// Transition from Connected
if (oldState === TransportState.Connected) {
this.stopSendingKeepAlives();
if (this.onDisconnect) {
try {
if (error) {
this.onDisconnect(error);
}
else {
this.onDisconnect();
}
}
catch (e) {
this.logger.error(e.toString());
this.logger.error("Exception thrown by onDisconnect callback");
throw e; // rethrow unhandled exception
}
}
}
// Complete connect promise
if (oldState === TransportState.Connecting) {
if (!connectResolve) {
throw new Error("Connect resolve undefined.");
}
if (!connectReject) {
throw new Error("Connect reject undefined.");
}
newState === TransportState.Connected ? connectResolve() : connectReject(error || new Error("Connect aborted."));
}
// Complete disconnect promise
if (oldState === TransportState.Disconnecting) {
if (!disconnectResolve) {
throw new Error("Disconnect resolve undefined.");
}
if (!disconnectReject) {
throw new Error("Disconnect reject undefined.");
}
newState === TransportState.Disconnected
? disconnectResolve()
: disconnectReject(error || new Error("Disconnect aborted."));
}
this.transitioningState = false;
}
// TODO: Review "KeepAlive Stuff".
// It is not clear if it works and there are no tests for it.
// It was blindly lifted the keep alive code unchanged from earlier transport code.
//
// From the RFC...
//
// SIP WebSocket Clients and Servers may keep their WebSocket
// connections open by sending periodic WebSocket "Ping" frames as
// described in [RFC6455], Section 5.5.2.
// ...
// The indication and use of the CRLF NAT keep-alive mechanism defined
// for SIP connection-oriented transports in [RFC5626], Section 3.5.1 or
// [RFC6223] are, of course, usable over the transport defined in this
// specification.
// https://tools.ietf.org/html/rfc7118#section-6
//
// and...
//
// The Ping frame contains an opcode of 0x9.
// https://tools.ietf.org/html/rfc6455#section-5.5.2
//
// ==============================
// KeepAlive Stuff
// ==============================
clearKeepAliveTimeout() {
if (this.keepAliveDebounceTimeout) {
clearTimeout(this.keepAliveDebounceTimeout);
}
this.keepAliveDebounceTimeout = undefined;
}
/**
* Send a keep-alive (a double-CRLF sequence).
*/
sendKeepAlive() {
if (this.keepAliveDebounceTimeout) {
// We already have an outstanding keep alive, do not send another.
return Promise.resolve();
}
this.keepAliveDebounceTimeout = setTimeout(() => {
this.clearKeepAliveTimeout();
}, this.configuration.keepAliveDebounce * 1000);
return this.send("\r\n\r\n");
}
/**
* Start sending keep-alives.
*/
startSendingKeepAlives() {
// Compute an amount of time in seconds to wait before sending another keep-alive.
const computeKeepAliveTimeout = (upperBound) => {
const lowerBound = upperBound * 0.8;
return 1000 * (Math.random() * (upperBound - lowerBound) + lowerBound);
};
if (this.configuration.keepAliveInterval && !this.keepAliveInterval) {
this.keepAliveInterval = setInterval(() => {
this.sendKeepAlive();
this.startSendingKeepAlives();
}, computeKeepAliveTimeout(this.configuration.keepAliveInterval));
}
}
/**
* Stop sending keep-alives.
*/
stopSendingKeepAlives() {
if (this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
}
if (this.keepAliveDebounceTimeout) {
clearTimeout(this.keepAliveDebounceTimeout);
}
this.keepAliveInterval = undefined;
this.keepAliveDebounceTimeout = undefined;
}
}
Transport.defaultOptions = {
server: "",
connectionTimeout: 5,
keepAliveInterval: 0,
keepAliveDebounce: 10,
traceSip: true
};