@fnlb-project/stanza
Version:
Modern XMPP in the browser, with a JSON API
463 lines (462 loc) • 17.4 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
const async_1 = require("async");
const events_1 = require("events");
const StreamManagement_1 = tslib_1.__importDefault(require("./helpers/StreamManagement"));
const JID = tslib_1.__importStar(require("./JID"));
const JXT = tslib_1.__importStar(require("./jxt"));
const SASL = tslib_1.__importStar(require("./lib/sasl"));
const plugins_1 = require("./plugins");
const protocol_1 = tslib_1.__importDefault(require("./protocol"));
const websocket_1 = tslib_1.__importDefault(require("./transports/websocket"));
const Utils_1 = require("./Utils");
const NetworkDiscovery_1 = tslib_1.__importDefault(require("./helpers/NetworkDiscovery"));
class Client extends events_1.EventEmitter {
constructor(opts = {}) {
super();
this.reconnectAttempts = 0;
this.setMaxListeners(100);
// Some EventEmitter shims don't include off()
this.off = this.removeListener;
this.updateConfig(opts);
this.jid = '';
this.sasl = new SASL.Factory();
this.sasl.register('EXTERNAL', SASL.EXTERNAL, 1000);
this.sasl.register('SCRAM-SHA-256-PLUS', SASL.SCRAM, 350);
this.sasl.register('SCRAM-SHA-256', SASL.SCRAM, 300);
this.sasl.register('SCRAM-SHA-1-PLUS', SASL.SCRAM, 250);
this.sasl.register('SCRAM-SHA-1', SASL.SCRAM, 200);
this.sasl.register('DIGEST-MD5', SASL.DIGEST, 100);
this.sasl.register('OAUTHBEARER', SASL.OAUTH, 100);
this.sasl.register('X-OAUTH2', SASL.PLAIN, 50);
this.sasl.register('PLAIN', SASL.PLAIN, 1);
this.sasl.register('ANONYMOUS', SASL.ANONYMOUS, 0);
this.stanzas = new JXT.Registry();
this.stanzas.define(protocol_1.default);
this.resolver = new NetworkDiscovery_1.default();
this.use(plugins_1.core);
this.sm = new StreamManagement_1.default();
if (this.config.allowResumption !== undefined) {
this.sm.allowResume = this.config.allowResumption;
}
this.sm.on('prebound', jid => {
this.jid = jid;
this.emit('session:bound', jid);
});
this.on('session:bound', jid => this.sm.bind(jid));
this.sm.on('send', sm => this.send('sm', sm));
this.sm.on('acked', acked => this.emit('stanza:acked', acked));
this.sm.on('failed', failed => this.emit('stanza:failed', failed));
this.sm.on('hibernated', data => this.emit('stanza:hibernated', data));
// We disable outgoing processing while stanza resends are queued up
// to prevent any interleaving.
this.sm.on('begin-resend', () => this.outgoingDataQueue.pause());
this.sm.on('resend', ({ kind, stanza }) => this.send(kind, stanza, true));
this.sm.on('end-resend', () => this.outgoingDataQueue.resume());
// Create message:* flavors of stanza:* SM events
for (const type of ['acked', 'hibernated', 'failed']) {
this.on(`stanza:${type}`, (data) => {
if (data.kind === 'message') {
this.emit(`message:${type}`, data.stanza);
}
});
}
this.transports = {
websocket: websocket_1.default
};
this.incomingDataQueue = (0, async_1.priorityQueue)(async (task, done) => {
const { kind, stanza } = task;
this.emit(kind, stanza);
if (stanza.id) {
this.emit((kind + ':id:' + stanza.id), stanza);
}
if (kind === 'message' || kind === 'presence' || kind === 'iq') {
this.emit('stanza', stanza);
await this.sm.handle();
}
else if (kind === 'sm') {
if (stanza.type === 'ack') {
await this.sm.process(stanza);
this.emit('stream:management:ack', stanza);
}
if (stanza.type === 'request') {
this.sm.ack();
}
}
if (done) {
done();
}
}, 1);
const handleFailedSend = (kind, stanza) => {
if (['message', 'presence', 'iq'].includes(kind)) {
if (!this.sm.started || !this.sm.resumable) {
this.emit('stanza:failed', {
kind,
stanza
});
}
else if (this.sm.resumable && !this.transport) {
this.emit('stanza:hibernated', {
kind,
stanza
});
}
}
};
this.outgoingDataQueue = (0, async_1.priorityQueue)(async (task, done) => {
var _a;
const { kind, stanza, replay } = task;
const ackRequest = replay || (await this.sm.track(kind, stanza));
if (kind === 'message') {
if (replay) {
this.emit('message:retry', stanza);
}
else {
this.emit('message:sent', stanza, false);
}
}
if (this.transport) {
try {
await this.transport.send(kind, stanza);
if (ackRequest) {
(_a = this.transport) === null || _a === void 0 ? void 0 : _a.send('sm', { type: 'request' });
}
}
catch (err) {
console.error(err);
handleFailedSend(kind, stanza);
}
}
else {
handleFailedSend(kind, stanza);
}
if (done) {
done();
}
}, 1);
this.on('stream:data', (json, kind) => {
this.incomingDataQueue.push({
kind,
stanza: json
}, 0);
});
this.on('--transport-disconnected', async () => {
const drains = [];
if (!this.incomingDataQueue.idle()) {
drains.push(this.incomingDataQueue.drain());
}
if (!this.outgoingDataQueue.idle()) {
drains.push(this.outgoingDataQueue.drain());
}
await Promise.all(drains);
await this.sm.hibernate();
if (this.transport) {
delete this.transport;
}
this.emit('--reset-stream-features');
if (!this.sessionTerminating && this.config.autoReconnect) {
this.reconnectAttempts += 1;
clearTimeout(this.reconnectTimer);
this.reconnectTimer = setTimeout(() => {
this.connect();
}, 1000 * Math.min(Math.pow(2, this.reconnectAttempts) + Math.random(), this.config.maxReconnectBackoff || 32));
}
this.emit('disconnected');
});
this.on('iq', (iq) => {
const iqType = iq.type;
const payloadType = iq.payloadType;
const iqEvent = 'iq:' + iqType + ':' + payloadType;
if (iqType === 'get' || iqType === 'set') {
if (payloadType === 'invalid-payload-count') {
return this.sendIQError(iq, {
error: {
condition: 'bad-request',
type: 'modify'
}
});
}
if (payloadType === 'unknown-payload' || this.listenerCount(iqEvent) === 0) {
return this.sendIQError(iq, {
error: {
condition: 'service-unavailable',
type: 'cancel'
}
});
}
this.emit(iqEvent, iq);
}
});
this.on('message', msg => {
const isChat = (msg.alternateLanguageBodies && msg.alternateLanguageBodies.length) ||
(msg.links && msg.links.length);
const isMarker = msg.marker && msg.marker.type !== 'markable';
if (isChat && !isMarker) {
if (msg.type === 'chat' || msg.type === 'normal') {
this.emit('chat', msg);
}
else if (msg.type === 'groupchat') {
this.emit('groupchat', msg);
}
}
if (msg.type === 'error') {
this.emit('message:error', msg);
}
});
this.on('presence', (pres) => {
let presType = pres.type || 'available';
if (presType === 'error') {
presType = 'presence:error';
}
this.emit(presType, pres);
});
this.on('session:started', () => {
this.sessionStarting = false;
this.reconnectAttempts = 0;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
});
}
updateConfig(opts = {}) {
var _a;
const currConfig = this.config || {};
this.config = {
allowResumption: true,
jid: '',
transports: {
websocket: true
},
useStreamManagement: true,
transportPreferenceOrder: ['websocket'],
...currConfig,
...opts
};
if (!this.config.server) {
this.config.server = JID.getDomain(this.config.jid);
}
if (this.config.password) {
this.config.credentials = this.config.credentials || {};
this.config.credentials.password = this.config.password;
delete this.config.password;
}
if (!this.config.transportPreferenceOrder) {
this.config.transportPreferenceOrder = Object.keys((_a = this.config.transports) !== null && _a !== void 0 ? _a : {});
}
}
get stream() {
return this.transport ? this.transport.stream : undefined;
}
emit(name, ...args) {
// Continue supporting the most common and useful wildcard events
const res = super.emit(name, ...args);
if (name === 'raw') {
super.emit(`raw:${args[0]}`, args[1]);
super.emit('raw:*', `raw:${args[0]}`, args[1]);
super.emit('*', `raw:${args[0]}`, args[1]);
}
else {
super.emit('*', name, ...args);
}
return res;
}
use(pluginInit) {
if (typeof pluginInit !== 'function') {
return;
}
pluginInit(this, this.stanzas, this.config);
}
nextId() {
return (0, Utils_1.uuid)();
}
async getCredentials() {
return this._getConfiguredCredentials();
}
async connect() {
var _a, _b, _c;
this.sessionTerminating = false;
this.sessionStarting = true;
this.emit('--reset-stream-features');
if (this.transport) {
this.transport.disconnect(false);
}
const transportPref = (_a = this.config.transportPreferenceOrder) !== null && _a !== void 0 ? _a : [];
let endpoints;
for (const name of transportPref) {
const settings = this.config.transports[name];
if (!settings || !this.transports[name]) {
continue;
}
let config = {
acceptLanguages: this.config.acceptLanguages || [(_b = this.config.lang) !== null && _b !== void 0 ? _b : 'en'],
jid: this.config.jid,
lang: (_c = this.config.lang) !== null && _c !== void 0 ? _c : 'en',
server: this.config.server
};
const transport = new this.transports[name](this, this.sm, this.stanzas);
if (typeof settings === 'string') {
config.url = settings;
}
else if (settings == true) {
if (transport.discoverBindings) {
const discovered = await transport.discoverBindings(this.config.server);
if (!discovered) {
continue;
}
config = {
...config,
...discovered
};
}
else {
if (!endpoints) {
try {
endpoints = await this.discoverBindings(this.config.server);
}
catch (err) {
console.error(err);
continue;
}
}
endpoints[name] = (endpoints[name] || []).filter(url => url.startsWith('wss:') || url.startsWith('https:'));
if (!endpoints[name] || !endpoints[name].length) {
continue;
}
config.url = endpoints[name][0];
}
}
this.transport = transport;
this.transport.connect(config);
return;
}
console.error('No endpoints found for the requested transports.');
this.emit('--transport-disconnected');
}
async disconnect() {
this.sessionTerminating = true;
clearTimeout(this.reconnectTimer);
this.outgoingDataQueue.pause();
if (this.sessionStarted && !this.sm.started) {
// Only emit session:end if we had a session, and we aren't using
// stream management to keep the session alive.
this.emit('session:end');
}
this.emit('--reset-stream-features');
this.sessionStarted = false;
if (this.transport) {
this.transport.disconnect();
}
else {
this.emit('--transport-disconnected');
}
this.outgoingDataQueue.resume();
if (!this.outgoingDataQueue.idle()) {
await this.outgoingDataQueue.drain();
}
await this.sm.shutdown();
}
async send(kind, stanza, replay = false) {
return new Promise((resolve, reject) => {
this.outgoingDataQueue.push({ kind, stanza, replay }, replay ? 0 : 1, err => err ? reject(err) : resolve());
});
}
sendMessage(data) {
const id = data.id || this.nextId();
const msg = {
id,
originId: id,
...data
};
this.send('message', msg);
return msg.id;
}
sendPresence(data = {}) {
const pres = {
id: this.nextId(),
...data
};
this.send('presence', pres);
return pres.id;
}
sendIQ(data) {
const iq = {
id: this.nextId(),
...data
};
const allowed = JID.allowedResponders(this.jid, data.to);
const respEvent = 'iq:id:' + iq.id;
const request = new Promise((resolve, reject) => {
const handler = (res) => {
// Only process result from the correct responder
if (!allowed.has(res.from)) {
return;
}
// Only process result or error responses, if the responder
// happened to send us a request using the same ID value at
// the same time.
if (res.type !== 'result' && res.type !== 'error') {
return;
}
this.off(respEvent, handler);
if (res.type === 'result') {
resolve(res);
}
else {
reject(res);
}
};
this.on(respEvent, handler);
});
this.send('iq', iq);
const timeout = this.config.timeout || 15;
return (0, Utils_1.timeoutPromise)(request, timeout * 1000, () => ({
...iq,
to: undefined,
from: undefined,
error: {
condition: 'timeout',
text: `Request timed out after ${timeout} seconds.`
},
id: iq.id,
type: 'error'
}));
}
sendIQResult(original, reply) {
this.send('iq', {
...reply,
id: original.id,
to: original.from,
type: 'result'
});
}
sendIQError(original, error) {
this.send('iq', {
...error,
id: original.id,
to: original.from,
type: 'error'
});
}
sendStreamError(error) {
this.emit('stream:error', error);
this.send('error', error);
this.disconnect();
}
_getConfiguredCredentials() {
const creds = this.config.credentials || {};
const requestedJID = JID.parse(this.config.jid || '');
const username = creds.username || requestedJID.local;
const server = creds.host || requestedJID.domain;
return {
host: server,
password: this.config.password,
realm: server,
serviceName: server,
serviceType: 'xmpp',
username,
...creds
};
}
}
exports.default = Client;