strophe.js
Version:
Strophe.js is an XMPP library for JavaScript
427 lines (426 loc) • 16.5 kB
JavaScript
import { connect as netConnect } from 'node:net';
import { createHash } from 'node:crypto';
import Builder from './builder';
import { stx } from './stanza';
import log from './log';
import { ErrorCondition, NS, Status } from './constants';
import { xmlGenerator } from './utils';
import ComponentParser from './component-parser';
const DEFAULT_COMPONENT_PORT = 5347;
/**
* Helper class that handles XEP-0114 external component connections.
*
* Like {@link Websocket} it is used internally by {@link Connection} to
* encapsulate a session and is not meant to be used from user code. Select it
* with the `protocol: 'component'` connection option and a `tcp://host:port`
* service URL.
*/
class Component {
/**
* Create and initialize a Component object.
* @param connection - The Connection that will use this component transport.
*/
constructor(connection) {
this._conn = connection;
// Complete stanzas are handed to _dataRecv wrapped in a <wrapper/>, the
// same convention the WebSocket transport uses, so downstream handling
// is identical.
this.strip = 'wrapper';
this.socket = null;
this._streamId = null;
this._authenticated = false;
this._parser = new ComponentParser({
onStreamStart: (attrs) => this._onStreamStart(attrs),
onStanza: (stanza) => this._onStanza(stanza),
onStreamEnd: () => this._onStreamEnd(),
onError: (err) => this._onParseError(err),
});
}
/**
* Parse the component service URL into a host and port.
*
* Accepts `tcp://host:port` (the recommended plaintext form) and tolerates a
* bare `host:port`. The port defaults to 5347, the conventional component
* listener port.
*/
_serviceUrl() {
const service = this._conn.service;
const url = new URL(service.includes('://') ? service : `tcp://${service}`);
return {
host: url.hostname || 'localhost',
port: url.port ? parseInt(url.port, 10) : DEFAULT_COMPONENT_PORT,
};
}
/**
* Reset the transport. Called by {@link Connection#reset}.
*/
_reset() {
this._parser.reset();
this._streamId = null;
this._authenticated = false;
}
/**
* _Private_ function called by Connection.connect
*
* Opens a TCP socket to the component listener and wires up its callbacks.
*/
_connect() {
this._closeSocket();
this._reset();
const { host, port } = this._serviceUrl();
log.debug(`Component connecting to ${host}:${port}`);
const socket = netConnect({ host, port });
this.socket = socket;
socket.on('connect', () => this._onOpen());
socket.on('data', (chunk) => this._onData(chunk));
socket.on('error', (e) => this._onError(e));
socket.on('close', () => this._onClose());
}
/**
* _Private_ function called by Connection._connect_cb
*
* The component protocol has no <stream:features> to inspect, so there is
* nothing to check here. Kept for interface parity with the other
* transports.
*/
_connect_cb(_bodyWrap) { }
/**
* _Private_ function to handle a successful TCP connection.
* Sends the component stream header (XEP-0114 §2).
*/
_onOpen() {
var _a;
log.debug('Component TCP socket connected');
const header = this._streamHeader();
this._conn.xmlOutput(this._streamOpenElement());
this._conn.rawOutput(header);
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.write(header);
}
/**
* The opening `<stream:stream>` header sent to the server. `to` is the
* component's own domain, per XEP-0114.
*/
_streamHeader() {
return ("<?xml version='1.0'?>" +
`<stream:stream xmlns='${NS.COMPONENT}' ` +
`xmlns:stream='${NS.STREAM}' ` +
`to='${this._conn.domain}'>`);
}
/**
* A self-closed representation of the stream header, purely for the
* `xmlOutput` debug hook (the real header sent on the wire never closes).
*/
_streamOpenElement() {
return stx `<stream:stream xmlns="${NS.COMPONENT}" xmlns:stream="${NS.STREAM}" to="${this._conn.domain}"/>`.tree();
}
/**
* _Private_ function called when raw bytes arrive on the socket.
* Feeds them into the incremental stream parser.
* @param chunk - The bytes received from the socket.
*/
_onData(chunk) {
this._parser.write(chunk);
}
/**
* Handle the server's opening stream header. Captures the stream id and
* replies with the SHA-1 handshake (XEP-0114 §3).
* @param attrs - The attributes of the server's <stream:stream> header.
*/
_onStreamStart(attrs) {
const streamId = attrs.id;
if (!streamId) {
log.error('Component stream header is missing a stream id');
this._conn._changeConnectStatus(Status.CONNFAIL, 'Missing stream id in component stream header');
this._conn._doDisconnect();
return;
}
this._streamId = streamId;
this._sendHandshake(streamId);
}
/**
* Compute and send the XEP-0114 handshake digest,
* `hex( SHA1( STREAM_ID + SHARED_SECRET ) )` in lowercase.
* @param streamId - The stream id from the server's stream header.
*/
_sendHandshake(streamId) {
var _a;
const secret = typeof this._conn.pass === 'string' ? this._conn.pass : '';
const digest = createHash('sha1')
.update(streamId + secret, 'utf8')
.digest('hex');
const handshake = stx `<handshake>${digest}</handshake>`;
const raw = handshake.toString();
this._conn.xmlOutput(handshake.tree());
this._conn.rawOutput(raw);
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.write(raw);
}
/**
* Route a complete inbound stanza. Before the handshake completes this is
* the handshake result; afterwards it's ordinary traffic.
* @param stanza - A complete top-level stanza element.
*/
_onStanza(stanza) {
if (this._authenticated) {
this._onMessage(stanza);
}
else {
this._handleHandshakeResult(stanza);
}
}
/**
* Inspect the server's reply to our handshake. An empty `<handshake/>`
* means success and the component is CONNECTED; a `<stream:error/>` (e.g.
* `<not-authorized/>`) means the shared secret was wrong.
* @param stanza - The first stanza received after sending the handshake.
*/
_handleHandshakeResult(stanza) {
var _a;
if (stanza.nodeName === 'handshake') {
log.debug('Component handshake succeeded');
this._authenticated = true;
this._conn.connected = true;
this._conn.authenticated = true;
// The handshake reply arrives without us having flushed the send
// queue, so the idle loop may have stopped while we were still
// unconnected. Restart it so timed handlers and queued stanzas keep
// flowing even for a component that only receives.
clearTimeout(this._conn._idleTimeout);
this._conn._idleTimeout = setTimeout(() => this._conn._onIdle(), 100);
this._conn._changeConnectStatus(Status.CONNECTED, null);
}
else if (stanza.nodeName === 'stream:error' || stanza.nodeName === 'error') {
const condition = ((_a = stanza.firstElementChild) === null || _a === void 0 ? void 0 : _a.nodeName) || 'not-authorized';
log.error(`Component handshake failed: ${condition}`);
this._conn._changeConnectStatus(Status.AUTHFAIL, condition);
this._conn._doDisconnect(condition);
}
else {
log.error(`Unexpected stanza <${stanza.nodeName}> before component handshake completed`);
this._conn._changeConnectStatus(Status.AUTHFAIL, 'unexpected-stanza');
this._conn._doDisconnect();
}
}
/**
* _Private_ function to handle incoming stanzas once authenticated.
* Wraps the stanza and routes it through the normal handler machinery,
* exactly as {@link Websocket#_onMessage} does.
* @param stanza - A complete top-level stanza element.
*/
_onMessage(stanza) {
if (stanza.nodeName === 'stream:error') {
this._checkStreamError(stanza);
return;
}
const raw = this._serialize(stanza);
const wrapper = xmlGenerator().createElement('wrapper');
wrapper.appendChild(stanza);
this._conn._dataRecv(wrapper, raw);
}
/**
* Serialize an element to a string, reusing Strophe's own serializer so
* output matches the other transports.
*/
_serialize(elem) {
var _a;
return (_a = Builder.serialize(elem)) !== null && _a !== void 0 ? _a : '';
}
/**
* _Private_ checks a received stream error and tears the connection down.
* @param streamError - The <stream:error/> element.
*/
_checkStreamError(streamError) {
let condition = 'unknown';
let text = '';
for (let i = 0; i < streamError.childNodes.length; i++) {
const child = streamError.childNodes[i];
if (child.nodeType === child.ELEMENT_NODE) {
if (child.nodeName === 'text') {
text = child.textContent || '';
}
else {
condition = child.nodeName;
}
}
}
log.error(`Component stream error: ${condition}${text ? ' - ' + text : ''}`);
this._conn._changeConnectStatus(Status.CONNFAIL, condition);
this._conn._doDisconnect(condition);
}
/**
* Handle the closing `</stream:stream>` tag from the server.
*/
_onStreamEnd() {
log.debug('Component received </stream:stream>');
if (!this._conn.disconnecting) {
this._conn._doDisconnect();
}
}
/**
* Handle a fatal XML parse error on the inbound stream.
* @param error - The parse error.
*/
_onParseError(error) {
log.error(`Component stream parse error: ${error.message}`);
this._conn._changeConnectStatus(Status.CONNFAIL, ErrorCondition.BAD_FORMAT);
this._conn._doDisconnect(ErrorCondition.BAD_FORMAT);
}
/**
* _Private_ function called by Connection.disconnect
* Sends an optional last stanza, then the closing stream tag, then
* tears down the socket.
* @param pres - This stanza will be sent before disconnecting.
*/
_disconnect(pres) {
if (this.socket && !this.socket.destroyed) {
if (pres) {
this._conn.send(pres);
}
const closeString = '</stream:stream>';
this._conn.rawOutput(closeString);
try {
this.socket.write(closeString);
}
catch (e) {
log.warn(`Couldn't send </stream:stream> tag. "${e.message}"`);
}
}
setTimeout(() => this._conn._doDisconnect(), 0);
}
/**
* _Private_ function to disconnect. Just closes the socket.
*/
_doDisconnect() {
log.debug('Component _doDisconnect was called');
this._closeSocket();
}
/**
* _Private_ function to close the TCP socket and drop its listeners.
*/
_closeSocket() {
if (this.socket) {
try {
this.socket.removeAllListeners('connect');
this.socket.removeAllListeners('data');
this.socket.removeAllListeners('error');
this.socket.removeAllListeners('close');
this.socket.destroy();
}
catch (e) {
log.debug(e.message);
}
}
this.socket = null;
}
/**
* _Private_ function to check if the message queue is empty.
* @returns True, because stanzas are written to the socket immediately.
*/
_emptyQueue() {
return true;
}
/**
* _Private_ function to handle the socket closing.
*/
_onClose() {
if (this._conn.connected && !this._conn.disconnecting) {
log.error('Component socket closed unexpectedly');
this._conn._doDisconnect();
}
else if (!this._conn.connected && this.socket) {
log.error('Component socket closed before the stream was established');
this._conn._changeConnectStatus(Status.CONNFAIL, 'The TCP connection could not be established or was disconnected.');
this._conn._doDisconnect();
}
else {
log.debug('Component socket closed');
}
}
/**
* Called on stream start/restart when no authentication mechanism is
* offered. Not reachable on the component path (there is no SASL), but kept
* for interface parity.
* @param callback
*/
_no_auth_received(callback) {
log.error('Component received no authentication mechanism');
this._conn._changeConnectStatus(Status.CONNFAIL, ErrorCondition.NO_AUTH_MECH);
callback === null || callback === void 0 ? void 0 : callback.call(this._conn);
this._conn._doDisconnect();
}
/**
* _Private_ timeout handler for a non-graceful disconnection.
* Nothing to do for a plain TCP socket.
*/
_onDisconnectTimeout() { }
/**
* _Private_ helper that makes sure all pending requests are aborted.
* A component has no in-flight requests, so this is a no-op.
*/
_abortAllRequests() { }
/**
* _Private_ function to handle socket errors.
* @param error - The socket error.
*/
_onError(error) {
var _a;
log.error('Component socket error ' + ((_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : JSON.stringify(error)));
this._conn._changeConnectStatus(Status.CONNFAIL, 'The TCP connection could not be established or was disconnected.');
this._conn._doDisconnect();
}
/**
* _Private_ function called by Connection._onIdle
* Serializes queued stanzas and writes them to the socket, stamping a
* `from` attribute under the component's domain when one is absent (the
* server validates that a component stamps `from`, per XEP-0114).
*/
_onIdle() {
var _a;
const data = this._conn._data;
if (data.length > 0 && !this._conn.paused) {
for (let i = 0; i < data.length; i++) {
const stanza = data[i];
// Components have no stream-restart concept; ignore any 'restart'
// marker (it is only produced by the SASL/bind path).
if (stanza === null || stanza === 'restart') {
continue;
}
this._stampFrom(stanza);
const rawStanza = this._serialize(stanza);
this._conn.xmlOutput(stanza);
this._conn.rawOutput(rawStanza);
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.write(rawStanza);
}
this._conn._data = [];
}
}
/**
* Ensure an outgoing stanza carries a `from` attribute under the
* component's domain, which XEP-0114 requires the server to validate.
* An explicit `from` (e.g. a sub-JID of the component) is left untouched.
* @param stanza - The outgoing stanza element.
*/
_stampFrom(stanza) {
const name = stanza.nodeName;
if ((name === 'message' || name === 'presence' || name === 'iq') && !stanza.getAttribute('from')) {
stanza.setAttribute('from', (this._conn.jid || this._conn.domain));
}
}
/**
* _Private_ part of the Connection.send function for the component transport.
* Just flushes the messages that are in the queue.
*/
_send() {
this._conn.flush();
}
/**
* Send an xmpp:restart stanza.
*
* Components never restart their stream, so this only flushes any queued
* data. Included for interface parity with the other transports.
*/
_sendRestart() {
clearTimeout(this._conn._idleTimeout);
this._conn._onIdle();
}
}
export default Component;