From 0dffe2903c8bafec64a11bac1202d3574b9cd86f Mon Sep 17 00:00:00 2001 From: JC Brand Date: Tue, 29 May 2018 12:00:23 +0200 Subject: [PATCH] Add support for XEP-0198 Stream Management - New plugin `converse-smacks` - New config option `enable_smacks` - Rename session cache id from `converse.bosh-session` to `converse.session` Updates #316 --- CHANGES.md | 11 +- docs/source/configuration.rst | 19 +++ package-lock.json | 4 +- src/headless/converse-core.js | 3 +- src/headless/converse-disco.js | 63 ++++++---- src/headless/converse-smacks.js | 210 ++++++++++++++++++++++++++++++++ src/headless/headless.js | 1 + src/headless/package.json | 2 +- 8 files changed, 279 insertions(+), 34 deletions(-) create mode 100644 src/headless/converse-smacks.js diff --git a/CHANGES.md b/CHANGES.md index c06693905c..43de0d2f3d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,15 +15,15 @@ - Message deduplication bugfixes and improvements - Continuously retry (in 2s intervals) to fetch login credentials (via [credentials_url](https://conversejs.org/docs/html/configuration.html#credentials-url)) in case of failure - Replace `moment` with [DayJS](https://github.com/iamkun/dayjs). -- New API method [\_converse.api.disco.features.get](https://conversejs.org/docs/html/api/-_converse.api.disco.features.html#.get) -- New config setting [muc_show_join_leave_status](https://conversejs.org/docs/html/configuration.html#muc-show-join-leave-status) +- New config option [enable_smacks](https://conversejs.org/docs/html/configuration.html#enable-smacks). +- New config option [muc_show_join_leave_status](https://conversejs.org/docs/html/configuration.html#muc-show-join-leave-status) - New config option [singleton](https://conversejs.org/docs/html/configuration.html#singleton). By setting this option to `false` and `view_mode` to `'embedded'`, it's now possible to "embed" the full app and not just a single chat. To embed just a single chat, it's now necessary to explicitly set `singleton` to `true`. -- New event: `chatBoxBlurred`. - New event: [chatBoxBlurred](https://conversejs.org/docs/html/api/-_converse.html#event:chatBoxBlurred) - New event: [chatReconnected](https://conversejs.org/docs/html/api/-_converse.html#event:chatReconnected) +- #316: Add support for XEP-0198 Stream Management - #1296: `embedded` view mode shows `chatbox-navback` arrow in header - #1465: When highlighting a roster contact, they're incorrectly shown as online - #1532: Converse reloads on enter pressed in the filter box @@ -34,12 +34,12 @@ - #1576: Converse gets stuck with spinner when logging out with `auto_login` set to `true` - #1586: Not possible to kick someone with a space in their nickname -- **Breaking changes**: +### Breaking changes + - Rename `muc_disable_moderator_commands` to [muc_disable_slash_commands](https://conversejs.org/docs/html/configuration.html#muc-disable-slash-commands). - `_converse.api.archive.query` now returns a Promise instead of accepting a callback functions. - `_converse.api.disco.supports` now returns a Promise which resolves to a Boolean instead of an Array. - ### API changes - `_converse.chats.open` and `_converse.rooms.open` now take a `force` @@ -49,6 +49,7 @@ - `_converse.api.emit` has been removed in favor of [\_converse.api.trigger](https://conversejs.org/docs/html/api/-_converse.api.html#.trigger) - `_converse.updateSettings` has been removed in favor of [\_converse.api.settings.update](https://conversejs.org/docs/html/api/-_converse.api.settings.html#.update) - `_converse.api.roster.get` now returns a promise. +- New API method [\_converse.api.disco.features.get](https://conversejs.org/docs/html/api/-_converse.api.disco.features.html#.get) ## 4.2.0 (2019-04-04) diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index c2c4cee616..6d38ee9e08 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -635,6 +635,15 @@ The app servers are specified with the `push_app_servers`_ option. Registering a push app server against a MUC domain is not (yet) standardized and this feature should be considered experimental. +enable_smacks +------------- + +* Default: ``false`` + +Determines whether `XEP-0198 Stream Management `_ +support is turned on or not. + + expose_rid_and_sid ------------------ @@ -1398,6 +1407,16 @@ want to embed a chat into the page. Alternatively you could use it with `view_mode`_ set to ``overlayed`` to create a single helpdesk-type chat. + +smacks_max_unacked_stanzas +-------------------------- + +* Default: ``5`` + +This setting relates to `XEP-0198 `_ +and determines the number of stanzas to be sent before Converse will ask the +server for acknowledgement of those stanzas. + sounds_path ----------- diff --git a/package-lock.json b/package-lock.json index ba597af5f6..4eba93bf93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13702,8 +13702,8 @@ } }, "strophe.js": { - "version": "github:strophe/strophejs#44da5faca8baa61c691739d63af8b1dea1d2436c", - "from": "github:strophe/strophejs#44da5faca8baa61c691739d63af8b1dea1d2436c" + "version": "github:strophe/strophejs#f52f26e8cc23f738b7b39180a7ee4511ccd41526", + "from": "github:strophe/strophejs#f52f26e8cc23f738b7b39180a7ee4511ccd41526" }, "style-loader": { "version": "0.23.1", diff --git a/src/headless/converse-core.js b/src/headless/converse-core.js index 4812512911..eac99d527c 100644 --- a/src/headless/converse-core.js +++ b/src/headless/converse-core.js @@ -102,6 +102,7 @@ _converse.core_plugins = [ 'converse-pubsub', 'converse-roster', 'converse-rsm', + 'converse-smacks', 'converse-vcard' ]; @@ -439,7 +440,7 @@ _converse.initConnection = function () { async function initSession () { - const id = 'converse.bosh-session'; + const id = 'converse.session'; _converse.session = new Backbone.Model({id}); _converse.session.browserStorage = new BrowserStorage.session(id); try { diff --git a/src/headless/converse-disco.js b/src/headless/converse-disco.js index 246e0850c2..afb93e0f32 100644 --- a/src/headless/converse-disco.js +++ b/src/headless/converse-disco.js @@ -22,6 +22,7 @@ converse.plugins.add('converse-disco', { // Promises exposed by this plugin _converse.api.promises.add('discoInitialized'); + _converse.api.promises.add('streamFeaturesAdded'); /** @@ -260,32 +261,33 @@ converse.plugins.add('converse-disco', { } function initStreamFeatures () { - _converse.stream_features = new Backbone.Collection(); - _converse.stream_features.browserStorage = new BrowserStorage.session( - `converse.stream-features-${_converse.bare_jid}` - ); - _converse.stream_features.fetch({ - success (collection) { - if (collection.length === 0 && _converse.connection.features) { - _.forEach( - _converse.connection.features.childNodes, - (feature) => { - _converse.stream_features.create({ - 'name': feature.nodeName, - 'xmlns': feature.getAttribute('xmlns') + const bare_jid = Strophe.getBareJidFromJid(_converse.jid); + const id = `converse.stream-features-${bare_jid}`; + if (!_converse.stream_features || _converse.stream_features.browserStorage.id !== id) { + _converse.stream_features = new Backbone.Collection(); + _converse.stream_features.browserStorage = new BrowserStorage.session(id); + _converse.stream_features.fetch({ + success (collection) { + if (collection.length === 0 && _converse.connection.features) { + Array.from(_converse.connection.features.childNodes) + .forEach(feature => { + _converse.stream_features.create({ + 'name': feature.nodeName, + 'xmlns': feature.getAttribute('xmlns') + }); }); - }); + } + /** + * Triggered as soon as Converse has processed the stream features as advertised by + * the server. If you want to check whether a stream feature is supported before + * proceeding, then you'll first want to wait for this event. + * @event _converse#streamFeaturesAdded + * @example _converse.api.listen.on('streamFeaturesAdded', () => { ... }); + */ + _converse.api.trigger('streamFeaturesAdded'); } - } - }); - /** - * Triggered as soon as Converse has processed the stream features as advertised by - * the server. If you want to check whether a stream feature is supported before - * proceeding, then you'll first want to wait for this event. - * @event _converse#streamFeaturesAdded - * @example _converse.api.listen.on('streamFeaturesAdded', () => { ... }); - */ - _converse.api.trigger('streamFeaturesAdded'); + }); + } } async function initializeDisco () { @@ -313,7 +315,13 @@ converse.plugins.add('converse-disco', { _converse.api.trigger('discoInitialized'); } + // beforeResourceBinding may or may not fire, depending on whether + // 'explicitResourceBinding` was set to true or false when + // Strophe.Connection was instantiated, so we also listen for + // setUserJID + _converse.api.listen.on('beforeResourceBinding', initStreamFeatures); _converse.api.listen.on('setUserJID', initStreamFeatures); + _converse.api.listen.on('reconnected', initializeDisco); _converse.api.listen.on('connected', initializeDisco); @@ -326,6 +334,10 @@ converse.plugins.add('converse-disco', { _converse.disco_entities.reset(); _converse.disco_entities.browserStorage._clear(); } + if (_converse.stream_features) { + _converse.stream_features.reset(); + _converse.stream_features.browserStorage._clear(); + } }); const plugin = this; @@ -386,7 +398,8 @@ converse.plugins.add('converse-disco', { * @param {String} xmlns The XML namespace * @example _converse.api.disco.stream.getFeature('ver', 'urn:xmpp:features:rosterver') */ - 'getFeature': function (name, xmlns) { + 'getFeature': async function (name, xmlns) { + await _converse.api.waitUntil('streamFeaturesAdded'); if (_.isNil(name) || _.isNil(xmlns)) { throw new Error("name and xmlns need to be provided when calling disco.stream.getFeature"); } diff --git a/src/headless/converse-smacks.js b/src/headless/converse-smacks.js new file mode 100644 index 0000000000..35b312003a --- /dev/null +++ b/src/headless/converse-smacks.js @@ -0,0 +1,210 @@ +// Converse.js +// http://conversejs.org +// +// Copyright (c) The Converse.js developers +// Licensed under the Mozilla Public License (MPLv2) + +/* This is a Converse.js plugin which add support for XEP-0198: Stream Management */ + +import converse from "./converse-core"; + +const { Strophe, $build } = converse.env; +const u = converse.env.utils; + +Strophe.addNamespace('SM', 'urn:xmpp:sm:3'); + + +converse.plugins.add('converse-smacks', { + + initialize () { + const { _converse } = this; + + // Configuration values for this plugin + // ==================================== + // Refer to docs/source/configuration.rst for explanations of these + // configuration settings. + _converse.api.settings.update({ + 'enable_smacks': false, + 'smacks_max_unacked_stanzas': 5, + }); + + _converse.default_connection_options = Object.assign( + _converse.default_connection_options, + {'explicitResourceBinding': true} + ); + + function isStreamManagementSupported () { + return _converse.api.disco.stream.getFeature('sm', Strophe.NS.SM); + } + + function handleAck (el) { + if (!_converse.session.get('smacks_enabled')) { + return true; + } + const handled = parseInt(el.getAttribute('h'), 10); + const last_known_handled = _converse.session.get('num_stanzas_handled_by_server'); + const delta = handled - last_known_handled; + + if (delta < 0) { + const err_msg = `New reported stanza count lower than previous. `+ + `New: ${handled} - Previous: ${last_known_handled}` + _converse.log(err_msg, Strophe.LogLevel.ERROR); + return new Error(err_msg); + } + const unacked_stanzas = _converse.session.get('unacked_stanzas'); + if (delta > unacked_stanzas.length) { + const err_msg = + `Higher reported acknowledge count than unacknowledged stanzas. `+ + `Reported Acknowledged Count: ${delta} -`+ + `Unacknowledged Stanza Count: ${unacked_stanzas.length} -`+ + `New: ${handled} - Previous: ${last_known_handled}` + _converse.log(err_msg, Strophe.LogLevel.ERROR); + return new Error(err_msg); + } + _converse.session.save({ + 'num_stanzas_handled_by_server': handled, + 'num_stanzas_since_last_ack': 0, + 'unacked_stanzas': unacked_stanzas.slice(delta) + }); + return true; + } + + function sendAck() { + if (_converse.session.get('smacks_enabled')) { + converse.connection.send($build('a', { + 'xmlns': Strophe.NS.SM, + 'h': _converse.session.get('num_stanzas_handled') + })); + } + return true; + } + + function stanzaHandler (el) { + if (_converse.session.get('smacks_enabled')) { + if (Strophe.isTagEqual(el, 'iq') || Strophe.isTagEqual(el, 'presence') || Strophe.isTagEqual(el, 'message')) { + const h = _converse.session.get('num_stanzas_handled'); + _converse.session.save('num_stanzas_handled', h+1); + } + } + return true; + } + + function initSMACKS (el) { + const data = {'smacks_enabled': true}; + if (['1', 'true'].includes(el.getAttribute('resume'))) { + data['smacks_stream_id'] = el.getAttribute('id'); + } + _converse.session.save(data); + return true; + } + + function onFailedStanza (el) { + if (el.querySelector('item-not-found')) { + // Stream resumption must happen before resource binding but + // enabling a new stream must happen after resource binding. + // Since resumption failed, we simply continue. + // + // After resource binding, sendEnableStanza will be called + // based on the setUserJID event. + _converse.log('Could not resume previous SMACKS session, session id not found. '+ + 'A new session will be established.', Strophe.LogLevel.WARN); + } else { + _converse.log('Failed to enable stream management', Strophe.LogLevel.ERROR); + _converse.log(el.outerHTML, Strophe.LogLevel.ERROR); + _converse.session.save({'smacks_enabled': false}); + } + return true; + } + + function onResumedStanza (el, resolve) { + _converse.connection.do_bind = false; // No need to bind our resource anymore + _converse.connection.authenticated = true; + _converse.connection._changeConnectStatus(Strophe.Status.CONNECTED, null); + initSMACKS(el); + handleAck(el); + return true; + } + + function sendResumeStanza () { + const previous_id = _converse.session.get('smacks_stream_id'); + const h = _converse.session.get('num_stanzas_handled_by_server'); + const stanza = u.toStanza(``); + _converse.connection.send(stanza); + _converse.connection.flush(); + } + + async function sendEnableStanza () { + if (await isStreamManagementSupported()) { + const stanza = u.toStanza(``); + _converse.connection.send(stanza); + _converse.connection.flush(); + } + } + + async function enableStreamManagement () { + if (!_converse.enable_smacks) { + return; + } + if (!(await isStreamManagementSupported())) { + return; + } + _converse.session.save({ + 'num_stanzas_handled': 0, + 'num_stanzas_handled_by_server': 0, + 'num_stanzas_since_last_ack': 0, + 'smacks_enabled': undefined, + 'stanzas_sent': 0, + 'unacked_stanzas': [] + }); + _converse.connection.addHandler(stanzaHandler); + _converse.connection.addHandler(sendAck, Strophe.NS.SM, 'r'); + _converse.connection.addHandler(handleAck, Strophe.NS.SM, 'a'); + + if (_converse.connection._proto instanceof Strophe.Bosh && + _converse.connfeedback.get('connection_status') === Strophe.Status.ATTACHED) { + // No need to continue further when we have an existing BOSH session, + // since our existing session still exists server-side. + return; + } + const promise = u.getResolveablePromise(); + _converse.session.on('change:smacks_enabled', promise.resolve); + + _converse.connection._addSysHandler(initSMACKS, Strophe.NS.SM, 'enabled'); + _converse.connection._addSysHandler(onFailedStanza, Strophe.NS.SM, 'failed'); + _converse.connection._addSysHandler(onResumedStanza, Strophe.NS.SM, 'resumed'); + + if (_converse.session.get('smacks_stream_id')) { + sendResumeStanza(); + } + await promise; + } + + function onStanzaSent (stanza) { + if (!_converse.session.get('smacks_enabled')) { + return; + } + if (Strophe.isTagEqual(stanza, 'iq') || + Strophe.isTagEqual(stanza, 'presence') || + Strophe.isTagEqual(stanza, 'message')) { + + const stanza_string = Strophe.serialize(stanza); + _converse.session.save( + 'unacked_stanzas', + _converse.session.get('unacked_stanzas').concat([stanza_string]) + ); + if (_converse.smacks_max_unacked_stanzas > 0) { + const num = _converse.session.get('num_stanzas_since_last_ack') + 1; + if (num >= _converse.smacks_max_unacked_stanzas) { + // Request confirmation of sent stanzas + _converse.connection.send(u.toStanza(``)); + } + _converse.session.save({'num_stanzas_since_last_ack': num}); + } + } + } + + _converse.api.listen.on('beforeResourceBinding', enableStreamManagement); + _converse.api.listen.on('setUserJID', sendEnableStanza); + _converse.api.listen.on('send', onStanzaSent); + } +}); diff --git a/src/headless/headless.js b/src/headless/headless.js index c815b8545b..aeba5705b0 100644 --- a/src/headless/headless.js +++ b/src/headless/headless.js @@ -12,6 +12,7 @@ import "./converse-ping"; // XEP-0199 XMPP Ping import "./converse-pubsub"; // XEP-0199 XMPP Ping import "./converse-roster"; // Contacts Roster import "./converse-rsm"; // XEP-0059 Result Set management +import "./converse-smacks"; // XEP-0198 Stream Management import "./converse-vcard"; // XEP-0054 VCard-temp /* END: Removable components */ diff --git a/src/headless/package.json b/src/headless/package.json index 8c2fa2b3d7..d1be720768 100644 --- a/src/headless/package.json +++ b/src/headless/package.json @@ -29,7 +29,7 @@ "jed": "1.1.1", "lodash": "^4.17.11", "pluggable.js": "2.0.1", - "strophe.js": "strophe/strophejs#44da5faca8baa61c691739d63af8b1dea1d2436c", + "strophe.js": "strophe/strophejs#f52f26e8cc23f738b7b39180a7ee4511ccd41526", "twemoji": "^11.0.1", "urijs": "^1.19.1" }