/**
* A JavaScript library to enable XMPP over Websocket in Strophejs.
*
* This file implements XMPP over WebSockets for Strophejs.
* If a Connection is established with a Websocket url (ws://...)
* Strophe will use WebSockets.
* For more information on XMPP-over-WebSocket see RFC 7395:
* http://tools.ietf.org/html/rfc7395
*
* WebSocket support implemented by Andreas Guth (andreas.guth@rwth-aachen.de)
*/
/* global clearTimeout, location */
/**
* @typedef {import("./connection.js").default} Connection
*/
import { DOMParser, WebSocket } from './shims';
import Builder, { $build } from './builder.js';
import log from './log.js';
import { NS, ErrorCondition, Status } from './constants.js';
/**
* Helper class that handles WebSocket Connections
*
* The WebSocket class is used internally by Connection
* to encapsulate WebSocket sessions. It is not meant to be used from user's code.
*/
class Websocket {
/**
* Create and initialize a WebSocket object.
* Currently only sets the connection Object.
* @param {Connection} connection - The Connection that will use WebSockets.
*/
constructor(connection) {
this._conn = connection;
this.strip = 'wrapper';
const service = connection.service;
if (service.indexOf('ws:') !== 0 && service.indexOf('wss:') !== 0) {
// If the service is not an absolute URL, assume it is a path and put the absolute
// URL together from options, current URL and the path.
let new_service = '';
if (connection.options.protocol === 'ws' && location.protocol !== 'https:') {
new_service += 'ws';
} else {
new_service += 'wss';
}
new_service += '://' + location.host;
if (service.indexOf('/') !== 0) {
new_service += location.pathname + service;
} else {
new_service += service;
}
connection.service = new_service;
}
}
/**
* _Private_ helper function to generate the <stream> start tag for WebSockets
* @private
* @return {Builder} - A Builder with a <stream> element.
*/
_buildStream() {
return $build('open', {
'xmlns': NS.FRAMING,
'to': this._conn.domain,
'version': '1.0',
});
}
/**
* _Private_ checks a message for stream:error
* @private
* @param {Element} bodyWrap - The received stanza.
* @param {number} connectstatus - The ConnectStatus that will be set on error.
* @return {boolean} - true if there was a streamerror, false otherwise.
*/
_checkStreamError(bodyWrap, connectstatus) {
let errors;
if (bodyWrap.getElementsByTagNameNS) {
errors = bodyWrap.getElementsByTagNameNS(NS.STREAM, 'error');
} else {
errors = bodyWrap.getElementsByTagName('stream:error');
}
if (errors.length === 0) {
return false;
}
const error = errors[0];
let condition = '';
let text = '';
const ns = 'urn:ietf:params:xml:ns:xmpp-streams';
for (let i = 0; i < error.childNodes.length; i++) {
const e = error.childNodes[i];
if (e.nodeType === e.ELEMENT_NODE) {
/** @type {Element} */
const el = /** @type {any} */ (e);
if (el.getAttribute('xmlns') !== ns) {
break;
}
}
if (e.nodeName === 'text') {
text = e.textContent;
} else {
condition = e.nodeName;
}
}
let errorString = 'WebSocket stream error: ';
if (condition) {
errorString += condition;
} else {
errorString += 'unknown';
}
if (text) {
errorString += ' - ' + text;
}
log.error(errorString);
// close the connection on stream_error
this._conn._changeConnectStatus(connectstatus, condition);
this._conn._doDisconnect();
return true;
}
/**
* Reset the connection.
*
* This function is called by the reset function of the Strophe Connection.
* Is not needed by WebSockets.
*/
// eslint-disable-next-line class-methods-use-this
_reset() {
return;
}
/**
* _Private_ function called by Connection.connect
*
* Creates a WebSocket for a connection and assigns Callbacks to it.
* Does nothing if there already is a WebSocket.
*/
_connect() {
// Ensure that there is no open WebSocket from a previous Connection.
this._closeSocket();
/**
* @typedef {Object} WebsocketLike
* @property {(str: string) => void} WebsocketLike.send
* @property {function(): void} WebsocketLike.close
* @property {function(): void} WebsocketLike.onopen
* @property {(e: ErrorEvent) => void} WebsocketLike.onerror
* @property {(e: CloseEvent) => void} WebsocketLike.onclose
* @property {(message: MessageEvent) => void} WebsocketLike.onmessage
* @property {string} WebsocketLike.readyState
*/
/** @type {import('ws')|WebSocket|WebsocketLike} */
this.socket = new WebSocket(this._conn.service, 'xmpp');
this.socket.onopen = () => this._onOpen();
/** @param {ErrorEvent} e */
this.socket.onerror = (e) => this._onError(e);
/** @param {CloseEvent} e */
this.socket.onclose = (e) => this._onClose(e);
/**
* Gets replaced with this._onMessage once _onInitialMessage is called
* @param {MessageEvent} message
*/
this.socket.onmessage = (message) => this._onInitialMessage(message);
}
/**
* _Private_ function called by Connection._connect_cb
* checks for stream:error
* @param {Element} bodyWrap - The received stanza.
*/
_connect_cb(bodyWrap) {
const error = this._checkStreamError(bodyWrap, Status.CONNFAIL);
if (error) {
return Status.CONNFAIL;
}
}
/**
* _Private_ function that checks the opening <open /> tag for errors.
*
* Disconnects if there is an error and returns false, true otherwise.
* @private
* @param {Element} message - Stanza containing the <open /> tag.
*/
_handleStreamStart(message) {
let error = null;
// Check for errors in the <open /> tag
const ns = message.getAttribute('xmlns');
if (typeof ns !== 'string') {
error = 'Missing xmlns in <open />';
} else if (ns !== NS.FRAMING) {
error = 'Wrong xmlns in <open />: ' + ns;
}
const ver = message.getAttribute('version');
if (typeof ver !== 'string') {
error = 'Missing version in <open />';
} else if (ver !== '1.0') {
error = 'Wrong version in <open />: ' + ver;
}
if (error) {
this._conn._changeConnectStatus(Status.CONNFAIL, error);
this._conn._doDisconnect();
return false;
}
return true;
}
/**
* _Private_ function that handles the first connection messages.
*
* On receiving an opening stream tag this callback replaces itself with the real
* message handler. On receiving a stream error the connection is terminated.
* @param {MessageEvent} message
*/
_onInitialMessage(message) {
if (message.data.indexOf('<open ') === 0 || message.data.indexOf('<?xml') === 0) {
// Strip the XML Declaration, if there is one
const data = message.data.replace(/^(<\?.*?\?>\s*)*/, '');
if (data === '') return;
const streamStart = new DOMParser().parseFromString(data, 'text/xml').documentElement;
this._conn.xmlInput(streamStart);
this._conn.rawInput(message.data);
//_handleStreamSteart will check for XML errors and disconnect on error
if (this._handleStreamStart(streamStart)) {
//_connect_cb will check for stream:error and disconnect on error
this._connect_cb(streamStart);
}
} else if (message.data.indexOf('<close ') === 0) {
// <close xmlns="urn:ietf:params:xml:ns:xmpp-framing />
// Parse the raw string to an XML element
const parsedMessage = new DOMParser().parseFromString(message.data, 'text/xml').documentElement;
// Report this input to the raw and xml handlers
this._conn.xmlInput(parsedMessage);
this._conn.rawInput(message.data);
const see_uri = parsedMessage.getAttribute('see-other-uri');
if (see_uri) {
const service = this._conn.service;
// Valid scenarios: WSS->WSS, WS->ANY
const isSecureRedirect =
(service.indexOf('wss:') >= 0 && see_uri.indexOf('wss:') >= 0) || service.indexOf('ws:') >= 0;
if (isSecureRedirect) {
this._conn._changeConnectStatus(
Status.REDIRECT,
'Received see-other-uri, resetting connection'
);
this._conn.reset();
this._conn.service = see_uri;
this._connect();
}
} else {
this._conn._changeConnectStatus(Status.CONNFAIL, 'Received closing stream');
this._conn._doDisconnect();
}
} else {
this._replaceMessageHandler();
const string = this._streamWrap(message.data);
const elem = new DOMParser().parseFromString(string, 'text/xml').documentElement;
this._conn._connect_cb(elem, null, message.data);
}
}
/**
* Called by _onInitialMessage in order to replace itself with the general message handler.
* This method is overridden by WorkerWebsocket, which manages a
* websocket connection via a service worker and doesn't have direct access
* to the socket.
*/
_replaceMessageHandler() {
/** @param {MessageEvent} m */
this.socket.onmessage = (m) => this._onMessage(m);
}
/**
* _Private_ function called by Connection.disconnect
* Disconnects and sends a last stanza if one is given
* @param {Element|Builder} [pres] - This stanza will be sent before disconnecting.
*/
_disconnect(pres) {
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
if (pres) {
this._conn.send(pres);
}
const close = $build('close', { 'xmlns': NS.FRAMING });
this._conn.xmlOutput(close.tree());
const closeString = Builder.serialize(close);
this._conn.rawOutput(closeString);
try {
this.socket.send(closeString);
} catch (e) {
log.warn(`Couldn't send <close /> tag. "${e.message}"`);
}
}
setTimeout(() => this._conn._doDisconnect(), 0);
}
/**
* _Private_ function to disconnect.
* Just closes the Socket for WebSockets
*/
_doDisconnect() {
log.debug('WebSockets _doDisconnect was called');
this._closeSocket();
}
/**
* PrivateFunction _streamWrap
* _Private_ helper function to wrap a stanza in a <stream> tag.
* This is used so Strophe can process stanzas from WebSockets like BOSH
* @param {string} stanza
*/
// eslint-disable-next-line class-methods-use-this
_streamWrap(stanza) {
return '<wrapper>' + stanza + '</wrapper>';
}
/**
* _Private_ function to close the WebSocket.
*
* Closes the socket if it is still open and deletes it
*/
_closeSocket() {
if (this.socket) {
try {
this.socket.onclose = null;
this.socket.onerror = null;
this.socket.onmessage = null;
this.socket.close();
} catch (e) {
log.debug(e.message);
}
}
this.socket = null;
}
/**
* _Private_ function to check if the message queue is empty.
* @return {true} - True, because WebSocket messages are send immediately after queueing.
*/
// eslint-disable-next-line class-methods-use-this
_emptyQueue() {
return true;
}
/**
* _Private_ function to handle websockets closing.
* @param {CloseEvent} [e]
*/
_onClose(e) {
if (this._conn.connected && !this._conn.disconnecting) {
log.error('Websocket closed unexpectedly');
this._conn._doDisconnect();
} else if (e && e.code === 1006 && !this._conn.connected && this.socket) {
// in case the onError callback was not called (Safari 10 does not
// call onerror when the initial connection fails) we need to
// dispatch a CONNFAIL status update to be consistent with the
// behavior on other browsers.
log.error('Websocket closed unexcectedly');
this._conn._changeConnectStatus(
Status.CONNFAIL,
'The WebSocket connection could not be established or was disconnected.'
);
this._conn._doDisconnect();
} else {
log.debug('Websocket closed');
}
}
/**
* @callback connectionCallback
* @param {Connection} connection
*/
/**
* Called on stream start/restart when no stream:features
* has been received.
* @param {connectionCallback} callback
*/
_no_auth_received(callback) {
log.error('Server did not offer a supported authentication mechanism');
this._conn._changeConnectStatus(Status.CONNFAIL, ErrorCondition.NO_AUTH_MECH);
callback?.call(this._conn);
this._conn._doDisconnect();
}
/**
* _Private_ timeout handler for handling non-graceful disconnection.
*
* This does nothing for WebSockets
*/
_onDisconnectTimeout() {} // eslint-disable-line class-methods-use-this
/**
* _Private_ helper function that makes sure all pending requests are aborted.
*/
_abortAllRequests() {} // eslint-disable-line class-methods-use-this
/**
* _Private_ function to handle websockets errors.
* @param {Object} error - The websocket error.
*/
_onError(error) {
log.error('Websocket error ' + JSON.stringify(error));
this._conn._changeConnectStatus(
Status.CONNFAIL,
'The WebSocket connection could not be established or was disconnected.'
);
this._disconnect();
}
/**
* _Private_ function called by Connection._onIdle
* sends all queued stanzas
*/
_onIdle() {
const data = this._conn._data;
if (data.length > 0 && !this._conn.paused) {
for (let i = 0; i < data.length; i++) {
if (data[i] !== null) {
const stanza = data[i] === 'restart' ? this._buildStream().tree() : data[i];
if (stanza === 'restart') throw new Error('Wrong type for stanza'); // Shut up tsc
const rawStanza = Builder.serialize(stanza);
this._conn.xmlOutput(stanza);
this._conn.rawOutput(rawStanza);
this.socket.send(rawStanza);
}
}
this._conn._data = [];
}
}
/**
* _Private_ function to handle websockets messages.
*
* This function parses each of the messages as if they are full documents.
* [TODO : We may actually want to use a SAX Push parser].
*
* Since all XMPP traffic starts with
* <stream:stream version='1.0'
* xml:lang='en'
* xmlns='jabber:client'
* xmlns:stream='http://etherx.jabber.org/streams'
* id='3697395463'
* from='SERVER'>
*
* The first stanza will always fail to be parsed.
*
* Additionally, the seconds stanza will always be <stream:features> with
* the stream NS defined in the previous stanza, so we need to 'force'
* the inclusion of the NS in this stanza.
*
* @param {MessageEvent} message - The websocket message event
*/
_onMessage(message) {
let elem;
// check for closing stream
const close = '<close xmlns="urn:ietf:params:xml:ns:xmpp-framing" />';
if (message.data === close) {
this._conn.rawInput(close);
this._conn.xmlInput(message);
if (!this._conn.disconnecting) {
this._conn._doDisconnect();
}
return;
} else if (message.data.search('<open ') === 0) {
// This handles stream restarts
elem = new DOMParser().parseFromString(message.data, 'text/xml').documentElement;
if (!this._handleStreamStart(elem)) {
return;
}
} else {
const data = this._streamWrap(message.data);
elem = new DOMParser().parseFromString(data, 'text/xml').documentElement;
}
if (this._checkStreamError(elem, Status.ERROR)) {
return;
}
//handle unavailable presence stanza before disconnecting
if (
this._conn.disconnecting &&
elem.firstElementChild.nodeName === 'presence' &&
elem.firstElementChild.getAttribute('type') === 'unavailable'
) {
this._conn.xmlInput(elem);
this._conn.rawInput(Builder.serialize(elem));
// if we are already disconnecting we will ignore the unavailable stanza and
// wait for the </stream:stream> tag before we close the connection
return;
}
this._conn._dataRecv(elem, message.data);
}
/**
* _Private_ function to handle websockets connection setup.
* The opening stream tag is sent here.
* @private
*/
_onOpen() {
log.debug('Websocket open');
const start = this._buildStream();
this._conn.xmlOutput(start.tree());
const startString = Builder.serialize(start);
this._conn.rawOutput(startString);
this.socket.send(startString);
}
/**
* _Private_ part of the Connection.send function for WebSocket
* Just flushes the messages that are in the queue
*/
_send() {
this._conn.flush();
}
/**
* Send an xmpp:restart stanza.
*/
_sendRestart() {
clearTimeout(this._conn._idleTimeout);
this._conn._onIdle.bind(this._conn)();
}
}
export default Websocket;