Jelajahi Sumber

Add experimental support for running the XMPP conneciton inside a shared worker

Still lacks inter-tab communication to update state across tabs, i.e.
when sending a 1-on-1 message in one tab, it doesn't appear in another,
because that information is not available via the websocket connection.

- Create a new `Connection` class that extends Strophe.Connection and
    move related code from `converse-core.js` into this class.
- Store the session in localStorage when using a worker
- Move XEP-0156 code to connection.js
    This allows us to initialize the connection without needing to know the
    domain.
JC Brand 5 tahun lalu
induk
melakukan
16ca8044f8

+ 24 - 1
docs/source/configuration.rst

@@ -578,6 +578,9 @@ For documentation on the configuration options that ``Strophe.Connection``
 accepts, refer to the
 `Strophe.Connection documentation <http://strophe.im/strophejs/doc/1.2.8/files/strophe-js.html#Strophe.Connection.Strophe.Connection>`_.
 
+Restricting the supported authentication mechanisms:
+****************************************************
+
 As an example, suppose you want to restrict the supported SASL authentication
 mechanisms, then you'd pass in the ``mechanisms`` as a ``connection_options``
 ``key:value`` pair:
@@ -589,9 +592,29 @@ mechanisms, then you'd pass in the ``mechanisms`` as a ``connection_options``
                 'mechanisms': [
                     converse.env.Strophe.SASLMD5,
                 ]
-            },
+            }
+        });
+
+Running the XMPP Connection inside a shared worker
+**************************************************
+
+Newer versions of Strophe.js, support the ability to run the XMPP Connection
+inside a `shared worker <https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker>`_ that's shared
+between open tabs in the browser in which Converse is running (and which have the same domain).
+
+*Note:* This feature is experimental and there currently is no way to
+synchronize actions between tabs. For example, sent 1-on-1 messages aren't
+reflected by the server, so you if you send such a message in one tab, it won't
+appear in another.
+
+
+.. code-block:: javascript
+
+        converse.initialize({
+            connection_options: { 'worker': true }
         });
 
+
 .. _`credentials_url`:
 
 credentials_url

+ 48 - 71
package-lock.json

@@ -3283,9 +3283,12 @@
 					}
 				},
 				"strophe.js": {
-					"version": "1.3.4",
-					"resolved": "https://registry.npmjs.org/strophe.js/-/strophe.js-1.3.4.tgz",
-					"integrity": "sha512-jSLDG8jolhAwGOSgiJ7DTMSYK3wVoEJHKtpVRyEacQZ6CWA6z2WRPJpcFMjsIweq5aP9/XIvKUQqHBu/ZhvESA=="
+					"version": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f",
+					"from": "strophe.js@github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f",
+					"requires": {
+						"abab": "^2.0.3",
+						"xmldom": "^0.1.27"
+					}
 				},
 				"twemoji": {
 					"version": "12.1.5",
@@ -4901,30 +4904,27 @@
 			}
 		},
 		"@octokit/endpoint": {
-			"version": "6.0.3",
-			"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.3.tgz",
-			"integrity": "sha512-Y900+r0gIz+cWp6ytnkibbD95ucEzDSKzlEnaWS52hbCDNcCJYO5mRmWW7HRAnDc7am+N/5Lnd8MppSaTYx1Yg==",
+			"version": "6.0.5",
+			"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.5.tgz",
+			"integrity": "sha512-70K5u6zd45ItOny6aHQAsea8HHQjlQq85yqOMe+Aj8dkhN2qSJ9T+Q3YjUjEYfPRBcuUWNgMn62DQnP/4LAIiQ==",
 			"dev": true,
 			"requires": {
 				"@octokit/types": "^5.0.0",
-				"is-plain-object": "^3.0.0",
-				"universal-user-agent": "^5.0.0"
+				"is-plain-object": "^4.0.0",
+				"universal-user-agent": "^6.0.0"
 			},
 			"dependencies": {
 				"is-plain-object": {
-					"version": "3.0.1",
-					"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz",
-					"integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==",
+					"version": "4.1.1",
+					"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-4.1.1.tgz",
+					"integrity": "sha512-5Aw8LLVsDlZsETVMhoMXzqsXwQqr/0vlnBYzIXJbYo2F4yYlhLHs+Ez7Bod7IIQKWkJbJfxrWD7pA1Dw1TKrwA==",
 					"dev": true
 				},
 				"universal-user-agent": {
-					"version": "5.0.0",
-					"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-5.0.0.tgz",
-					"integrity": "sha512-B5TPtzZleXyPrUMKCpEHFmVhMN6EhmJYjG5PQna9s7mXeSqGTLap4OpqLl5FCEFUI3UBmllkETwKf/db66Y54Q==",
-					"dev": true,
-					"requires": {
-						"os-name": "^3.1.0"
-					}
+					"version": "6.0.0",
+					"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz",
+					"integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==",
+					"dev": true
 				}
 			}
 		},
@@ -4982,19 +4982,19 @@
 			}
 		},
 		"@octokit/request": {
-			"version": "5.4.5",
-			"resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.4.5.tgz",
-			"integrity": "sha512-atAs5GAGbZedvJXXdjtKljin+e2SltEs48B3naJjqWupYl2IUBbB/CJisyjbNHcKpHzb3E+OYEZ46G8eakXgQg==",
+			"version": "5.4.7",
+			"resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.4.7.tgz",
+			"integrity": "sha512-FN22xUDP0i0uF38YMbOfx6TotpcENP5W8yJM1e/LieGXn6IoRxDMnBf7tx5RKSW4xuUZ/1P04NFZy5iY3Rax1A==",
 			"dev": true,
 			"requires": {
 				"@octokit/endpoint": "^6.0.1",
 				"@octokit/request-error": "^2.0.0",
 				"@octokit/types": "^5.0.0",
 				"deprecation": "^2.0.0",
-				"is-plain-object": "^3.0.0",
+				"is-plain-object": "^4.0.0",
 				"node-fetch": "^2.3.0",
 				"once": "^1.4.0",
-				"universal-user-agent": "^5.0.0"
+				"universal-user-agent": "^6.0.0"
 			},
 			"dependencies": {
 				"@octokit/request-error": {
@@ -5009,19 +5009,16 @@
 					}
 				},
 				"is-plain-object": {
-					"version": "3.0.1",
-					"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz",
-					"integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==",
+					"version": "4.1.1",
+					"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-4.1.1.tgz",
+					"integrity": "sha512-5Aw8LLVsDlZsETVMhoMXzqsXwQqr/0vlnBYzIXJbYo2F4yYlhLHs+Ez7Bod7IIQKWkJbJfxrWD7pA1Dw1TKrwA==",
 					"dev": true
 				},
 				"universal-user-agent": {
-					"version": "5.0.0",
-					"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-5.0.0.tgz",
-					"integrity": "sha512-B5TPtzZleXyPrUMKCpEHFmVhMN6EhmJYjG5PQna9s7mXeSqGTLap4OpqLl5FCEFUI3UBmllkETwKf/db66Y54Q==",
-					"dev": true,
-					"requires": {
-						"os-name": "^3.1.0"
-					}
+					"version": "6.0.0",
+					"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz",
+					"integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==",
+					"dev": true
 				}
 			}
 		},
@@ -5072,9 +5069,9 @@
 			}
 		},
 		"@octokit/types": {
-			"version": "5.0.1",
-			"resolved": "https://registry.npmjs.org/@octokit/types/-/types-5.0.1.tgz",
-			"integrity": "sha512-GorvORVwp244fGKEt3cgt/P+M0MGy4xEDbckw+K5ojEezxyMDgCaYPKVct+/eWQfZXOT7uq0xRpmrl/+hliabA==",
+			"version": "5.1.2",
+			"resolved": "https://registry.npmjs.org/@octokit/types/-/types-5.1.2.tgz",
+			"integrity": "sha512-+zuMnja97vuZmWa+HdUY+0KB9MLwcEHueSSyKu0G/HqZaFYCVdLpBkavb0xyDlH7eoBdvAvSX/+Y8+4FOEZkrQ==",
 			"dev": true,
 			"requires": {
 				"@types/node": ">= 8"
@@ -7558,19 +7555,6 @@
 				"through2": "^3.0.0"
 			},
 			"dependencies": {
-				"handlebars": {
-					"version": "4.7.6",
-					"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.6.tgz",
-					"integrity": "sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA==",
-					"dev": true,
-					"requires": {
-						"minimist": "^1.2.5",
-						"neo-async": "^2.6.0",
-						"source-map": "^0.6.1",
-						"uglify-js": "^3.1.4",
-						"wordwrap": "^1.0.0"
-					}
-				},
 				"inherits": {
 					"version": "2.0.4",
 					"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -7583,12 +7567,6 @@
 					"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
 					"dev": true
 				},
-				"source-map": {
-					"version": "0.6.1",
-					"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
-					"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
-					"dev": true
-				},
 				"through2": {
 					"version": "3.0.2",
 					"resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz",
@@ -9197,9 +9175,9 @@
 			"dev": true
 		},
 		"envinfo": {
-			"version": "7.5.1",
-			"resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.5.1.tgz",
-			"integrity": "sha512-hQBkDf2iO4Nv0CNHpCuSBeaSrveU6nThVxFGTrq/eDlV716UQk09zChaJae4mZRsos1x4YLY2TaH3LHUae3ZmQ==",
+			"version": "7.7.2",
+			"resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.7.2.tgz",
+			"integrity": "sha512-k3Eh5bKuQnZjm49/L7H4cHzs2FlL5QjbTB3JrPxoTI8aJG7hVMe4uKyJxSYH4ahseby2waUwk5OaKX/nAsaYgg==",
 			"dev": true
 		},
 		"err-code": {
@@ -13757,9 +13735,9 @@
 			}
 		},
 		"localforage": {
-			"version": "1.7.4",
-			"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.7.4.tgz",
-			"integrity": "sha512-3EmVZatmNVeCo/t6Te7P06h2alGwbq8wXlSkcSXMvDE2/edPmsVqTPlzGnZaqwZZDBs6v+kxWpqjVsqsNJT8jA==",
+			"version": "1.8.1",
+			"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.8.1.tgz",
+			"integrity": "sha512-azSSJJfc7h4bVpi0PGi+SmLQKJl2/8NErI+LhJsrORNikMZnhaQ7rv9fHj+ofwgSHrKRlsDCL/639a6nECIKuQ==",
 			"requires": {
 				"lie": "3.1.1"
 			}
@@ -13965,9 +13943,9 @@
 			}
 		},
 		"macos-release": {
-			"version": "2.4.0",
-			"resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.4.0.tgz",
-			"integrity": "sha512-ko6deozZYiAkqa/0gmcsz+p4jSy3gY7/ZsCEokPaYd8k+6/aXGkiTgr61+Owup7Sf+xjqW8u2ElhoM9SEcEfuA==",
+			"version": "2.4.1",
+			"resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.4.1.tgz",
+			"integrity": "sha512-H/QHeBIN1fIGJX517pvK8IEK53yQOW7YcEI55oYtgjDdoCQQz7eJS94qt5kNrscReEyuD/JcdFCm2XBEcGOITg==",
 			"dev": true
 		},
 		"make-dir": {
@@ -14195,9 +14173,9 @@
 					}
 				},
 				"parse-json": {
-					"version": "5.0.0",
-					"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.0.0.tgz",
-					"integrity": "sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw==",
+					"version": "5.0.1",
+					"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.0.1.tgz",
+					"integrity": "sha512-ztoZ4/DYeXQq4E21v169sC8qWINGpcosGv9XhTDvg9/hWvx/zrFkc9BiWxR58OJLHGk28j5BL0SDLeV2WmFZlQ==",
 					"dev": true,
 					"requires": {
 						"@babel/code-frame": "^7.0.0",
@@ -22874,11 +22852,10 @@
 			}
 		},
 		"strophe.js": {
-			"version": "1.3.6",
-			"resolved": "https://registry.npmjs.org/strophe.js/-/strophe.js-1.3.6.tgz",
-			"integrity": "sha512-kTFdf6ziHqlp2PCr7Z7D/lhO+Hd0FIhzwXXlAIQNOqCWwnnTEor9folIUCVoXgZRMYPQ9BTXI2wBv88RG8mgAA==",
+			"version": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f",
+			"from": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f",
 			"requires": {
-				"abab": "^2.0.0",
+				"abab": "^2.0.3",
 				"ws": "^7.0.0",
 				"xmldom": "^0.1.27"
 			},

+ 1 - 1
spec/bookmarks.js

@@ -1,4 +1,4 @@
-/* global mock */
+/* global mock, converse */
 
 describe("A chat room", function () {
 

+ 0 - 68
spec/mock.js

@@ -599,74 +599,6 @@ window.addEventListener('converse-loaded', () => {
         'preventDefault': function () {}
     };
 
-    const OriginalConnection = Strophe.Connection;
-
-    function MockConnection (service, options) {
-        OriginalConnection.call(this, service, options);
-
-        Strophe.Bosh.prototype._processRequest = function () {}; // Don't attempt to send out stanzas
-        const sendIQ = this.sendIQ;
-
-        this.IQ_stanzas = [];
-        this.IQ_ids = [];
-        this.sendIQ = function (iq, callback, errback) {
-            if (!_.isElement(iq)) {
-                iq = iq.nodeTree;
-            }
-            this.IQ_stanzas.push(iq);
-            const id = sendIQ.bind(this)(iq, callback, errback);
-            this.IQ_ids.push(id);
-            return id;
-        }
-
-        const send = this.send;
-        this.sent_stanzas = [];
-        this.send = function (stanza) {
-            if (_.isElement(stanza)) {
-                this.sent_stanzas.push(stanza);
-            } else {
-                this.sent_stanzas.push(stanza.nodeTree);
-            }
-            return send.apply(this, arguments);
-        }
-
-        this.features = Strophe.xmlHtmlNode(
-            '<stream:features xmlns:stream="http://etherx.jabber.org/streams" xmlns="jabber:client">'+
-                '<ver xmlns="urn:xmpp:features:rosterver"/>'+
-                '<csi xmlns="urn:xmpp:csi:0"/>'+
-                '<this xmlns="http://jabber.org/protocol/caps" ver="UwBpfJpEt3IoLYfWma/o/p3FFRo=" hash="sha-1" node="http://prosody.im"/>'+
-                '<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">'+
-                    '<required/>'+
-                '</bind>'+
-                `<sm xmlns='urn:xmpp:sm:3'/>`+
-                '<session xmlns="urn:ietf:params:xml:ns:xmpp-session">'+
-                    '<optional/>'+
-                '</session>'+
-            '</stream:features>').firstChild;
-
-        this._proto._connect = () => {
-            this.connected = true;
-            this.mock = true;
-            this.jid = 'romeo@montague.lit/orchard';
-            this._changeConnectStatus(Strophe.Status.BINDREQUIRED);
-        };
-
-        this.bind = () => {
-            this.authenticated = true;
-            this.authenticated = true;
-            if (!_converse.no_connection_on_bind) {
-                this._changeConnectStatus(Strophe.Status.CONNECTED);
-            }
-        };
-
-        this._proto._disconnect = () => this._onDisconnectTimeout();
-        this._proto._onDisconnectTimeout = _.noop;
-    }
-
-    MockConnection.prototype = Object.create(OriginalConnection.prototype);
-    Strophe.Connection = MockConnection;
-
-
     function clearIndexedDB () {
         const promise = u.getResolveablePromise();
         const db_request = window.indexedDB.open("converse-test-persistent");

+ 6 - 6
spec/register.js

@@ -1,4 +1,4 @@
-/*global mock */
+/*global mock, converse */
 
 const Strophe = converse.env.Strophe;
 const $iq = converse.env.$iq;
@@ -56,7 +56,6 @@ describe("The Registration Panel", function () {
               allow_registration: true },
             async function (done, _converse) {
 
-        spyOn(Strophe.Connection.prototype, 'connect');
 
         await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'));
         const toggle = document.querySelector(".toggle-controlbox");
@@ -66,6 +65,7 @@ describe("The Registration Panel", function () {
         await u.waitUntil(() => u.isVisible(cbview.el));
         const registerview = cbview.registerpanel;
         spyOn(registerview, 'onProviderChosen').and.callThrough();
+        spyOn(registerview, 'fetchRegistrationForm').and.callThrough();
         registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
 
         // Open the register panel
@@ -85,7 +85,8 @@ describe("The Registration Panel", function () {
         form.querySelector('input[name=domain]').value = 'conversejs.org';
         submit_button.click();
         expect(registerview.onProviderChosen).toHaveBeenCalled();
-        await u.waitUntil(() => _converse.connection.connect.calls.count());
+        expect(registerview.fetchRegistrationForm).toHaveBeenCalled();
+        delete _converse.connection;
         done();
     }));
 
@@ -97,12 +98,12 @@ describe("The Registration Panel", function () {
               allow_registration: true },
             async function (done, _converse) {
 
-        spyOn(Strophe.Connection.prototype, 'connect');
         await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel'));
         const cbview = _converse.api.controlbox.get();
         cbview.el.querySelector('.toggle-register-login').click();
 
         const registerview = _converse.chatboxviews.get('controlbox').registerpanel;
+        spyOn(registerview, 'fetchRegistrationForm').and.callThrough();
         spyOn(registerview, 'onProviderChosen').and.callThrough();
         spyOn(registerview, 'getRegistrationFields').and.callThrough();
         spyOn(registerview, 'onRegistrationFields').and.callThrough();
@@ -115,7 +116,7 @@ describe("The Registration Panel", function () {
         registerview.el.querySelector('input[type=submit]').click();
         expect(registerview.onProviderChosen).toHaveBeenCalled();
         expect(registerview._registering).toBeTruthy();
-        await u.waitUntil(() => _converse.connection.connect.calls.count());
+        await u.waitUntil(() => registerview.fetchRegistrationForm.calls.count());
 
         let stanza = new Strophe.Builder("stream:features", {
                     'xmlns:stream': "http://etherx.jabber.org/streams",
@@ -294,7 +295,6 @@ describe("The Registration Panel", function () {
         mock.initConverse(
             ['chatBoxesInitialized'],
             { auto_login: false,
-              view_mode: 'fullscreen',
               discover_connection_methods: false,
               allow_registration: true },
             async function (done, _converse) {

+ 4 - 5
spec/smacks.js

@@ -1,4 +1,4 @@
-/*global mock */
+/*global mock, converse */
 
 const $iq = converse.env.$iq;
 const $msg = converse.env.$msg;
@@ -23,8 +23,7 @@ describe("XEP-0198 Stream Management", function () {
 
         await _converse.api.user.login('romeo@montague.lit/orchard', 'secret');
         const sent_stanzas = _converse.connection.sent_stanzas;
-        let stanza = await u.waitUntil(() =>
-            sent_stanzas.filter(s => (s.tagName === 'enable')).pop());
+        let stanza = await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'enable'), 1000).pop());
 
         expect(_converse.session.get('smacks_enabled')).toBe(false);
         expect(Strophe.serialize(stanza)).toEqual('<enable resume="true" xmlns="urn:xmpp:sm:3"/>');
@@ -33,7 +32,7 @@ describe("XEP-0198 Stream Management", function () {
         _converse.connection._dataRecv(mock.createRequest(result));
         expect(_converse.session.get('smacks_enabled')).toBe(true);
 
-        await u.waitUntil(() => view.renderControlBoxPane.calls.count());
+        await u.waitUntil(() => view.renderControlBoxPane.calls?.count());
 
         let IQ_stanzas = _converse.connection.IQ_stanzas;
         await u.waitUntil(() => IQ_stanzas.length === 4);
@@ -105,7 +104,7 @@ describe("XEP-0198 Stream Management", function () {
         _converse.connection.IQ_stanzas = [];
         IQ_stanzas = _converse.connection.IQ_stanzas;
         await _converse.api.connection.reconnect();
-        stanza = await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'resume')).pop());
+        stanza = await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'resume')).pop(), 1000);
         expect(Strophe.serialize(stanza)).toEqual('<resume h="2" previd="some-long-sm-id" xmlns="urn:xmpp:sm:3"/>');
 
         result = u.toStanza(`<resumed xmlns="urn:xmpp:sm:3" h="another-sequence-number" previd="some-long-sm-id"/>`);

+ 3 - 1
src/converse-register.js

@@ -310,7 +310,9 @@ converse.plugins.add('converse-register', {
                     '_registering': true
                 });
                 await _converse.initConnection(this.domain);
-                _converse.connection.connect(this.domain, "", status => this.onConnectStatusChanged(status));
+                // When testing, the test tears down before the async function
+                // above finishes. So we use optional chaining here
+                _converse.connection?.connect(this.domain, "", status => this.onConnectStatusChanged(status));
                 return false;
             },
 

+ 407 - 0
src/headless/connection.js

@@ -0,0 +1,407 @@
+import log from "./log";
+import sizzle from 'sizzle';
+import u from '@converse/headless/utils/core';
+import { Strophe } from 'strophe.js/src/core';
+import { __ } from './i18n';
+import { _converse, api, clearSession, tearDown } from "./converse-core";
+import { isElement, noop } from 'lodash';
+
+
+const BOSH_WAIT = 59;
+
+
+/**
+ * The Connection class manages the connection to the XMPP server. It's
+ * agnostic concerning the underlying protocol (i.e. websocket, long-polling
+ * via BOSH or websocket inside a shared worker).
+ */
+export class Connection extends Strophe.Connection {
+
+    static generateResource () {
+        return `/converse.js-${Math.floor(Math.random()*139749528).toString()}`;
+    }
+
+    async bind () {
+        /**
+         * Synchronous event triggered before we send an IQ to bind the user's
+         * JID resource for this session.
+         * @event _converse#beforeResourceBinding
+         */
+        await api.trigger('beforeResourceBinding', {'synchronous': true});
+        super.bind();
+    }
+
+
+    async onDomainDiscovered (response) {
+        const text = await response.text();
+        const xrd = (new window.DOMParser()).parseFromString(text, "text/xml").firstElementChild;
+        if (xrd.nodeName != "XRD" || xrd.namespaceURI != "http://docs.oasis-open.org/ns/xri/xrd-1.0") {
+            return log.warn("Could not discover XEP-0156 connection methods");
+        }
+        const bosh_links = sizzle(`Link[rel="urn:xmpp:alt-connections:xbosh"]`, xrd);
+        const ws_links = sizzle(`Link[rel="urn:xmpp:alt-connections:websocket"]`, xrd);
+        const bosh_methods = bosh_links.map(el => el.getAttribute('href'));
+        const ws_methods = ws_links.map(el => el.getAttribute('href'));
+        if (bosh_methods.length === 0 && ws_methods.length === 0) {
+            log.warn("Neither BOSH nor WebSocket connection methods have been specified with XEP-0156.");
+        } else {
+            // TODO: support multiple endpoints
+            api.settings.set("websocket_url", ws_methods.pop());
+            api.settings.set('bosh_service_url', bosh_methods.pop());
+            this.service = api.settings.get("websocket_url") || api.settings.get('bosh_service_url');
+        }
+    }
+
+    /**
+     * Adds support for XEP-0156 by quering the XMPP server for alternate
+     * connection methods. This allows users to use the websocket or BOSH
+     * connection of their own XMPP server instead of a proxy provided by the
+     * host of Converse.js.
+     * @method Connnection.discoverConnectionMethods
+     */
+    async discoverConnectionMethods (domain) {
+        // Use XEP-0156 to check whether this host advertises websocket or BOSH connection methods.
+        const options = {
+            'mode': 'cors',
+            'headers': {
+                'Accept': 'application/xrd+xml, text/xml'
+            }
+        };
+        const url = `https://${domain}/.well-known/host-meta`;
+        let response;
+        try {
+            response = await fetch(url, options);
+        } catch (e) {
+            log.error(`Failed to discover alternative connection methods at ${url}`);
+            log.error(e);
+            return;
+        }
+        if (response.status >= 200 && response.status < 400) {
+            await this.onDomainDiscovered(response);
+        } else {
+            log.warn("Could not discover XEP-0156 connection methods");
+        }
+    }
+
+    /**
+     * Establish a new XMPP session by logging in with the supplied JID and
+     * password.
+     * @method Connnection.connect
+     * @param { String } jid
+     * @param { String } password
+     * @param { Funtion } callback
+     */
+    async connect (jid, password, callback) {
+        if (api.settings.get("discover_connection_methods")) {
+            const domain = Strophe.getDomainFromJid(jid);
+            await this.discoverConnectionMethods(domain);
+        }
+        super.connect(jid, password, callback || this.onConnectStatusChanged, BOSH_WAIT);
+    }
+
+    async reconnect () {
+        log.debug('RECONNECTING: the connection has dropped, attempting to reconnect.');
+        this.setConnectionStatus(
+            Strophe.Status.RECONNECTING,
+            __('The connection has dropped, attempting to reconnect.')
+        );
+        /**
+        * Triggered when the connection has dropped, but Converse will attempt
+        * to reconnect again.
+        *
+        * @event _converse#will-reconnect
+        */
+        api.trigger('will-reconnect');
+
+        this.reconnecting = true;
+        await tearDown();
+        return api.user.login();
+    }
+
+    /**
+     * Called as soon as a new connection has been established, either
+     * by logging in or by attaching to an existing BOSH session.
+     * @method Connection.onConnected
+     * @param { Boolean } reconnecting - Whether Converse.js reconnected from an earlier dropped session.
+     */
+    async onConnected (reconnecting) {
+        delete this.reconnecting;
+        this.flush(); // Solves problem of returned PubSub BOSH response not received by browser
+        await _converse.setUserJID(this.jid);
+
+        /**
+         * Synchronous event triggered after we've sent an IQ to bind the
+         * user's JID resource for this session.
+         * @event _converse#afterResourceBinding
+         */
+        await api.trigger('afterResourceBinding', reconnecting, {'synchronous': true});
+
+        if (reconnecting) {
+            /**
+             * After the connection has dropped and converse.js has reconnected.
+             * Any Strophe stanza handlers (as registered via `converse.listen.stanza`) will
+             * have to be registered anew.
+             * @event _converse#reconnected
+             * @example _converse.api.listen.on('reconnected', () => { ... });
+             */
+            api.trigger('reconnected');
+        } else {
+            /**
+             * Triggered once converse.js has been initialized.
+             * See also {@link _converse#event:pluginsInitialized}.
+             * @event _converse#initialized
+             */
+            api.trigger('initialized');
+            /**
+             * Triggered after the connection has been established and Converse
+             * has got all its ducks in a row.
+             * @event _converse#initialized
+             */
+            api.trigger('connected');
+        }
+    }
+
+    /**
+     * Used to keep track of why we got disconnected, so that we can
+     * decide on what the next appropriate action is (in onDisconnected)
+     * @method Connection.setDisconnectionCause
+     * @param { Number } cause - The status number as received from Strophe.
+     * @param { String } [reason] - An optional user-facing message as to why
+     *  there was a disconnection.
+     * @param { Boolean } [override] - An optional flag to replace any previous
+     *  disconnection cause and reason.
+     */
+    setDisconnectionCause (cause, reason, override) {
+        if (cause === undefined) {
+            delete this.disconnection_cause;
+            delete this.disconnection_reason;
+        } else if (this.disconnection_cause === undefined || override) {
+            this.disconnection_cause = cause;
+            this.disconnection_reason = reason;
+        }
+    }
+
+    setConnectionStatus (status, message) {
+        this.status = status;
+        _converse.connfeedback.set({'connection_status': status, message });
+    }
+
+    async finishDisconnection () {
+        // Properly tear down the session so that it's possible to manually connect again.
+        log.debug('DISCONNECTED');
+        delete this.reconnecting;
+        this.reset();
+        tearDown();
+        await clearSession();
+        delete _converse.connection;
+        /**
+        * Triggered after converse.js has disconnected from the XMPP server.
+        * @event _converse#disconnected
+        * @memberOf _converse
+        * @example _converse.api.listen.on('disconnected', () => { ... });
+        */
+        api.trigger('disconnected');
+    }
+
+    /**
+     * Gets called once strophe's status reaches Strophe.Status.DISCONNECTED.
+     * Will either start a teardown process for converse.js or attempt
+     * to reconnect.
+     * @method onDisconnected
+     */
+    onDisconnected () {
+        if (api.settings.get("auto_reconnect")) {
+            const reason = this.disconnection_reason;
+            if (this.disconnection_cause === Strophe.Status.AUTHFAIL) {
+                if (api.settings.get("credentials_url") || api.settings.get("authentication") === _converse.ANONYMOUS) {
+                    // If `credentials_url` is set, we reconnect, because we might
+                    // be receiving expirable tokens from the credentials_url.
+                    //
+                    // If `authentication` is anonymous, we reconnect because we
+                    // might have tried to attach with stale BOSH session tokens
+                    // or with a cached JID and password
+                    return api.connection.reconnect();
+                } else {
+                    return this.finishDisconnection();
+                }
+            } else if (
+                this.disconnection_cause === _converse.LOGOUT ||
+                reason === Strophe.ErrorCondition.NO_AUTH_MECH ||
+                reason === "host-unknown" ||
+                reason === "remote-connection-failed"
+            ) {
+                return this.finishDisconnection();
+            }
+            api.connection.reconnect();
+        } else {
+            return this.finishDisconnection();
+        }
+    }
+
+    /**
+     * Callback method called by Strophe as the Connection goes
+     * through various states while establishing or tearing down a
+     * connection.
+     * @param { Number } status
+     * @param { String } message
+     */
+    onConnectStatusChanged (status, message) {
+        log.debug(`Status changed to: ${_converse.CONNECTION_STATUS[status]}`);
+        if (status === Strophe.Status.ATTACHFAIL) {
+            this.setConnectionStatus(status);
+            this.worker_attach_promise?.resolve(false);
+
+        } else if (status === Strophe.Status.CONNECTED || status === Strophe.Status.ATTACHED) {
+            if (this.worker_attach_promise?.isResolved && this.status === Strophe.Status.ATTACHED) {
+                // A different tab must have attached, so nothing to do for us here.
+                return;
+            }
+            this.setConnectionStatus(status);
+            this.worker_attach_promise?.resolve(true);
+
+            // By default we always want to send out an initial presence stanza.
+            _converse.send_initial_presence = true;
+            this.setDisconnectionCause();
+            if (this.reconnecting) {
+                log.debug(status === Strophe.Status.CONNECTED ? 'Reconnected' : 'Reattached');
+                this.onConnected(true);
+            } else {
+                log.debug(status === Strophe.Status.CONNECTED ? 'Connected' : 'Attached');
+                if (this.restored) {
+                    // No need to send an initial presence stanza when
+                    // we're restoring an existing session.
+                    _converse.send_initial_presence = false;
+                }
+                this.onConnected();
+            }
+        } else if (status === Strophe.Status.DISCONNECTED) {
+            this.setDisconnectionCause(status, message);
+            this.onDisconnected();
+        } else if (status === Strophe.Status.BINDREQUIRED) {
+            this.bind();
+        } else if (status === Strophe.Status.ERROR) {
+            this.setConnectionStatus(
+                status,
+                __('An error occurred while connecting to the chat server.')
+            );
+        } else if (status === Strophe.Status.CONNECTING) {
+            this.setConnectionStatus(status);
+        } else if (status === Strophe.Status.AUTHENTICATING) {
+            this.setConnectionStatus(status);
+        } else if (status === Strophe.Status.AUTHFAIL) {
+            if (!message) {
+                message = __('Your XMPP address and/or password is incorrect. Please try again.');
+            }
+            this.setConnectionStatus(status, message);
+            this.setDisconnectionCause(status, message, true);
+            this.onDisconnected();
+        } else if (status === Strophe.Status.CONNFAIL) {
+            let feedback = message;
+            if (message === "host-unknown" || message == "remote-connection-failed") {
+                feedback = __("Sorry, we could not connect to the XMPP host with domain: %1$s",
+                    `\"${Strophe.getDomainFromJid(this.jid)}\"`);
+            } else if (message !== undefined && message === Strophe?.ErrorCondition?.NO_AUTH_MECH) {
+                feedback = __("The XMPP server did not offer a supported authentication mechanism");
+            }
+            this.setConnectionStatus(status, feedback);
+            this.setDisconnectionCause(status, message);
+        } else if (status === Strophe.Status.DISCONNECTING) {
+            this.setDisconnectionCause(status, message);
+        }
+    }
+
+    isType (type) {
+        if (type.toLowerCase() === 'websocket') {
+            return this._proto instanceof Strophe.Websocket;
+        } else if (type.toLowerCase() === 'bosh') {
+            return Strophe.BOSH && this._proto instanceof Strophe.Bosh;
+        }
+    }
+
+    hasResumed () {
+        if (api.settings.get("connection_options")?.worker || this.isType('bosh')) {
+            return _converse.connfeedback.get('connection_status') === Strophe.Status.ATTACHED;
+        } else {
+            // Not binding means that the session was resumed.
+            return !this.do_bind;
+        }
+    }
+
+    restoreWorkerSession () {
+        this.attach(this.onConnectStatusChanged);
+        this.worker_attach_promise = u.getResolveablePromise();
+        return this.worker_attach_promise;
+    }
+}
+
+
+/**
+ * The MockConnection class is used during testing, to mock an XMPP connection.
+ * @class
+ */
+export class MockConnection extends Connection {
+
+    constructor (service, options) {
+        super(service, options);
+
+        this.sent_stanzas = [];
+        this.IQ_stanzas = [];
+        this.IQ_ids = [];
+
+        this.features = Strophe.xmlHtmlNode(
+            '<stream:features xmlns:stream="http://etherx.jabber.org/streams" xmlns="jabber:client">'+
+                '<ver xmlns="urn:xmpp:features:rosterver"/>'+
+                '<csi xmlns="urn:xmpp:csi:0"/>'+
+                '<this xmlns="http://jabber.org/protocol/caps" ver="UwBpfJpEt3IoLYfWma/o/p3FFRo=" hash="sha-1" node="http://prosody.im"/>'+
+                '<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">'+
+                    '<required/>'+
+                '</bind>'+
+                `<sm xmlns='urn:xmpp:sm:3'/>`+
+                '<session xmlns="urn:ietf:params:xml:ns:xmpp-session">'+
+                    '<optional/>'+
+                '</session>'+
+            '</stream:features>').firstChild;
+
+        this._proto._processRequest = noop;
+        this._proto._disconnect = () => this._onDisconnectTimeout();
+        this._proto._onDisconnectTimeout = noop;
+        this._proto._connect = () => {
+            this.connected = true;
+            this.mock = true;
+            this.jid = 'romeo@montague.lit/orchard';
+            this._changeConnectStatus(Strophe.Status.BINDREQUIRED);
+        }
+    }
+
+    _processRequest () { // eslint-disable-line class-methods-use-this
+        // Don't attempt to send out stanzas
+    }
+
+    sendIQ (iq, callback, errback) {
+        if (!isElement(iq)) {
+            iq = iq.nodeTree;
+        }
+        this.IQ_stanzas.push(iq);
+        const id = super.sendIQ(iq, callback, errback);
+        this.IQ_ids.push(id);
+        return id;
+    }
+
+    send (stanza) {
+        if (isElement(stanza)) {
+            this.sent_stanzas.push(stanza);
+        } else {
+            this.sent_stanzas.push(stanza.nodeTree);
+        }
+        return super.send(stanza);
+    }
+
+    async bind () {
+        await api.trigger('beforeResourceBinding', {'synchronous': true});
+        this.authenticated = true;
+        if (!_converse.no_connection_on_bind) {
+            this._changeConnectStatus(Strophe.Status.CONNECTED);
+        }
+    }
+}
+

+ 63 - 341
src/headless/converse-core.js

@@ -5,7 +5,8 @@
  */
 import './polyfill';
 import 'strophe.js/src/websocket';
-import * as strophe from 'strophe.js/src/core';
+import { Strophe, $build, $iq, $msg, $pres } from 'strophe.js/src/strophe';
+import { Connection, MockConnection } from '@converse/headless/connection.js';
 import Storage from '@converse/skeletor/src/storage.js';
 import _ from './lodash.noconflict';
 import advancedFormat from 'dayjs/plugin/advancedFormat';
@@ -22,11 +23,6 @@ import { Router } from '@converse/skeletor/src/router.js';
 import { __, i18n } from './i18n';
 import { assignIn, debounce, invoke, isFunction, isObject, isString, pick } from 'lodash-es';
 
-const Strophe = strophe.default.Strophe;
-const $build = strophe.default.$build;
-const $iq = strophe.default.$iq;
-const $msg = strophe.default.$msg;
-const $pres = strophe.default.$pres;
 
 dayjs.extend(advancedFormat);
 
@@ -82,8 +78,6 @@ class IllegalMessage extends Error {}
 // Setting wait to 59 instead of 60 to avoid timing conflicts with the
 // webserver, which is often also set to 60 and might therefore sometimes
 // return a 504 error page instead of passing through to the BOSH proxy.
-const BOSH_WAIT = 59;
-
 const PROMISES = [
     'afterResourceBinding',
     'connectionInitialized',
@@ -233,7 +227,9 @@ export const _converse = {
     TimeoutError: TimeoutError,
     IllegalMessage: IllegalMessage,
 
-    isTestEnv: () => (Strophe.Connection.name === 'MockConnection'),
+    isTestEnv: () => {
+        return initialization_settings.bosh_service_url === 'montague.lit/http-bind';
+    },
 
     /**
      * Translate the given string based on the current locale.
@@ -296,7 +292,7 @@ function initUserSettings () {
     if (!user_settings?.fetched) {
         const id = `converse.user-settings.${_converse.bare_jid}`;
         user_settings = new Model({id});
-        user_settings.browserStorage = _converse.createStore(id);
+        user_settings.browserStorage = createStore(id);
         user_settings.fetched = user_settings.fetch({'promise': true});
     }
     return user_settings.fetched;
@@ -394,9 +390,9 @@ export const api = _converse.api = {
             }
 
             if (_converse.connection.reconnecting) {
-                debouncedReconnect();
+                _converse.connection.debouncedReconnect();
             } else {
-                return reconnect();
+                return _converse.connection.reconnect();
             }
         },
 
@@ -407,11 +403,7 @@ export const api = _converse.api = {
          * @returns {boolean}
          */
         isType (type) {
-            if (type.toLowerCase() === 'websocket') {
-                return _converse.connection._proto instanceof Strophe.Websocket;
-            } else if (type.toLowerCase() === 'bosh') {
-                return Strophe.BOSH && _converse.connection._proto instanceof Strophe.Bosh;
-            }
+            return _converse.connection.isType(type);
         }
     },
 
@@ -508,8 +500,15 @@ export const api = _converse.api = {
          *  fails to restore a previous auth'd session.
          */
         async login (jid, password, automatic=false) {
-            if (jid || _converse.jid) {
-                jid = await _converse.setUserJID(jid || _converse.jid);
+            jid = jid || _converse.jid;
+            if (!_converse.connection?.jid || (jid && !u.isSameDomain(_converse.connection.jid, jid))) {
+                await _converse.initConnection();
+            }
+            if (api.settings.get("connection_options")?.worker && (await _converse.connection.restoreWorkerSession())) {
+                return;
+            }
+            if (jid) {
+                jid = await _converse.setUserJID(jid);
             }
 
             // See whether there is a BOSH session to re-attach to
@@ -521,7 +520,6 @@ export const api = _converse.api = {
                     return _converse.startNewPreboundBOSHSession();
                 }
             }
-
             password = password || api.settings.get("password");
             const credentials = (jid && password) ? { jid, password } : null;
             attemptNonPreboundSession(credentials, automatic);
@@ -546,7 +544,7 @@ export const api = _converse.api = {
                 promise.resolve();
             }
 
-            _converse.setDisconnectionCause(_converse.LOGOUT, undefined, true);
+            _converse.connection.setDisconnectionCause(_converse.LOGOUT, undefined, true);
             if (_converse.connection !== undefined) {
                 api.listen.once('disconnected', () => complete());
                 _converse.connection.disconnect();
@@ -933,16 +931,6 @@ function replacePromise (name) {
     }
 }
 
-_converse.haveResumed = function () {
-    if (_converse.api.connection.isType('bosh')) {
-        return _converse.connfeedback.get('connection_status') === Strophe.Status.ATTACHED;
-    } else {
-        // XXX: Not binding means that the session was resumed.
-        // This seems very fragile. Perhaps a better way is possible.
-        return !_converse.connection.do_bind;
-    }
-}
-
 _converse.isUniView = function () {
     /* We distinguish between UniView and MultiView instances.
      *
@@ -984,11 +972,13 @@ function initPersistentStorage () {
 }
 
 
-_converse.createStore = function (id, storage) {
+function createStore (id, storage) {
     const s = _converse.storage[storage ? storage : _converse.config.get('storage')];
     return new Storage(id, s);
 }
 
+_converse.createStore = createStore;
+
 
 function initPlugins () {
     // If initialize gets called a second time (e.g. during tests), then we
@@ -1046,7 +1036,7 @@ function initClientConfig () {
         'trusted': _converse.api.settings.get("trusted") && true || false,
         'storage': _converse.api.settings.get("trusted") ? 'persistent' : 'session'
     });
-    _converse.config.browserStorage = _converse.createStore(id, "session");
+    _converse.config.browserStorage = createStore(id, "session");
     _converse.config.fetch();
     /**
      * Triggered once the XMPP-client configuration has been initialized.
@@ -1061,7 +1051,7 @@ function initClientConfig () {
 }
 
 
-async function tearDown () {
+export async function tearDown () {
     await _converse.api.trigger('beforeTearDown', {'synchronous': true});
     window.removeEventListener('click', _converse.onUserActivity);
     window.removeEventListener('focus', _converse.onUserActivity);
@@ -1075,7 +1065,8 @@ async function tearDown () {
 
 
 async function attemptNonPreboundSession (credentials, automatic) {
-    if (_converse.api.settings.get("authentication") === _converse.LOGIN) {
+    const { api } = _converse;
+    if (api.settings.get("authentication") === _converse.LOGIN) {
         // XXX: If EITHER ``keepalive`` or ``auto_login`` is ``true`` and
         // ``authentication`` is set to ``login``, then Converse will try to log the user in,
         // since we don't have a way to distinguish between wether we're
@@ -1112,12 +1103,7 @@ function connect (credentials) {
         if (!_converse.connection.reconnecting) {
             _converse.connection.reset();
         }
-        _converse.connection.connect(
-            _converse.jid.toLowerCase(),
-            null,
-            _converse.onConnectStatusChanged,
-            BOSH_WAIT
-        );
+        _converse.connection.connect(_converse.jid.toLowerCase());
     } else if (_converse.api.settings.get("authentication") === _converse.LOGIN) {
         const password = credentials ? credentials.password : (_converse.connection?.pass || _converse.api.settings.get("password"));
         if (!password) {
@@ -1125,51 +1111,25 @@ function connect (credentials) {
                 throw new Error("autoLogin: If you use auto_login and "+
                     "authentication='login' then you also need to provide a password.");
             }
-            _converse.setDisconnectionCause(Strophe.Status.AUTHFAIL, undefined, true);
+            _converse.connection.setDisconnectionCause(Strophe.Status.AUTHFAIL, undefined, true);
             _converse.api.connection.disconnect();
             return;
         }
         if (!_converse.connection.reconnecting) {
             _converse.connection.reset();
         }
-        _converse.connection.connect(_converse.jid, password, _converse.onConnectStatusChanged, BOSH_WAIT);
+        _converse.connection.connect(_converse.jid, password);
     }
 }
 
 
-async function reconnect () {
-    log.debug('RECONNECTING: the connection has dropped, attempting to reconnect.');
-    _converse.setConnectionStatus(
-        Strophe.Status.RECONNECTING,
-        __('The connection has dropped, attempting to reconnect.')
-    );
-    /**
-     * Triggered when the connection has dropped, but Converse will attempt
-     * to reconnect again.
-     *
-     * @event _converse#will-reconnect
-     */
-    _converse.api.trigger('will-reconnect');
-
-    _converse.connection.reconnecting = true;
-    await tearDown();
-    return _converse.api.user.login();
-}
-
-const debouncedReconnect = debounce(reconnect, 2000);
-
-
 _converse.shouldClearCache = () => (!_converse.config.get('trusted') || _converse.isTestEnv());
 
 
-function clearSession  () {
-    if (_converse.session !== undefined) {
-        _converse.session.destroy();
-        delete _converse.session;
-    }
-    if (_converse.shouldClearCache()) {
-        _converse.api.user.settings.clear();
-    }
+export function clearSession  () {
+    _converse.session?.destroy();
+    delete _converse.session;
+    _converse.shouldClearCache() && _converse.api.user.settings.clear();
     /**
      * Synchronouse event triggered once the user session has been cleared,
      * for example when the user has logged out or when Converse has
@@ -1180,77 +1140,31 @@ function clearSession  () {
 }
 
 
-async function onDomainDiscovered (response) {
-    const text = await response.text();
-    const xrd = (new window.DOMParser()).parseFromString(text, "text/xml").firstElementChild;
-    if (xrd.nodeName != "XRD" || xrd.namespaceURI != "http://docs.oasis-open.org/ns/xri/xrd-1.0") {
-        return log.warn("Could not discover XEP-0156 connection methods");
-    }
-    const bosh_links = sizzle(`Link[rel="urn:xmpp:alt-connections:xbosh"]`, xrd);
-    const ws_links = sizzle(`Link[rel="urn:xmpp:alt-connections:websocket"]`, xrd);
-    const bosh_methods = bosh_links.map(el => el.getAttribute('href'));
-    const ws_methods = ws_links.map(el => el.getAttribute('href'));
-    // TODO: support multiple endpoints
-    _converse.api.settings.set("websocket_url", ws_methods.pop());
-    _converse.api.settings.set('bosh_service_url', bosh_methods.pop());
-    if (bosh_methods.length === 0 && ws_methods.length === 0) {
-        log.warn(
-            "onDomainDiscovered: neither BOSH nor WebSocket connection methods have been specified with XEP-0156."
-        );
-    }
-}
-
-
-async function discoverConnectionMethods (domain) {
-    // Use XEP-0156 to check whether this host advertises websocket or BOSH connection methods.
-    const options = {
-        'mode': 'cors',
-        'headers': {
-            'Accept': 'application/xrd+xml, text/xml'
-        }
-    };
-    const url = `https://${domain}/.well-known/host-meta`;
-    let response;
-    try {
-        response = await fetch(url, options);
-    } catch (e) {
-        log.error(`Failed to discover alternative connection methods at ${url}`);
-        log.error(e);
-        return;
-    }
-    if (response.status >= 200 && response.status < 400) {
-        await onDomainDiscovered(response);
-    } else {
-        log.warn("Could not discover XEP-0156 connection methods");
-    }
-}
-
+_converse.initConnection = function () {
+    const api = _converse.api;
 
-_converse.initConnection = async function (domain) {
-    if (_converse.api.settings.get("discover_connection_methods")) {
-        await discoverConnectionMethods(domain);
-    }
-    if (! _converse.api.settings.get('bosh_service_url')) {
-        if (_converse.api.settings.get("authentication") === _converse.PREBIND) {
+    if (! api.settings.get('bosh_service_url')) {
+        if (api.settings.get("authentication") === _converse.PREBIND) {
             throw new Error("authentication is set to 'prebind' but we don't have a BOSH connection");
         }
-        if (! _converse.api.settings.get("websocket_url")) {
+        if (! api.settings.get("websocket_url")) {
             throw new Error("initConnection: you must supply a value for either the bosh_service_url or websocket_url or both.");
         }
     }
 
-    if (('WebSocket' in window || 'MozWebSocket' in window) && _converse.api.settings.get("websocket_url")) {
-        _converse.connection = new Strophe.Connection(
-            _converse.api.settings.get("websocket_url"),
-            Object.assign(_converse.default_connection_options, _converse.api.settings.get("connection_options"))
+    const XMPPConnection = _converse.isTestEnv() ? MockConnection : Connection;
+    if (('WebSocket' in window || 'MozWebSocket' in window) && api.settings.get("websocket_url")) {
+        _converse.connection = new XMPPConnection(
+            api.settings.get("websocket_url"),
+            Object.assign(_converse.default_connection_options, api.settings.get("connection_options"))
         );
-    } else if (_converse.api.settings.get('bosh_service_url')) {
-        _converse.connection = new Strophe.Connection(
-            _converse.api.settings.get('bosh_service_url'),
+    } else if (api.settings.get('bosh_service_url')) {
+        _converse.connection = new XMPPConnection(
+            api.settings.get('bosh_service_url'),
             Object.assign(
                 _converse.default_connection_options,
-                _converse.api.settings.get("connection_options"),
-                {'keepalive': _converse.api.settings.get("keepalive")}
+                api.settings.get("connection_options"),
+                {'keepalive': api.settings.get("keepalive")}
             )
         );
     } else {
@@ -1259,23 +1173,28 @@ _converse.initConnection = async function (domain) {
     }
     setUpXMLLogging();
     /**
-     * Triggered once the `Strophe.Connection` constructor has been initialized, which
+     * Triggered once the `Connection` constructor has been initialized, which
      * will be responsible for managing the connection to the XMPP server.
      *
      * @event _converse#connectionInitialized
      */
-    _converse.api.trigger('connectionInitialized');
+    api.trigger('connectionInitialized');
 }
 
 
 async function initSession (jid) {
+    const is_shared_session = api.settings.get('connection_options').worker;
+
     const bare_jid = Strophe.getBareJidFromJid(jid).toLowerCase();
     const id = `converse.session-${bare_jid}`;
-    if (!_converse.session || _converse.session.get('id') !== id) {
+    if (_converse.session?.get('id') !== id) {
         _converse.session = new Model({id});
-        _converse.session.browserStorage = _converse.createStore(id, "session");
+        _converse.session.browserStorage = createStore(id, is_shared_session ? "persistent" : "session");
         await new Promise(r => _converse.session.fetch({'success': r, 'error': r}));
-        if (_converse.session.get('active')) {
+
+        if (!is_shared_session && _converse.session.get('active')) {
+            // If the `active` flag is set, it means this tab was cloned from
+            // another (e.g. via middle-click), and its session data was copied over.
             _converse.session.clear();
             _converse.session.save({id});
         }
@@ -1297,7 +1216,7 @@ async function initSession (jid) {
 function saveJIDtoSession (jid) {
     jid = _converse.session.get('jid') || jid;
     if (_converse.api.settings.get("authentication") !== _converse.ANONYMOUS && !Strophe.getResourceFromJid(jid)) {
-        jid = jid.toLowerCase() + _converse.generateResource();
+        jid = jid.toLowerCase() + Connection.generateResource();
     }
     _converse.jid = jid;
     _converse.bare_jid = Strophe.getBareJidFromJid(jid);
@@ -1308,6 +1227,9 @@ function saveJIDtoSession (jid) {
        'bare_jid': _converse.bare_jid,
        'resource': _converse.resource,
        'domain': _converse.domain,
+        // We use the `active` flag to determine whether we should use the values from sessionStorage.
+        // When "cloning" a tab (e.g. via middle-click), the `active` flag will be set and we'll create
+        // a new empty user session, otherwise it'll be false and we can re-use the user session.
        'active': true
     });
     // Set JID on the connection object so that when we call `connection.bind`
@@ -1330,10 +1252,6 @@ function saveJIDtoSession (jid) {
  * @params { String } jid
  */
 _converse.setUserJID = async function (jid) {
-    if (!_converse.connection || !u.isSameDomain(_converse.connection.jid, jid)) {
-        const domain = Strophe.getDomainFromJid(jid)
-        await _converse.initConnection(domain);
-    }
     await initSession(jid);
     /**
      * Triggered whenever the user's JID has been updated
@@ -1405,95 +1323,6 @@ function cleanup () {
     _converse.off();
 }
 
-_converse.generateResource = () => `/converse.js-${Math.floor(Math.random()*139749528).toString()}`;
-
-
-/**
- * Callback method called by Strophe as the Strophe.Connection goes
- * through various states while establishing or tearing down a
- * connection.
- * @method _converse#onConnectStatusChanged
- * @private
- * @memberOf _converse
- */
-_converse.onConnectStatusChanged = function (status, message) {
-    log.debug(`Status changed to: ${_converse.CONNECTION_STATUS[status]}`);
-    if (status === Strophe.Status.CONNECTED || status === Strophe.Status.ATTACHED) {
-        _converse.setConnectionStatus(status);
-        // By default we always want to send out an initial presence stanza.
-        _converse.send_initial_presence = true;
-        _converse.setDisconnectionCause();
-        if (_converse.connection.reconnecting) {
-            log.debug(status === Strophe.Status.CONNECTED ? 'Reconnected' : 'Reattached');
-            onConnected(true);
-        } else {
-            log.debug(status === Strophe.Status.CONNECTED ? 'Connected' : 'Attached');
-            if (_converse.connection.restored) {
-                // No need to send an initial presence stanza when
-                // we're restoring an existing session.
-                _converse.send_initial_presence = false;
-            }
-            onConnected();
-        }
-    } else if (status === Strophe.Status.DISCONNECTED) {
-        _converse.setDisconnectionCause(status, message);
-        _converse.onDisconnected();
-    } else if (status === Strophe.Status.BINDREQUIRED) {
-        _converse.bindResource();
-    } else if (status === Strophe.Status.ERROR) {
-        _converse.setConnectionStatus(
-            status,
-            __('An error occurred while connecting to the chat server.')
-        );
-    } else if (status === Strophe.Status.CONNECTING) {
-        _converse.setConnectionStatus(status);
-    } else if (status === Strophe.Status.AUTHENTICATING) {
-        _converse.setConnectionStatus(status);
-    } else if (status === Strophe.Status.AUTHFAIL) {
-        if (!message) {
-            message = __('Your XMPP address and/or password is incorrect. Please try again.');
-        }
-        _converse.setConnectionStatus(status, message);
-        _converse.setDisconnectionCause(status, message, true);
-        _converse.onDisconnected();
-    } else if (status === Strophe.Status.CONNFAIL) {
-        let feedback = message;
-        if (message === "host-unknown" || message == "remote-connection-failed") {
-            feedback = __("Sorry, we could not connect to the XMPP host with domain: %1$s",
-                `\"${Strophe.getDomainFromJid(_converse.connection.jid)}\"`);
-        } else if (message !== undefined && message === Strophe?.ErrorCondition?.NO_AUTH_MECH) {
-            feedback = __("The XMPP server did not offer a supported authentication mechanism");
-        }
-        _converse.setConnectionStatus(status, feedback);
-        _converse.setDisconnectionCause(status, message);
-    } else if (status === Strophe.Status.DISCONNECTING) {
-        _converse.setDisconnectionCause(status, message);
-    }
-};
-
-
-_converse.setConnectionStatus = function (connection_status, message) {
-    _converse.connfeedback.set({
-        'connection_status': connection_status,
-        'message': message
-    });
-};
-
-
-/**
- * Used to keep track of why we got disconnected, so that we can
- * decide on what the next appropriate action is (in onDisconnected)
- */
-_converse.setDisconnectionCause = function (cause, reason, override) {
-    if (cause === undefined) {
-        delete _converse.disconnection_cause;
-        delete _converse.disconnection_reason;
-    } else if (_converse.disconnection_cause === undefined || override) {
-        _converse.disconnection_cause = cause;
-        _converse.disconnection_reason = reason;
-    }
-};
-
 
 function enableCarbons () {
     /* Ask the XMPP server to enable Message Carbons
@@ -1519,65 +1348,7 @@ function enableCarbons () {
     _converse.connection.send(carbons_iq);
 }
 
-
-async function onConnected (reconnecting) {
-    /* Called as soon as a new connection has been established, either
-     * by logging in or by attaching to an existing BOSH session.
-     */
-    delete _converse.connection.reconnecting;
-    _converse.connection.flush(); // Solves problem of returned PubSub BOSH response not received by browser
-    await _converse.setUserJID(_converse.connection.jid);
-
-    /**
-     * Synchronous event triggered after we've sent an IQ to bind the
-     * user's JID resource for this session.
-     * @event _converse#afterResourceBinding
-     */
-    await api.trigger('afterResourceBinding', reconnecting, {'synchronous': true});
-    enableCarbons();
-
-    if (reconnecting) {
-        /**
-         * After the connection has dropped and converse.js has reconnected.
-         * Any Strophe stanza handlers (as registered via `converse.listen.stanza`) will
-         * have to be registered anew.
-         * @event _converse#reconnected
-         * @example _converse.api.listen.on('reconnected', () => { ... });
-         */
-        api.trigger('reconnected');
-    } else {
-        /**
-         * Triggered once converse.js has been initialized.
-         * See also {@link _converse#event:pluginsInitialized}.
-         * @event _converse#initialized
-         */
-        api.trigger('initialized');
-        /**
-         * Triggered after the connection has been established and Converse
-         * has got all its ducks in a row.
-         * @event _converse#initialized
-         */
-        api.trigger('connected');
-    }
-}
-
-
-async function finishDisconnection () {
-    // Properly tear down the session so that it's possible to manually connect again.
-    log.debug('DISCONNECTED');
-    delete _converse.connection.reconnecting;
-    _converse.connection.reset();
-    tearDown();
-    await clearSession();
-    delete _converse.connection;
-    /**
-     * Triggered after converse.js has disconnected from the XMPP server.
-     * @event _converse#disconnected
-     * @memberOf _converse
-     * @example _converse.api.listen.on('disconnected', () => { ... });
-     */
-    api.trigger('disconnected');
-}
+api.listen.on('afterResourceBinding', () => enableCarbons());
 
 
 function fetchLoginCredentials (wait=0) {
@@ -1659,55 +1430,6 @@ function unregisterGlobalEventHandlers () {
 }
 
 
-/**
- * Gets called once strophe's status reaches Strophe.Status.DISCONNECTED.
- * Will either start a teardown process for converse.js or attempt
- * to reconnect.
- * @method onDisconnected
- * @private
- * @memberOf _converse
- */
-_converse.onDisconnected = function () {
-    if (api.settings.get("auto_reconnect")) {
-        const reason = _converse.disconnection_reason;
-        if (_converse.disconnection_cause === Strophe.Status.AUTHFAIL) {
-            if (api.settings.get("credentials_url") || api.settings.get("authentication") === _converse.ANONYMOUS) {
-                // If `credentials_url` is set, we reconnect, because we might
-                // be receiving expirable tokens from the credentials_url.
-                //
-                // If `authentication` is anonymous, we reconnect because we
-                // might have tried to attach with stale BOSH session tokens
-                // or with a cached JID and password
-                return api.connection.reconnect();
-            } else {
-                return finishDisconnection();
-            }
-        } else if (
-            _converse.disconnection_cause === _converse.LOGOUT ||
-            reason === Strophe.ErrorCondition.NO_AUTH_MECH ||
-            reason === "host-unknown" ||
-            reason === "remote-connection-failed"
-        ) {
-            return finishDisconnection();
-        }
-        api.connection.reconnect();
-    } else {
-        return finishDisconnection();
-    }
-};
-
-
-_converse.bindResource = async function () {
-    /**
-     * Synchronous event triggered before we send an IQ to bind the user's
-     * JID resource for this session.
-     * @event _converse#beforeResourceBinding
-     */
-    await api.trigger('beforeResourceBinding', {'synchronous': true});
-    _converse.connection.bind();
-};
-
-
 _converse.ConnectionFeedback = Model.extend({
     defaults: {
         'connection_status': Strophe.Status.DISCONNECTED,

+ 2 - 2
src/headless/converse-mam.js

@@ -146,8 +146,8 @@ converse.plugins.add('converse-mam', {
 
 
         _converse.onMAMError = function (iq) {
-            if (iq && iq.querySelectorAll('feature-not-implemented').length) {
-                log.warn(`Message Archive Management (XEP-0313) not supported by ${iq.getAttribute('from')}`);
+            if (iq?.querySelectorAll('feature-not-implemented').length) {
+                    log.warn(`Message Archive Management (XEP-0313) not supported by ${iq.getAttribute('from')}`);
             } else {
                 log.error(`Error while trying to set archiving preferences for ${iq.getAttribute('from')}.`);
                 log.error(iq);

+ 1 - 1
src/headless/converse-roster.js

@@ -960,7 +960,7 @@ converse.plugins.add('converse-roster', {
                 // When reconnecting and not resuming a previous session,
                 // we clear all cached presence data, since it might be stale
                 // and we'll receive new presence updates
-                !_converse.haveResumed() && await clearPresences();
+                !_converse.connection.hasResumed() && await clearPresences();
             } else {
                 _converse.presences = new _converse.Presences();
                 const id = `converse.presences-${_converse.bare_jid}`;

+ 41 - 57
src/headless/package-lock.json

@@ -4,38 +4,25 @@
 	"lockfileVersion": 1,
 	"requires": true,
 	"dependencies": {
-		"filesize": {
-			"version": "6.1.0",
-			"resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz",
-			"integrity": "sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg==",
-			"dev": true
-		},
-		"fs-extra": {
-			"version": "8.1.0",
-			"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
-			"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
+		"@converse/skeletor": {
+			"version": "github:conversejs/skeletor#b260c554f4ce961c29deea4740083e58a489aa9b",
+			"from": "github:conversejs/skeletor#b260c554f4ce961c29deea4740083e58a489aa9b",
 			"dev": true,
 			"requires": {
-				"graceful-fs": "^4.2.0",
-				"jsonfile": "^4.0.0",
-				"universalify": "^0.1.0"
-			},
-			"dependencies": {
-				"jsonfile": {
-					"version": "4.0.0",
-					"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
-					"integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
-					"dev": true,
-					"requires": {
-						"graceful-fs": "^4.1.6"
-					}
-				}
+				"lit-html": "^1.2.1",
+				"lodash-es": "^4.17.14"
 			}
 		},
-		"graceful-fs": {
-			"version": "4.2.3",
-			"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz",
-			"integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==",
+		"abab": {
+			"version": "2.0.3",
+			"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.3.tgz",
+			"integrity": "sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg==",
+			"dev": true
+		},
+		"filesize": {
+			"version": "6.1.0",
+			"resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz",
+			"integrity": "sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg==",
 			"dev": true
 		},
 		"immediate": {
@@ -50,16 +37,6 @@
 			"integrity": "sha1-elSbvZ/+FYWwzQoZHiAwVb7ldLQ=",
 			"dev": true
 		},
-		"jsonfile": {
-			"version": "5.0.0",
-			"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
-			"integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
-			"dev": true,
-			"requires": {
-				"graceful-fs": "^4.1.6",
-				"universalify": "^0.1.2"
-			}
-		},
 		"lie": {
 			"version": "3.1.1",
 			"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
@@ -69,6 +46,12 @@
 				"immediate": "~3.0.5"
 			}
 		},
+		"lit-html": {
+			"version": "1.2.1",
+			"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-1.2.1.tgz",
+			"integrity": "sha512-GSJHHXMGLZDzTRq59IUfL9FCdAlGfqNp/dEa7k7aBaaWD+JKaCjsAk9KYm2V12ItonVaYx2dprN66Zdm1AuBTQ==",
+			"dev": true
+		},
 		"localforage": {
 			"version": "1.7.3",
 			"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.7.3.tgz",
@@ -81,6 +64,7 @@
 		"lodash": {
 			"version": "4.17.19",
 			"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
+			"integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==",
 			"dev": true
 		},
 		"lodash-es": {
@@ -95,32 +79,32 @@
 			"integrity": "sha512-SBt6v6Tbp20Jf8hU0cpcc/+HBHGMY8/Q+yA6Ih0tBQE8tfdZ6U4PRG0iNvUUjLx/hVyOP53n0UfGBymlfaaXCg==",
 			"dev": true,
 			"requires": {
-				"lodash": "^4.17.19"
+				"lodash": "^4.17.11"
 			}
 		},
-		"twemoji": {
-			"version": "12.1.5",
-			"resolved": "https://registry.npmjs.org/twemoji/-/twemoji-12.1.5.tgz",
-			"integrity": "sha512-B0PBVy5xomwb1M/WZxf/IqPZfnoIYy1skXnlHjMwLwTNfZ9ljh8VgWQktAPcJXu8080WoEh6YwQGPVhDVqvrVQ==",
+		"strophe.js": {
+			"version": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f",
+			"from": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f",
 			"dev": true,
 			"requires": {
-				"fs-extra": "^8.0.1",
-				"jsonfile": "^5.0.0",
-				"twemoji-parser": "12.1.3",
-				"universalify": "^0.1.2"
+				"abab": "^2.0.3",
+				"ws": "^7.0.0",
+				"xmldom": "^0.1.27"
 			}
 		},
-		"twemoji-parser": {
-			"version": "12.1.3",
-			"resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-12.1.3.tgz",
-			"integrity": "sha512-ND4LZXF4X92/PFrzSgGkq6KPPg8swy/U0yRw1k/+izWRVmq1HYi3khPwV3XIB6FRudgVICAaBhJfW8e8G3HC7Q==",
-			"dev": true
+		"ws": {
+			"version": "7.3.1",
+			"resolved": "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz",
+			"integrity": "sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==",
+			"dev": true,
+			"optional": true
 		},
-		"universalify": {
-			"version": "0.1.2",
-			"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
-			"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
-			"dev": true
+		"xmldom": {
+			"version": "0.1.31",
+			"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz",
+			"integrity": "sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==",
+			"dev": true,
+			"optional": true
 		}
 	}
 }

+ 1 - 1
src/headless/package.json

@@ -42,6 +42,6 @@
     "localforage": "^1.7.3",
     "lodash-es": "^4.17.15",
     "pluggable.js": "2.0.1",
-    "strophe.js": "1.3.6"
+    "strophe.js": "strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f"
   }
 }

+ 1 - 1
src/templates/spinner.js

@@ -1,3 +1,3 @@
 import { html } from "lit-html";
 
-export default (o) => html`<span class="spinner fa fa-spinner centered ${o.classes || ''}"/>`
+export default (o={}) => html`<span class="spinner fa fa-spinner centered ${o.classes || ''}"/>`

+ 1 - 1
webpack.common.js

@@ -89,7 +89,7 @@ module.exports = {
             ]
         }, {
             test: /\.js$/,
-            exclude: /(node_modules|spec|mockup)/,
+            include: /src/,
             use: {
                 loader: 'babel-loader',
                 options: {

+ 1 - 0
webpack.html

@@ -28,6 +28,7 @@
         modtools_disable_query: ['moderator', 'participant', 'visitor'],
         enable_smacks: true,
         i18n: 'en',
+        // connection_options: { 'worker': '/dist/shared-connection-worker.js' },
         message_archiving: 'always',
         muc_domain: 'conference.chat.example.org',
         muc_respect_autojoin: true,

+ 1 - 0
webpack.prod.js

@@ -20,6 +20,7 @@ module.exports = merge(common, {
         new MiniCssExtractPlugin({filename: '../dist/converse.min.css'}),
         new CopyWebpackPlugin({
             patterns: [
+                {from: 'src/headless/node_modules/strophe.js/src/shared-connection-worker.js', to: 'shared-connection-worker.js'},
                 {from: 'sounds', to: 'sounds'},
                 {from: 'images/favicon.ico', to: 'images/favicon.ico'},
                 {from: 'images/custom_emojis', to: 'images/custom_emojis'},