diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9d1cb98a..81e8d6f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,14 @@
## Version 4.1.0 - (Unreleased)
- Migrate to full TypeScript
+- Add native [XEP-0198 Stream Management](https://xmpp.org/extensions/xep-0198.html) support for
+ websocket connections. Opt in with the new `enableStreamManagement` connection option and tune it
+ via the `streamManagement` option (see the README).
+- New `Connection.onForeignStanzaSent` hook: under the `worker` option, messages and presences
+ sent from one tab are reflected to all other tabs.
+- `Strophe.Status.RECONNECTING` is now defined.
+- Export `MemoryStorageBackend`, `SessionStorageBackend`, `ConnectionOptions` and
+ `ConnectCallback` from the package root.
## Version 4.0.0 - (2026-06-03)
@@ -31,23 +39,24 @@
## Version 3.0.1 - (2024-08-15)
-* Bugfix: `Package path . is not exported from package`
-* #708 Properly set exports value in package.json
-* #710 Fix types minor errors
-* #711 Error with Builder.up depending on context
-* #712 fix: export node and default modules
-* #715 Fix the error when the attrs field is null
+- Bugfix: `Package path . is not exported from package`
+- #708 Properly set exports value in package.json
+- #710 Fix types minor errors
+- #711 Error with Builder.up depending on context
+- #712 fix: export node and default modules
+- #715 Fix the error when the attrs field is null
Dependency updates:
-* Bump @rollup/plugin-commonjs from 24.1.0 to 26.0.1
-* Bump @xmldom/xmldom from 0.8.8 to 0.8.10
-* Bump prettier from 2.8.8 to 3.3.3
-* Bump sinon from 15.0.4 to 18.0.0
+
+- Bump @rollup/plugin-commonjs from 24.1.0 to 26.0.1
+- Bump @xmldom/xmldom from 0.8.8 to 0.8.10
+- Bump prettier from 2.8.8 to 3.3.3
+- Bump sinon from 15.0.4 to 18.0.0
## Version 3.0.0 - (2024-05-07)
-* #704 Cannot use with NodeJS
-* #706 TypeError when receiving a `stream:error` IQ message
+- #704 Cannot use with NodeJS
+- #706 TypeError when receiving a `stream:error` IQ message
Out of an abundance of caution, making a major version bump, since there was
some internal refactoring of the Strophe files to remove circular
@@ -55,67 +64,68 @@ dependencies. So certain deep imports used by integrators might no longer work.
Instead of deep imports, everything should be imported from `strophe.js`.
For example:
+
```
import { Strophe, $build, stx } from strophe.js;
```
## Version 2.0.0 - (2024-02-21)
-* Type checking via TypeScript and JSDoc typing annotations
-* Types definitions are now generated and placed in `./dist/types/`.
-* Remove the deprecated `matchBare` option for `Strophe.Handler`. Use `matchBareFromJid` instead.
-* Add the ability to create stanzas via a tagged template literal (`stx`).
-* Bugfix: Ignore unknown SCRAM attributes instead of aborting the connection
+- Type checking via TypeScript and JSDoc typing annotations
+- Types definitions are now generated and placed in `./dist/types/`.
+- Remove the deprecated `matchBare` option for `Strophe.Handler`. Use `matchBareFromJid` instead.
+- Add the ability to create stanzas via a tagged template literal (`stx`).
+- Bugfix: Ignore unknown SCRAM attributes instead of aborting the connection
## Version 1.6.2 - (2023-06-23)
-* #613 TypeError: XHTML.validTag is not a function
-* Re-add NodeJS support and add the ability to run tests on NodeJS to avoid regressions
+- #613 TypeError: XHTML.validTag is not a function
+- Re-add NodeJS support and add the ability to run tests on NodeJS to avoid regressions
## Version 1.6.1 - (2023-05-15)
-* #602 Websocket connection times out instead of being disconnected
+- #602 Websocket connection times out instead of being disconnected
## Version 1.6.0 - (2022-10-29)
-* #314 Support for SCRAM-SHA-256 and SCRAM-SHA-512 authentication
+- #314 Support for SCRAM-SHA-256 and SCRAM-SHA-512 authentication
## Version 1.5.0 - (2022-04-28)
-* Add an automatic fallback handler for unhandled IQ "set" and "get" stanzas
+- Add an automatic fallback handler for unhandled IQ "set" and "get" stanzas
that returns an IQ error with `service-unavailable`.
-* Update various 3rd party dependencies
-* #390 Replace deprecated String.prototype.substr()
-* #418 BOSH fix: mark first request dead when second is done
+- Update various 3rd party dependencies
+- #390 Replace deprecated String.prototype.substr()
+- #418 BOSH fix: mark first request dead when second is done
## Version 1.4.4 - (2022-01-21)
-* #388 Properly import xmldom
+- #388 Properly import xmldom
## Version 1.4.3 - (2021-12-16)
-* Update xmldom to version 0.7.5
-* Add disconnection_timeout setting: an optional timeout in milliseconds before `_doDisconnect` is called.
-* Update ws optional dependency to fix security issue https://github.com/websockets/ws/commit/00c425ec77993773d823f018f64a5c44e17023ff
+- Update xmldom to version 0.7.5
+- Add disconnection_timeout setting: an optional timeout in milliseconds before `_doDisconnect` is called.
+- Update ws optional dependency to fix security issue https://github.com/websockets/ws/commit/00c425ec77993773d823f018f64a5c44e17023ff
## Version 1.4.2 - (2021-04-28)
-* Update optional NodeJS-specific dependency xmldom to version 0.5.0 which includes a security fix.
-* #369 Add `clientChallenge` SASL mechanism method to avoid having to monkey patch `onChallenge`,
+- Update optional NodeJS-specific dependency xmldom to version 0.5.0 which includes a security fix.
+- #369 Add `clientChallenge` SASL mechanism method to avoid having to monkey patch `onChallenge`,
which prevents reconnection when using SCRAM-SHA1.
## Version 1.4.1 - (2020-12-02)
-* #201: NodeJS related fixes: `window` and `WebSocket` are `undefined`
-* New method `Strophe.Connection.prototype.setProtocol` which can be used to
+- #201: NodeJS related fixes: `window` and `WebSocket` are `undefined`
+- New method `Strophe.Connection.prototype.setProtocol` which can be used to
determine the protocol used after the connection has been constructed.
## Version 1.4.0 - (2020-09-10)
-* #347: Bugfix. Reconnection fails when SessionResultIQ arrives too late
-* #354: Strophe.js sends an authzid during PLAIN when not acting on behalf of another entity
-* #359: Bugfix: WebSocket connection failed: Data frame received after close
-* Add support for running a websocket connection inside a shared worker
+- #347: Bugfix. Reconnection fails when SessionResultIQ arrives too late
+- #354: Strophe.js sends an authzid during PLAIN when not acting on behalf of another entity
+- #359: Bugfix: WebSocket connection failed: Data frame received after close
+- Add support for running a websocket connection inside a shared worker
## Version 1.3.6 - (2020-06-15)
@@ -124,236 +134,255 @@ import { Strophe, $build, stx } from strophe.js;
## Version 1.3.5 - (2020-04-29)
-* Remove support for obselete SASL DIGEST-MD5 auth
-* #329 Varous compatibility fixes to make Strophe.js work in NodeJS
-* #344 Properly set Strophe.VERSION
+- Remove support for obselete SASL DIGEST-MD5 auth
+- #329 Varous compatibility fixes to make Strophe.js work in NodeJS
+- #344 Properly set Strophe.VERSION
## Version 1.3.4 - (2019-08-08)
-* Replace webpack with rollup
-* `TypeError: this._changeConnectStatus is not a function`
-* Bugfix. Remove handlers on closed socket
-* Add new Strophe.Connection option `explicitResourceBinding`.
+- Replace webpack with rollup
+- `TypeError: this._changeConnectStatus is not a function`
+- Bugfix. Remove handlers on closed socket
+- Add new Strophe.Connection option `explicitResourceBinding`.
If is set to true the XMPP client needs to explicitly
call `Strophe.Connection.prototype.bind` once the XMPP
server has advertised the "urn:ietf:params:xml:ns:xmpp-bind" feature.
## Version 1.3.3 - (2019-05-13)
-* The dist files are no longer included in the repo, but generated by NPM/Yarn
-* Moved some log statements from INFO to DEBUG level
-* Don't break when a falsy value is passed to `getResourceFromJid`
+- The dist files are no longer included in the repo, but generated by NPM/Yarn
+- Moved some log statements from INFO to DEBUG level
+- Don't break when a falsy value is passed to `getResourceFromJid`
## Version 1.3.2 - (2019-03-21)
-* #320 Fix error on SCRAM-SHA-1 client nonce generation
+- #320 Fix error on SCRAM-SHA-1 client nonce generation
## Version 1.3.1 - (2018-11-15)
-* #311 Expose `Strophe`, `$build`, `$msg` and `$iq` as globals
+- #311 Expose `Strophe`, `$build`, `$msg` and `$iq` as globals
## Version 1.3.0 - (2018-10-21)
-* Use ES2015 modules
-* Drop support for Internet Explorer < 11
+- Use ES2015 modules
+- Drop support for Internet Explorer < 11
## Version 1.2.16 - (2018-09-16)
-* #299 'no-auth-mech' error. Server did not offer a supported authentication mechanism
-* #306 Fix websocket close handler exception and reporting
+
+- #299 'no-auth-mech' error. Server did not offer a supported authentication mechanism
+- #306 Fix websocket close handler exception and reporting
## Version 1.2.15 - (2018-05-21)
-* #259 XML element should be sent to xmlOutput
-* #266 Support Browserify/CommonJS. `require('strophe.js/src/wrapper')`
-* #296 Remove error handler from old websocket before closing
-* #271 SASL X-OAUTH2 authentication mechanism implemented
-* #288 Strophe now logs fatal errors by default.
-* Run tests with headless Chromium instead of Phantomjs
+
+- #259 XML element should be sent to xmlOutput
+- #266 Support Browserify/CommonJS. `require('strophe.js/src/wrapper')`
+- #296 Remove error handler from old websocket before closing
+- #271 SASL X-OAUTH2 authentication mechanism implemented
+- #288 Strophe now logs fatal errors by default.
+- Run tests with headless Chromium instead of Phantomjs
## Version 1.2.14 - 2017-06-15
-* #231 SASL OAuth Bearer authentication should not require a JID node, when a user identifer
+
+- #231 SASL OAuth Bearer authentication should not require a JID node, when a user identifer
can be retreived from the bearer token.
-* #250 Show XHR error message
-* #254 Set connection status to CONNFAIL after max retries
-* #255 Set CONNFAIL error status when connection fails on Safari 10
+- #250 Show XHR error message
+- #254 Set connection status to CONNFAIL after max retries
+- #255 Set CONNFAIL error status when connection fails on Safari 10
## Version 1.2.13 - 2017-02-25
-* Use almond to create the build. This means that the build itself is an AMD
+- Use almond to create the build. This means that the build itself is an AMD
module and can be loaded via `require`.
-* Remove Grunt as a build tool.
+- Remove Grunt as a build tool.
## Version 1.2.12 - 2017-01-15
-* Reduce the priority of the SASL-EXTERNAL auth mechanism. OpenFire 4.1.1
+- Reduce the priority of the SASL-EXTERNAL auth mechanism. OpenFire 4.1.1
advertises support for SASL-EXTERNAL and the vast majority of Strophe.js
installs are not set up to support SASL-EXTERNAl, causing them to fail
logging users in.
## Version 1.2.11 - 2016-12-13
-* 189 Strophe never reaches DISCONNECTED status after .connect(..) and
+
+- 189 Strophe never reaches DISCONNECTED status after .connect(..) and
.disconnect(..) calls while offline.
-* Add `sendPresence` method, similar to `sendIQ`, i.e. for cases where you expect a
+- Add `sendPresence` method, similar to `sendIQ`, i.e. for cases where you expect a
responding presence (e.g. when leaving a MUC room).
## Version 1.2.10 - 2016-11-30
-* #172 and #215: Strophe shouldn't require `from` attribute in iq response
-* #216 Get inactivity attribute from session creation response
-* Enable session restoration for anonymous logins
+
+- #172 and #215: Strophe shouldn't require `from` attribute in iq response
+- #216 Get inactivity attribute from session creation response
+- Enable session restoration for anonymous logins
## Version 1.2.9 - 2016-10-24
-* Allow SASL mechanisms to be supported to be passed in as option to `Strophe.Connection` constructor.
-* Add new matching option to `Strophe.Handler`, namely `ignoreNamespaceFragment`.
-* The `matchBare` matching option for `Strophe.Handler` has been renamed to
+
+- Allow SASL mechanisms to be supported to be passed in as option to `Strophe.Connection` constructor.
+- Add new matching option to `Strophe.Handler`, namely `ignoreNamespaceFragment`.
+- The `matchBare` matching option for `Strophe.Handler` has been renamed to
`matchBareFromJid`. The old name will still work in this release but is
deprecated and will be removed in a future release.
-* #114 Add an error handler for HTTP calls
-* #213 "XHR open failed." in BOSH in IE9
-* #214 Add function to move Strophe.Builder pointer back to the root node
-* #172, #215 Don't compare `to` and `to` values of sent and received IQ stanzas
+- #114 Add an error handler for HTTP calls
+- #213 "XHR open failed." in BOSH in IE9
+- #214 Add function to move Strophe.Builder pointer back to the root node
+- #172, #215 Don't compare `to` and `to` values of sent and received IQ stanzas
to determine correctness (we rely on UUIDs for that).
## Version 1.2.8 - 2016-09-16
-* #200 Fix for webpack
-* #203 Allow custom Content-Type header for requests
-* #206 XML stanza attributes: there is no 'quot' escape inside 'serialize' method
-* The files in `./src` are now also included in the NPM distribution.
-* Add support for SASL-EXTERNAL
+
+- #200 Fix for webpack
+- #203 Allow custom Content-Type header for requests
+- #206 XML stanza attributes: there is no 'quot' escape inside 'serialize' method
+- The files in `./src` are now also included in the NPM distribution.
+- Add support for SASL-EXTERNAL
## Version 1.2.7 - 2016-06-17
-* #193 Move phantomjs dependencies to devDependencies
+
+- #193 Move phantomjs dependencies to devDependencies
## Version 1.2.6 - 2016-06-06
-* #178 Added new value (CONNTIMEOUT) to Strophe.Status
-* #180 bosh: check sessionStorage support before using it
-* #182 Adding SASL OAuth Bearer authentication
-* #190 Fix .c() to accept both text and numbers as text for the child element
-* #192 User requirejs instead of require for node compat
+
+- #178 Added new value (CONNTIMEOUT) to Strophe.Status
+- #180 bosh: check sessionStorage support before using it
+- #182 Adding SASL OAuth Bearer authentication
+- #190 Fix .c() to accept both text and numbers as text for the child element
+- #192 User requirejs instead of require for node compat
## Version 1.2.5 - 2016-02-09
-* Add a new Strophe.Connection option to add cookies
-* Add new Strophe.Connection option "withCredentials"
+
+- Add a new Strophe.Connection option to add cookies
+- Add new Strophe.Connection option "withCredentials"
## Version 1.2.4 - 2016-01-28
-* #147 Support for UTF-16 encoded usernames (e.g. Chinese)
-* #162 allow empty expectedFrom according to W3C DOM 3 Specification
-* #171 Improve invalid BOSH URL handling
+
+- #147 Support for UTF-16 encoded usernames (e.g. Chinese)
+- #162 allow empty expectedFrom according to W3C DOM 3 Specification
+- #171 Improve invalid BOSH URL handling
## Version 1.2.3 - 2015-09-01
-* Bugfix. Check if JID is null when restoring a session.
-* #127 IE-Fix: error on setting null value with setAttributes
-* #138 New stub method nextValidRid
-* #144 Change ID generator to generate UUIDs
+
+- Bugfix. Check if JID is null when restoring a session.
+- #127 IE-Fix: error on setting null value with setAttributes
+- #138 New stub method nextValidRid
+- #144 Change ID generator to generate UUIDs
## Version 1.2.2 - 2015-06-20
-* #109 Explicitly define AMD modules to prevent errors with AlmondJS and AngularJS.
-* #111 Fixed IE9 compatibility.
-* #113 Permit connecting with an alternative authcid.
-* #116 tree.attrs() now removes Elements when they are set to undefined
-* #119 Provide the 'keepalive' option to keep a BOSH session alive across page loads.
-* #121 Ensure that the node names of HTML elements copied into XHTML are lower case.
-* #124 Strophe's Builder will swallow elements if given a blank string as a 'text' parameter.
+
+- #109 Explicitly define AMD modules to prevent errors with AlmondJS and AngularJS.
+- #111 Fixed IE9 compatibility.
+- #113 Permit connecting with an alternative authcid.
+- #116 tree.attrs() now removes Elements when they are set to undefined
+- #119 Provide the 'keepalive' option to keep a BOSH session alive across page loads.
+- #121 Ensure that the node names of HTML elements copied into XHTML are lower case.
+- #124 Strophe's Builder will swallow elements if given a blank string as a 'text' parameter.
## Version 1.2.1 - 2015-02-22
-* Rerelease of 1.2.0 but with a semver tag and proper formatting of bower.json
+
+- Rerelease of 1.2.0 but with a semver tag and proper formatting of bower.json
for usage with Bower.io.
## Version 1.2.0 - 2015-02-21
-* Add bower package manager support.
-* Add commandline testing support via qunit-phantomjs-runner
-* Add integrated testing via TravisCI.
-* Fix Websocket connections now use the current XMPP-over-WebSockets RFC
-* #25 Item-not-found-error caused by long term request.
-* #29 Add support for the Asynchronous Module Definition (AMD) and require.js
-* #30 Base64 encoding problem in some older browsers.
-* #45 Move xhlr plugin to strophejs-plugins repo.
-* #60 Fixed deletion of handlers in websocket connections
-* #62 Add `xmlunescape` method.
-* #67 Use correct Content-Type in BOSH
-* #70 `_onDisconnectTimeout` never tiggers because maxRetries is undefined.
-* #71 switched to case sensitive handling of XML elements
-* #73 `getElementsByTagName` problem with namespaced elements.
-* #76 respect "Invalid SID" message
-* #79 connect.pause work correctly again
-* #90 The queue data was not reset in .reset() method.
-* #104 Websocket connections with MongooseIM work now
+
+- Add bower package manager support.
+- Add commandline testing support via qunit-phantomjs-runner
+- Add integrated testing via TravisCI.
+- Fix Websocket connections now use the current XMPP-over-WebSockets RFC
+- #25 Item-not-found-error caused by long term request.
+- #29 Add support for the Asynchronous Module Definition (AMD) and require.js
+- #30 Base64 encoding problem in some older browsers.
+- #45 Move xhlr plugin to strophejs-plugins repo.
+- #60 Fixed deletion of handlers in websocket connections
+- #62 Add `xmlunescape` method.
+- #67 Use correct Content-Type in BOSH
+- #70 `_onDisconnectTimeout` never tiggers because maxRetries is undefined.
+- #71 switched to case sensitive handling of XML elements
+- #73 `getElementsByTagName` problem with namespaced elements.
+- #76 respect "Invalid SID" message
+- #79 connect.pause work correctly again
+- #90 The queue data was not reset in .reset() method.
+- #104 Websocket connections with MongooseIM work now
## Version 1.1.3 - 2014-01-20
-* Fix SCRAM-SHA1 auth now works for multiple connections at the same time
-* Fix Connecting to a different server with the same connection after disconnect
-* Add Gruntfile so StropheJS can now also be built using grunt
-* Fix change in sha1.js that broke the caps plugin
-* Fix all warnings from jshint.
+
+- Fix SCRAM-SHA1 auth now works for multiple connections at the same time
+- Fix Connecting to a different server with the same connection after disconnect
+- Add Gruntfile so StropheJS can now also be built using grunt
+- Fix change in sha1.js that broke the caps plugin
+- Fix all warnings from jshint.
## Version 1.1.2 - 2014-01-04
-* Add option for synchronous BOSH connections
-* moved bower.json to other repository
-* Remove unused code in sha1 and md5 modules
+
+- Add option for synchronous BOSH connections
+- moved bower.json to other repository
+- Remove unused code in sha1 and md5 modules
## Version 1.1.1 - 2013-12-16
-* Fix BOSH attach is working again
+
+- Fix BOSH attach is working again
## Version 1.1.0 - 2013-12-11
-* Add Support for XMPP-over-WebSocket
-* Authentication mechanisms are now modular and can be toggled
-* Transport protocols are now modular and will be chosen based on the
+
+- Add Support for XMPP-over-WebSocket
+- Authentication mechanisms are now modular and can be toggled
+- Transport protocols are now modular and will be chosen based on the
connection URL. Currently supported protocols are BOSH and WebSocket
-* Add Strings to some disconnects that indicate the reason
-* Add option to strip
tags before passing to xmlInput/xmlOutput
-* Fix Connection status staying at CONNFAIL or CONNECTING in certain
+- Add Strings to some disconnects that indicate the reason
+- Add option to strip tags before passing to xmlInput/xmlOutput
+- Fix Connection status staying at CONNFAIL or CONNECTING in certain
error scenarios
-* Add package.json for use with npm
-* Add bower.json for use with bower
-* Fix handlers not being removed after disconnect
-* Fix legacy non-sasl authentication
-* Add better tests for BOSH bind
-* Fix use of deprecated functions in tests
-* Remove some dead code
-* Remove deprecated documentation
-* Fix Memory leak in IE9
-* Add An options object can be passed to a Connection constructor now
-* Add "Route" Parameter for BOSH Connections
-* Add Maximum number of connection attempts before disconnecting
-* Add conflict condition for AUTHFAIL
-* Add XHTML message support
-* Fix parsing chat messages in IE
-* Add SCRAM-SHA-1 SASL mechanism
-* Fix escaping of messages
+- Add package.json for use with npm
+- Add bower.json for use with bower
+- Fix handlers not being removed after disconnect
+- Fix legacy non-sasl authentication
+- Add better tests for BOSH bind
+- Fix use of deprecated functions in tests
+- Remove some dead code
+- Remove deprecated documentation
+- Fix Memory leak in IE9
+- Add An options object can be passed to a Connection constructor now
+- Add "Route" Parameter for BOSH Connections
+- Add Maximum number of connection attempts before disconnecting
+- Add conflict condition for AUTHFAIL
+- Add XHTML message support
+- Fix parsing chat messages in IE
+- Add SCRAM-SHA-1 SASL mechanism
+- Fix escaping of messages
## Version 1.0.2 - 2011-06-19
-* Fix security bug where DIGEST-MD5 client nonce was not properly randomized.
-* Fix double escaping in copyElement.
-* Fix IE errors related to importNode.
-* Add ability to pass text into Builder.c().
-* Improve performance by skipping debugging callbacks when not
+- Fix security bug where DIGEST-MD5 client nonce was not properly randomized.
+- Fix double escaping in copyElement.
+- Fix IE errors related to importNode.
+- Add ability to pass text into Builder.c().
+- Improve performance by skipping debugging callbacks when not
overridden.
-* Wrap handler runs in try/catch so they don't affect or remove later
+- Wrap handler runs in try/catch so they don't affect or remove later
handlers.
-* Add ' and " to escaped characters and other escaping fixes.
-* Fix _throttledRequestHandler to use proper window size.
-* Fix timed handler management.
-* Fix flXHR plugin to better deal with errors.
-* Fix bind() to be ECMAScript 5 compatible.
-* Use bosh.metajack.im in examples so they work out of the box.
-* Add simple XHR tests.
-* Update basic example to HTML5.
-* Move community plugins to their own repository.
-* Fix bug causing infinite retries.
-* Fix 5xx error handling.
-* Store stream:features for later use by plugins.
-* Fix to prevent passing stanzas during disconnect.
-* Fix handling of disconnect responses.
-* Fix getBareJidFromJid to return null on error.
-* Fix equality testing in matchers so that string literals and string
+- Add ' and " to escaped characters and other escaping fixes.
+- Fix \_throttledRequestHandler to use proper window size.
+- Fix timed handler management.
+- Fix flXHR plugin to better deal with errors.
+- Fix bind() to be ECMAScript 5 compatible.
+- Use bosh.metajack.im in examples so they work out of the box.
+- Add simple XHR tests.
+- Update basic example to HTML5.
+- Move community plugins to their own repository.
+- Fix bug causing infinite retries.
+- Fix 5xx error handling.
+- Store stream:features for later use by plugins.
+- Fix to prevent passing stanzas during disconnect.
+- Fix handling of disconnect responses.
+- Fix getBareJidFromJid to return null on error.
+- Fix equality testing in matchers so that string literals and string
objects both match.
-* Fix bare matching on missing from attributes.
-* Remove use of reserved word self.
-* Fix various documentation errors.
+- Fix bare matching on missing from attributes.
+- Remove use of reserved word self.
+- Fix various documentation errors.
## Version 1.0.1 - 2010-01-27
-* Fix handling of window, hold, and wait attributes. Bug #75.
+- Fix handling of window, hold, and wait attributes. Bug #75.
## Version 1.0 - 2010-01-01
-* First release.
+- First release.
diff --git a/README.md b/README.md
index d769b0be..d7a95b2d 100644
--- a/README.md
+++ b/README.md
@@ -12,10 +12,51 @@ XMPP applications.
## Quick Links
-* [Homepage](https://strophe.im/strophejs/)
-* [Documentation](https://strophe.im/strophejs)
-* [Mailing list](https://groups.google.com/g/strophe)
-* [Community Plugins](https://github.com/strophe/strophejs-plugins)
+- [Homepage](https://strophe.im/strophejs/)
+- [Documentation](https://strophe.im/strophejs)
+- [Mailing list](https://groups.google.com/g/strophe)
+- [Community Plugins](https://github.com/strophe/strophejs-plugins)
+
+## Stream Management (XEP-0198)
+
+Since version 4.1.0, Strophe.js natively supports
+[XEP-0198 Stream Management](https://xmpp.org/extensions/xep-0198.html) on websocket
+connections: sent stanzas are acknowledged by the server, and a dropped connection can be
+resumed without losing them.
+
+It is off by default; opt in when creating the connection:
+
+```javascript
+const conn = new Strophe.Connection(service, {
+ enableStreamManagement: true,
+ // Optional fine-tuning:
+ streamManagement: {
+ maxUnacked: 5, // request an ack every N sent stanzas
+ requestResume: true, // ask the server for a resumable session
+ },
+});
+```
+
+After connecting, `conn.hasResumed()` tells you whether the previous session was resumed
+(skip re-fetching the roster, re-joining rooms etc.) or a fresh session was established.
+Resumable state is kept in `sessionStorage` by default; pass a custom
+`streamManagement.storage` backend to change that.
+
+### Sharing a connection between tabs
+
+With the `worker` connection option, all tabs of your application share a single websocket
+connection through a SharedWorker. Point it at `dist/shared-connection-worker.js`.
+
+One tab is assigned the `primary` role and drives the connection; the others attach to it
+as `secondary` and are promoted automatically if the primary tab goes away (see
+`Connection.onRoleChanged`). When Stream Management is enabled, the XEP-0198 engine runs
+inside the worker itself, so a single SM session covers all tabs and a stanza sent from
+any tab survives resumption.
+
+Messages and presences sent from one tab are reflected to all the other tabs, so every tab
+can render what any other tab sent (override `Connection.onForeignStanzaSent` to receive them).
+They are deliberately kept out of the regular stanza handlers, which only see _received_
+traffic.
## Support in different environments
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 45e75322..890071ec 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -264,7 +264,7 @@ export default [...compat.extends("eslint:recommended"), {
"valid-jsdoc": "off",
"vars-on-top": "off",
"wrap-iife": ["error", "any"],
- "wrap-regex": "error",
+ "wrap-regex": "off",
"yield-star-spacing": "error",
yoda: "off",
},
diff --git a/package.json b/package.json
index ed4b65b4..205b6d21 100644
--- a/package.json
+++ b/package.json
@@ -62,11 +62,11 @@
"scripts": {
"types": "tsc",
"build": "tsc && rollup -c",
- "lint": "eslint src/*.ts",
+ "lint": "eslint src",
"clean": "make clean",
"doc": "make doc",
"test": "npm run build && vitest run && npm run lint",
- "prettier": "prettier --write src/*.ts"
+ "prettier": "prettier --write \"src/**/*.ts\""
},
"volo": {
"url": "https://raw.githubusercontent.com/strophe/strophejs/release-{version}/strophe.js"
diff --git a/rollup.config.js b/rollup.config.js
index 17bc2e8d..0eceb175 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -110,4 +110,17 @@ export default [
},
plugins: [typescript(tsConfig)],
},
+ // Shared-connection worker: a self-contained classic (non-module) worker
+ // script; point the Connection `worker` option at this file's URL.
+ {
+ input: 'src/shared-connection-worker.ts',
+ output: {
+ name: 'StropheSharedConnectionWorker',
+ file: 'dist/shared-connection-worker.js',
+ format: 'iife',
+ exports: 'named',
+ sourcemap: true,
+ },
+ plugins: [typescript(tsConfig)],
+ },
];
diff --git a/src/connection.ts b/src/connection.ts
index 71306cc8..e5639104 100644
--- a/src/connection.ts
+++ b/src/connection.ts
@@ -23,12 +23,21 @@ import {
getResourceFromJid,
getText,
handleError,
+ toElement,
type Cookies,
} from './utils';
import { SessionError } from './errors';
import Bosh from './bosh';
import WorkerWebsocket from './worker-websocket';
import Websocket from './websocket';
+import StreamManagement, {
+ SessionStorageBackend,
+ StreamManagementMirror,
+ isCountableStanza,
+ toStanzaView,
+ type StreamManagementController,
+ type StreamManagementOptions,
+} from './stream-management';
export interface ConnectionOptions {
/**
@@ -85,6 +94,21 @@ export interface ConnectionOptions {
* session, before binding the JID resource for this session.
*/
explicitResourceBinding?: boolean;
+ /**
+ * If `enableStreamManagement` is set to `true`, Strophe negotiates
+ * XEP-0198 Stream Management on websocket connections.
+ *
+ * Defaults to `false`. Not available over BOSH. In combination with the
+ * `worker` option, the SM session lives inside the shared worker (which
+ * sees every tab's traffic) and the connection only mirrors its state.
+ */
+ enableStreamManagement?: boolean;
+ /**
+ * Fine-tuning options for XEP-0198 Stream Management — see
+ * {@link StreamManagementOptions}. Only relevant together with
+ * {@link ConnectionOptions.enableStreamManagement}.
+ */
+ streamManagement?: StreamManagementOptions;
/**
* _Note: This option is only relevant to Websocket connections, and not BOSH_
*
@@ -115,7 +139,7 @@ export interface ConnectionOptions {
* This allows you to share a single websocket-based connection between
* multiple Connection instances, for example one per browser tab.
*
- * The script to use is the one in `src/shared-connection-worker.js`.
+ * The script to use is `dist/shared-connection-worker.js`.
*/
worker?: string;
/**
@@ -188,7 +212,7 @@ type ProtocolErrorHandlers = {
websocket: Record;
};
-type ConnectCallback = (status: number, condition: string | null, elem?: Element) => void;
+export type ConnectCallback = (status: number, condition: string | null, elem?: Element) => void;
/**
* _Private_ variable Used to store plugin names that need
@@ -223,6 +247,7 @@ const connectionPlugins: Record = {};
*
* @memberof Strophe
*/
+
class Connection {
service: string;
options: ConnectionOptions;
@@ -248,6 +273,12 @@ class Connection {
do_authentication: boolean;
paused: boolean;
restored: boolean;
+ /**
+ * This connection's role in a shared-worker setup: the 'primary' tab
+ * drives the stream, 'secondary' tabs share it. undefined outside worker
+ * mode. Assigned by the worker (see {@link Connection#onRoleChanged}).
+ */
+ role?: 'primary' | 'secondary';
_data: (Element | 'restart')[];
_uniqueId: number;
_sasl_success_handler: Handler | null;
@@ -264,6 +295,17 @@ class Connection {
_proto: Bosh | Websocket | WorkerWebsocket;
_sasl_mechanism: SASLMechanism | null;
_requests: Request[];
+ /**
+ * The XEP-0198 Stream Management engine. Only present when the
+ * `enableStreamManagement` option is set on a websocket connection.
+ * Under the `worker` option this is a {@link StreamManagementMirror}:
+ * the engine itself lives inside the shared worker and the mirror only
+ * reflects the session state. Typed against the shared
+ * {@link StreamManagementController} interface so the two are
+ * interchangeable here.
+ */
+ sm?: StreamManagementController;
+ _smHandlers: Handler[];
/**
* Create and initialize a {@link Connection} object.
@@ -289,6 +331,33 @@ class Connection {
this.setProtocol();
+ this._smHandlers = [];
+ if (options.enableStreamManagement) {
+ if (options.worker) {
+ // Under a shared worker the page hosts no SM engine since
+ // the worker owns all counting and queueing. The mirror
+ // only reflects the session state so that hasResumed()
+ // and friends keep working in every tab.
+ this.sm = new StreamManagementMirror();
+ } else {
+ // The engine is constructed regardless of the current
+ // transport since embedders may swap `_proto` after construction.
+ const smOptions = { ...(options.streamManagement || {}) };
+ if (!smOptions.storage && typeof sessionStorage !== 'undefined') {
+ smOptions.storage = new SessionStorageBackend();
+ }
+
+ // The SM engine emits nonzas (and re-sends queued stanzas) as
+ // strings. They are pushed directly onto the send queue and
+ // they ride the same FIFO, so an goes out after the
+ // stanzas it covers.
+ this.sm = new StreamManagement((data: string) => {
+ this._data.push(toElement(data));
+ this._proto._send();
+ }, smOptions);
+ }
+ }
+
/* The connected JID. */
this.jid = '';
/* the JIDs domain */
@@ -356,7 +425,7 @@ class Connection {
this.send(
$iq({ type: 'error', id: iq.getAttribute('id') })
.c('error', { 'type': 'cancel' })
- .c('service-unavailable', { 'xmlns': NS.STANZAS })
+ .c('service-unavailable', { 'xmlns': NS.STANZAS }),
);
return false;
},
@@ -364,7 +433,7 @@ class Connection {
null,
['get', 'set'],
null,
- null
+ null,
);
// initialize plugins
@@ -415,6 +484,9 @@ class Connection {
*/
reset(): void {
this._proto._reset();
+ // In-memory SM state only. Persisted (resumable) state is kept and
+ // reloaded when the next stream advertises SM support.
+ this.sm?.reset();
// SASL
this.do_session = false;
@@ -437,6 +509,24 @@ class Connection {
this._uniqueId = 0;
}
+ /**
+ * @returns true if the current session was established by resuming a
+ * previous one via XEP-0198 Stream Management (in which case the
+ * previously bound resource is still valid and roster/presence
+ * state was retained by the server).
+ */
+ hasResumed(): boolean {
+ return !!this.sm?.resumed;
+ }
+
+ /**
+ * @returns true if a XEP-0198 Stream Management session is currently
+ * active (i.e. was received or a session was resumed).
+ */
+ isStreamManagementEnabled(): boolean {
+ return !!this.sm?.enabled;
+ }
+
/**
* Pause the request manager.
*
@@ -574,7 +664,7 @@ class Connection {
hold?: number,
route?: string,
authcid?: string,
- disconnection_timeout = 3000
+ disconnection_timeout = 3000,
): void {
this.jid = jid;
/** Authorization identity */
@@ -596,6 +686,9 @@ class Connection {
this.authenticated = false;
this.restored = false;
this.disconnection_timeout = disconnection_timeout;
+ // Per-stream SM state starts fresh; resumable state is reloaded from
+ // storage once the server advertises SM (_onStreamFeaturesAfterSASL).
+ this.sm?.reset();
// parse jid for domain
this.domain = getDomainFromJid(this.jid);
@@ -629,13 +722,13 @@ class Connection {
* allowed range of request ids that are valid. The default is 5.
*/
attach(
- jid: string | (() => void),
+ jid: string | ConnectCallback,
sid?: string,
rid?: number,
callback?: ConnectCallback,
wait?: number,
hold?: number,
- wind?: number
+ wind?: number,
): void {
if (this._proto instanceof Bosh && typeof jid === 'string') {
return this._proto._attach(jid, sid, rid, callback, wait, hold, wind);
@@ -706,11 +799,6 @@ class Connection {
* User overrideable function that receives XML data coming into the
* connection.
*
- * The default function does nothing. User code can override this with
- * > Connection.xmlInput = function (elem) {
- * > (user code)
- * > };
- *
* Due to limitations of current Browsers' XML-Parsers the opening and closing
* tag for WebSocket-Connoctions will be passed as selfclosing here.
*
@@ -727,11 +815,6 @@ class Connection {
* User overrideable function that receives XML data sent to the
* connection.
*
- * The default function does nothing. User code can override this with
- * > Connection.xmlOutput = function (elem) {
- * > (user code)
- * > };
- *
* Due to limitations of current Browsers' XML-Parsers the opening and closing
* tag for WebSocket-Connoctions will be passed as selfclosing here.
*
@@ -748,11 +831,6 @@ class Connection {
* User overrideable function that receives raw data coming into the
* connection.
*
- * The default function does nothing. User code can override this with
- * > Connection.rawInput = function (data) {
- * > (user code)
- * > };
- *
* @param _data - The data received by the connection.
*/
rawInput(_data: string): void {
@@ -763,11 +841,6 @@ class Connection {
* User overrideable function that receives raw data sent to the
* connection.
*
- * The default function does nothing. User code can override this with
- * > Connection.rawOutput = function (data) {
- * > (user code)
- * > };
- *
* @param _data - The data sent by the connection.
*/
rawOutput(_data: string): void {
@@ -777,17 +850,42 @@ class Connection {
/**
* User overrideable function that receives the new valid rid.
*
- * The default function does nothing. User code can override this with
- * > Connection.nextValidRid = function (rid) {
- * > (user code)
- * > };
- *
* @param _rid - The next valid rid
*/
nextValidRid(_rid: number): void {
return;
}
+ /**
+ * User overrideable function that receives the new role of this
+ * connection in a shared-worker setup.
+ *
+ * Called when the shared worker assigns or changes this tab's role,
+ * for example when this tab is promoted to 'primary' after the previous
+ * primary tab went away.
+ *
+ * @param _role - The new role ('primary' or 'secondary')
+ */
+ onRoleChanged(_role: 'primary' | 'secondary'): void {
+ return;
+ }
+
+ /**
+ * User overrideable function that receives message and presence stanzas
+ * sent by *another* tab sharing this connection (via the `worker`
+ * option), so every tab can render what any tab sent.
+ *
+ * Deliberately separate from the inbound handler pipeline: these stanzas
+ * were sent, not received, so they must not trigger stanza handlers.
+ * IQs are not reflected — they are request/response traffic private to
+ * the sending tab.
+ *
+ * @param _elem - The sent stanza.
+ */
+ onForeignStanzaSent(_elem: Element): void {
+ return;
+ }
+
/**
* Send a stanza.
*
@@ -841,7 +939,7 @@ class Connection {
stanza: Element | Builder,
callback?: (stanza: Element) => void,
errback?: (stanza: Element | null) => void,
- timeout?: number
+ timeout?: number,
): string {
let timeoutHandler: TimedHandler | null = null;
@@ -870,7 +968,7 @@ class Connection {
null,
'presence',
null,
- id
+ id,
);
// if timeout specified, set up a timeout handler.
@@ -903,7 +1001,7 @@ class Connection {
stanza: Element | Builder,
callback?: (stanza: Element) => void,
errback?: (stanza: Element | null) => void,
- timeout?: number
+ timeout?: number,
): string {
let timeoutHandler: TimedHandler | null = null;
@@ -937,7 +1035,7 @@ class Connection {
null,
'iq',
['error', 'result'],
- id
+ id,
);
// if timeout specified, set up a timeout handler.
@@ -968,6 +1066,13 @@ class Connection {
throw error;
}
this._data.push(element);
+ // XEP-0198: every countable outbound stanza is queued here, after the
+ // push, so that an emitted by the engine lands behind it in the
+ // send FIFO. Hooking _queueData (rather than send/sendIQ/sendPresence)
+ // means raw send() calls can't escape the counting.
+ if (this.sm?.isTracking() && isCountableStanza(element.tagName)) {
+ this.sm.trackOutbound(toStanzaView(element));
+ }
}
/**
@@ -1088,7 +1193,7 @@ class Connection {
type: string | string[] | null,
id?: string | null,
from?: string | null,
- options?: HandlerOptions
+ options?: HandlerOptions,
): Handler {
const hand = new Handler(handler, ns, name, type, id, from, options);
this.addHandlers.push(hand);
@@ -1178,10 +1283,14 @@ class Connection {
'type': 'unavailable',
}).tree();
}
+ // A cleanly closed stream is not resumable: send a final so
+ // the server doesn't redeliver stanzas we already received, and
+ // drop the persisted XEP-0198 state.
+ this.sm?.onGracefulClose();
// setup timeout handler
this._disconnectTimeout = this._addSysTimedHandler(
this.disconnection_timeout,
- this._onDisconnectTimeout.bind(this)
+ this._onDisconnectTimeout.bind(this),
);
this._proto._disconnect(pres);
} else {
@@ -1274,9 +1383,7 @@ class Connection {
* @param raw - The stanza as raw string.
*/
_dataRecv(req: Element | Request, raw?: string): void {
- const elem = (
- '_reqToData' in this._proto ? this._proto._reqToData(req as Request) : req
- ) as Element | null;
+ const elem = ('_reqToData' in this._proto ? this._proto._reqToData(req as Request) : req) as Element | null;
if (elem === null) {
return;
}
@@ -1338,36 +1445,34 @@ class Connection {
}
// send each incoming stanza through the handler chain
- forEachChild(
- elem,
- null,
- (child: Element) => {
- const matches: Handler[] = [];
- this.handlers = this.handlers.reduce((handlers, handler) => {
- try {
- if (handler.isMatch(child) && (this.authenticated || !handler.user)) {
- if (handler.run(child)) {
- handlers.push(handler);
- }
- matches.push(handler);
- } else {
+ forEachChild(elem, null, (child: Element) => {
+ // XEP-0198: count inbound stanzas here in the dispatch loop —
+ // exactly-once, in order, and immune to handler churn.
+ this.sm?.onInboundStanza(child.nodeName);
+ const matches: Handler[] = [];
+ this.handlers = this.handlers.reduce((handlers, handler) => {
+ try {
+ if (handler.isMatch(child) && (this.authenticated || !handler.user)) {
+ if (handler.run(child)) {
handlers.push(handler);
}
- } catch (e) {
- // if the handler throws an exception, we consider it as false
- log.warn('Removing Strophe handlers due to uncaught exception: ' + e.message);
+ matches.push(handler);
+ } else {
+ handlers.push(handler);
}
-
- return handlers;
- }, []);
-
- // If no handler was fired for an incoming IQ with type="set",
- // then we return an IQ error stanza with service-unavailable.
- if (!matches.length && this.iqFallbackHandler.isMatch(child)) {
- this.iqFallbackHandler.run(child);
+ } catch (e) {
+ // if the handler throws an exception, we consider it as false
+ log.warn('Removing Strophe handlers due to uncaught exception: ' + e.message);
}
+ return handlers;
+ }, []);
+
+ // If no handler was fired for an incoming IQ with type="set",
+ // then we return an IQ error stanza with service-unavailable.
+ if (!matches.length && this.iqFallbackHandler.isMatch(child)) {
+ this.iqFallbackHandler.run(child);
}
- );
+ });
}
/**
@@ -1392,9 +1497,7 @@ class Connection {
let bodyWrap: Element | null;
try {
- bodyWrap = (
- '_reqToData' in this._proto ? this._proto._reqToData(req as Request) : req
- ) as Element | null;
+ bodyWrap = ('_reqToData' in this._proto ? this._proto._reqToData(req as Request) : req) as Element | null;
} catch (e) {
if (e.name !== ErrorCondition.BAD_FORMAT) {
throw e;
@@ -1518,21 +1621,21 @@ class Connection {
null,
'success',
null,
- null
+ null,
);
this._sasl_failure_handler = this._addSysHandler(
this._sasl_failure_cb.bind(this),
null,
'failure',
null,
- null
+ null,
);
this._sasl_challenge_handler = this._addSysHandler(
this._sasl_challenge_cb.bind(this),
null,
'challenge',
null,
- null
+ null,
);
this._sasl_mechanism = mechanisms[i];
@@ -1590,7 +1693,7 @@ class Connection {
.c('query', { xmlns: NS.AUTH })
.c('username', {})
.t(getNodeFromJid(this.jid))
- .tree()
+ .tree(),
);
}
}
@@ -1689,8 +1792,8 @@ class Connection {
null,
'stream:features',
null,
- null
- )
+ null,
+ ),
);
streamfeature_handlers.push(
@@ -1699,8 +1802,8 @@ class Connection {
NS.STREAM,
'features',
null,
- null
- )
+ null,
+ ),
);
// we must send an xmpp:restart now
@@ -1724,17 +1827,146 @@ class Connection {
if (child.nodeName === 'session') {
this.do_session = true;
}
+ if (this.sm && child.nodeName === 'sm' && (child as Element).namespaceURI === NS.SM) {
+ this.sm.serverSupported = true;
+ }
+ }
+
+ if (this.sm?.serverSupported) {
+ if (this.options.worker) {
+ // Only the worker knows whether resumable state exists: it
+ // either sends itself (answering _smResumed or
+ // _smFailed) or replies _smNoState, upon which the
+ // connection proceeds to bind (see WorkerWebsocket).
+ (this._proto as WorkerWebsocket)._smFeatures();
+ return false;
+ }
+
+ // SM is only negotiated over websocket. Checked here on the
+ // live transport because the transport can be swapped after
+ // construction (e.g. XEP-0156 discovery).
+ if (this._proto instanceof Websocket) {
+ this.sm.initialize(this.jid);
+ if (this.sm.hasResumableState()) {
+ // Attempt XEP-0198 stream resumption instead of binding a resource.
+ this._registerSMHandlers();
+ this.sm.sendResume();
+ return false;
+ }
+ }
}
+ this._proceedToBind();
+ return false;
+ }
+
+ /**
+ * Continue the connect flow with resource binding, once it is clear no
+ * XEP-0198 resumption will happen (no SM support, no resumable state, a
+ * failed , or the shared worker reporting _smNoState).
+ */
+ _proceedToBind(): void {
if (!this.do_bind) {
this._changeConnectStatus(Status.AUTHFAIL, null);
- return false;
} else if (!this.options.explicitResourceBinding) {
this.bind();
} else {
this._changeConnectStatus(Status.BINDREQUIRED, null);
}
- return false;
+ }
+
+ /**
+ * Register the XEP-0198 nonza handlers (idempotently)
+ * They are system handlers, so they run before authentication completes,
+ * which the resume flow requires.
+ */
+ _registerSMHandlers(): void {
+ this._smHandlers.forEach((h) => this.deleteHandler(h));
+ const delegate = (el: Element): boolean => {
+ this.sm.onInbound(toStanzaView(el));
+ return true;
+ };
+ this._smHandlers = [
+ this._addSysHandler(delegate, NS.SM, 'r', null, null),
+ this._addSysHandler(delegate, NS.SM, 'a', null, null),
+ this._addSysHandler(delegate, NS.SM, 'enabled', null, null),
+ this._addSysHandler((el) => this._onStreamResumed(el), NS.SM, 'resumed', null, null),
+ this._addSysHandler((el) => this._onStreamResumptionFailed(el), NS.SM, 'failed', null, null),
+ ];
+ }
+
+ /**
+ * _Private_ handler for a successful XEP-0198 stream resumption.
+ *
+ * The engine reconciles the server's 'h' and re-sends whatever the
+ * server didn't acknowledge.
+ *
+ * The connection state is restored, `this.jid` is set back to the JID that
+ * was bound when the SM session was established, *before* CONNECTED is emitted,
+ * so no stanza can ever be stamped with a resource the server doesn't know.
+ * @param elem - The nonza.
+ * @return `true` to keep the handler.
+ */
+ _onStreamResumed(elem: Element): boolean {
+ this.sm.onInbound(toStanzaView(elem));
+ this.do_bind = false;
+ this.authenticated = true;
+ this.restored = true;
+ this.jid = this.sm.boundJid;
+ this._changeConnectStatus(Status.CONNECTED, null);
+ return true;
+ }
+
+ /**
+ * _Private_ handler for a failed or .
+ *
+ * The engine resets its state; after a failed *resumption* it also
+ * salvages the unacked queue (re-sent once a fresh session reaches
+ * ), and the connection falls back to binding a resource on
+ * this same stream, per XEP-0198 ("the server SHOULD allow the client
+ * to bind a resource at this point"). A refused needs neither:
+ * the stream is alive and bound, it just runs without SM.
+ * @param elem - The nonza.
+ * @return `true` to keep the handler.
+ */
+ _onStreamResumptionFailed(elem: Element): boolean {
+ // Read before feeding the nonza to the engine, which clears the flag.
+ const resuming = this.sm.resumePending;
+ this.sm.onInbound(toStanzaView(elem));
+ if (resuming) {
+ this.do_bind = true;
+ this._proceedToBind();
+ }
+ return true;
+ }
+
+ /**
+ * Called at the CONNECTED-emission points of the connect flow, just
+ * before CONNECTED is emitted i.e. once the full JID is final (resource
+ * bound, legacy session established, or legacy auth completed).
+ *
+ * Under the `worker` option the bound JID is always reported to the
+ * shared worker — with or without Stream Management — so it can hand the
+ * right JID to attaching tabs, release joins parked on the handshake,
+ * and (when this stream's features advertised SM) start a fresh SM
+ * session itself (it answers with _smEnabled, which updates the mirror).
+ *
+ * Otherwise, if the server supports XEP-0198 and nothing was resumed,
+ * a fresh SM session is started from here. The XEP allows any
+ * time after binding.
+ */
+ _onSessionReady(): void {
+ if (this.options.worker) {
+ (this._proto as WorkerWebsocket)._bound(this.jid);
+ return;
+ }
+ if (!this.sm?.serverSupported || this.sm.resumed || !(this._proto instanceof Websocket)) {
+ return;
+ }
+ if (!this.sm.isTracking()) {
+ this._registerSMHandlers();
+ this.sm.sendEnable(this.jid);
+ }
}
/**
@@ -1763,7 +1995,7 @@ class Connection {
.c('bind', { xmlns: NS.BIND })
.c('resource', {})
.t(resource)
- .tree()
+ .tree(),
);
} else {
this.send($iq({ type: 'set', id: '_bind_auth_2' }).c('bind', { xmlns: NS.BIND }).tree());
@@ -1797,6 +2029,7 @@ class Connection {
if (this.do_session) {
this._establishSession();
} else {
+ this._onSessionReady();
this._changeConnectStatus(Status.CONNECTED, null);
}
}
@@ -1820,7 +2053,7 @@ class Connection {
if (!this.do_session) {
throw new Error(
`Connection.prototype._establishSession ` +
- `called but apparently ${NS.SESSION} wasn't advertised by the server`
+ `called but apparently ${NS.SESSION} wasn't advertised by the server`,
);
}
this._addSysHandler(this._onSessionResultIQ.bind(this), null, null, null, '_session_auth_2');
@@ -1846,6 +2079,7 @@ class Connection {
_onSessionResultIQ(elem: Element): boolean {
if (elem.getAttribute('type') === 'result') {
this.authenticated = true;
+ this._onSessionReady();
this._changeConnectStatus(Status.CONNECTED, null);
} else if (elem.getAttribute('type') === 'error') {
this.authenticated = false;
@@ -1889,6 +2123,7 @@ class Connection {
_auth2_cb(elem: Element): boolean {
if (elem.getAttribute('type') === 'result') {
this.authenticated = true;
+ this._onSessionReady();
this._changeConnectStatus(Status.CONNECTED, null);
} else if (elem.getAttribute('type') === 'error') {
this._changeConnectStatus(Status.AUTHFAIL, null, elem);
@@ -1930,7 +2165,7 @@ class Connection {
ns: string | null,
name: string | null,
type: string | null,
- id: string | null
+ id: string | null,
): Handler {
const hand = new Handler(handler, ns, name, type, id, null);
hand.user = false;
diff --git a/src/constants.ts b/src/constants.ts
index 1c64ae20..22f2fa16 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -1,30 +1,46 @@
-/**
- * Common namespace constants from the XMPP RFCs and XEPs.
- */
-export const NS = {
- HTTPBIND: 'http://jabber.org/protocol/httpbind',
+const _NS = {
+ AUTH: 'jabber:iq:auth',
+ BIND: 'urn:ietf:params:xml:ns:xmpp-bind',
BOSH: 'urn:xmpp:xbosh',
CLIENT: 'jabber:client',
- SERVER: 'jabber:server',
- AUTH: 'jabber:iq:auth',
- ROSTER: 'jabber:iq:roster',
- PROFILE: 'jabber:iq:profile',
DISCO_INFO: 'http://jabber.org/protocol/disco#info',
DISCO_ITEMS: 'http://jabber.org/protocol/disco#items',
+ DELAY: 'urn:xmpp:delay' /** XEP-0203 */,
+ FRAMING: 'urn:ietf:params:xml:ns:xmpp-framing',
+ HTTPBIND: 'http://jabber.org/protocol/httpbind',
MUC: 'http://jabber.org/protocol/muc',
+ PROFILE: 'jabber:iq:profile',
+ ROSTER: 'jabber:iq:roster',
SASL: 'urn:ietf:params:xml:ns:xmpp-sasl',
- STREAM: 'http://etherx.jabber.org/streams',
- FRAMING: 'urn:ietf:params:xml:ns:xmpp-framing',
- BIND: 'urn:ietf:params:xml:ns:xmpp-bind',
+ SERVER: 'jabber:server',
SESSION: 'urn:ietf:params:xml:ns:xmpp-session',
- VERSION: 'jabber:iq:version',
+ SM: 'urn:xmpp:sm:3',
STANZAS: 'urn:ietf:params:xml:ns:xmpp-stanzas',
- XHTML_IM: 'http://jabber.org/protocol/xhtml-im',
+ STREAM: 'http://etherx.jabber.org/streams',
+ VERSION: 'jabber:iq:version',
XHTML: 'http://www.w3.org/1999/xhtml',
+ XHTML_IM: 'http://jabber.org/protocol/xhtml-im',
} as const;
+/**
+ * Common namespace constants from the XMPP RFCs and XEPs.
+ * Extensible at runtime via {@link Strophe.addNamespace}, hence the string
+ * index signature.
+ */
+export const NS: typeof _NS & Record = _NS;
+
export const PARSE_ERROR_NS = 'http://www.w3.org/1999/xhtml';
+/**
+ * The version of the page↔worker message protocol spoken between
+ * WorkerWebsocket and dist/shared-connection-worker.js. A SharedWorker can
+ * outlive the pages that spawned it, so after a deploy a freshly loaded page
+ * may attach to a worker from an older build (or vice versa). The version is
+ * exchanged on _connect/_attach so that a mismatch fails loudly instead of
+ * silently misbehaving.
+ */
+export const SHARED_WORKER_PROTOCOL_VERSION = 2;
+
/**
* Contains allowed tags, tag attributes, and css properties.
* Used in the {@link Strophe.createHtml} function to filter incoming html into the allowed XHTML-IM subset.
diff --git a/src/index.ts b/src/index.ts
index 4114ab74..41105df5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -14,6 +14,7 @@ import SASLSHA256 from './sasl-sha256';
import SASLSHA384 from './sasl-sha384';
import SASLSHA512 from './sasl-sha512';
import SASLXOAuth2 from './sasl-xoauth2';
+import StreamManagement, { MemoryStorageBackend, SessionStorageBackend, StreamManagementMirror } from './stream-management';
import TimedHandler from './timed-handler';
import Websocket from './websocket';
import WorkerWebsocket from './worker-websocket';
@@ -41,6 +42,9 @@ type StropheType = {
SASLExternal: typeof SASLExternal;
SASLXOAuth2: typeof SASLXOAuth2;
Stanza: typeof Stanza;
+ StreamManagement: typeof StreamManagement;
+ MemoryStorageBackend: typeof MemoryStorageBackend;
+ SessionStorageBackend: typeof SessionStorageBackend;
Builder: typeof Builder;
ElementType: typeof ElementType;
ErrorCondition: typeof ErrorCondition;
@@ -58,7 +62,8 @@ type StropheType = {
setLogLevel(level: LogLevel): void;
addNamespace(name: string, value: string): void;
addConnectionPlugin(name: string, ptype: object): void;
-} & typeof utils;
+} & typeof utils &
+ typeof log;
const Strophe: StropheType = {
VERSION: '4.0.0',
@@ -101,6 +106,9 @@ const Strophe: StropheType = {
SASLXOAuth2,
Stanza,
+ StreamManagement,
+ MemoryStorageBackend,
+ SessionStorageBackend,
Builder,
ElementType,
ErrorCondition,
@@ -144,4 +152,28 @@ const Strophe: StropheType = {
const toStanza = Stanza.toElement;
(globalThis as any).toStanza = Stanza.toElement;
-export { Builder, $build, $iq, $msg, $pres, Strophe, Stanza, stx, toStanza, Request };
+export {
+ Builder,
+ $build,
+ $iq,
+ $msg,
+ $pres,
+ Strophe,
+ Stanza,
+ stx,
+ toStanza,
+ Request,
+ StreamManagement,
+ StreamManagementMirror,
+ MemoryStorageBackend,
+ SessionStorageBackend,
+};
+export type {
+ StanzaView,
+ QueuedStanza,
+ SMState,
+ SMStorageBackend,
+ StreamManagementController,
+ StreamManagementOptions,
+} from './stream-management';
+export type { ConnectCallback, ConnectionOptions } from './connection';
diff --git a/src/shared-connection-worker.ts b/src/shared-connection-worker.ts
index cec238b7..0ef77656 100644
--- a/src/shared-connection-worker.ts
+++ b/src/shared-connection-worker.ts
@@ -1,100 +1,470 @@
-let manager: ConnectionManager | null = null;
-
-const Status = {
- ERROR: 0,
- CONNECTING: 1,
- CONNFAIL: 2,
- AUTHENTICATING: 3,
- AUTHFAIL: 4,
- CONNECTED: 5,
- DISCONNECTED: 6,
- DISCONNECTING: 7,
- ATTACHED: 8,
- REDIRECT: 9,
- CONNTIMEOUT: 10,
- BINDREQUIRED: 11,
- ATTACHFAIL: 12,
-};
-
-/** Class: ConnectionManager
+/**
+ * The SharedWorker side of Strophe's worker-based connection sharing:
+ * multiple tabs (ports) share the single websocket connection managed here.
+ *
+ * Exactly one live port holds the 'primary' role and it drives the XMPP stream
+ * (handshake, and at the application layer it is the tab that should respond
+ * to stanzas). All other ports are 'secondary' and attach to the shared
+ * session.
*
+ * The worker sends pings to probe port liveness. Pages answer from their
+ * message handler, which browsers do not throttle. A silent port keeps
+ * receiving broadcasts (a frozen tab replays them on thaw) until it has
+ * been quiet for so long that it must be gone for good (30 mins).
+ *
+ * This file is built as a self-contained classic-worker script
+ * (`dist/shared-connection-worker.js`) keep it free of DOM dependencies.
+ */
+import { SHARED_WORKER_PROTOCOL_VERSION, Status } from './constants';
+import StreamManagement from './stream-management/engine';
+import { peekElement } from './stream-management/parse';
+
+/** How often the worker pings its ports (and sweeps for gone ones). */
+export const PING_INTERVAL = 30_000;
+
+/**
+ * How long the primary may stay silent before the worker fails over to the
+ * freshest other port. Pongs are posted from the page's message handler,
+ * which runs even in background-throttled tabs, so silence this long means
+ * the tab is frozen, crashed or gone — not merely backgrounded.
+ */
+export const PRIMARY_TIMEOUT = 70_000;
+
+/**
+ * How long any port may stay silent before it is dropped entirely. Generous:
+ * a frozen tab posts nothing but keeps its MessagePort, and every broadcast
+ * posted to it while frozen is delivered when it thaws — dropping it early
+ * would forfeit that replay. A dropped port that speaks again is re-admitted.
+ */
+export const PORT_GC_TIMEOUT = 30 * 60_000;
+
+/** How long the socket is kept open after the last port has gone away. */
+export const SOCKET_GRACE = 10_000;
+
+/**
+ * How long the worker waits for the server to answer an ack probe ()
+ * before presuming the socket dead. Probes are sent from the sweep when an
+ * SM-enabled stream has been quiet for a full PING_INTERVAL, and when a
+ * tab (re)joins the established session. Without them, a silently dead
+ * socket (NAT timeout, sleep/wake) is only discovered by the OS's TCP
+ * timeout, which can take many minutes.
+ */
+export const PROBE_TIMEOUT = 10_000;
+
+/** The page→worker methods that may be invoked via postMessage. */
+const METHODS = [
+ '_connect',
+ '_attach',
+ '_pong',
+ '_relinquish',
+ 'send',
+ 'close',
+ '_closeSocket',
+ '_smFeatures',
+ '_bound',
+];
+
+/**
+ * The subset of METHODS that presumes a live socket. When one arrives after
+ * the socket died, the sender is answered with _onClose instead of having
+ * its message dropped into the void (see _onPortMessage).
+ */
+const SESSION_METHODS = ['send', 'close', '_smFeatures', '_bound'];
+
+type Role = 'primary' | 'secondary';
+
+interface PortInfo {
+ role: Role;
+ lastSeen: number;
+}
+
+/**
* Manages the shared websocket connection as well as the ports of the
* connected tabs.
*/
class ConnectionManager {
- ports: MessagePort[];
- jid: string | undefined;
+ ports: Map;
+ jid: string;
+ service: string;
socket: WebSocket | null;
+ /**
+ * Where the shared stream is in its lifecycle. 'connecting' spans from
+ * socket creation until the primary reports the bound JID (_bound) or a
+ * worker-driven resumption succeeds; only in 'established' are join
+ * requests answered — earlier ones wait in _pendingJoins.
+ */
+ session: 'none' | 'connecting' | 'established';
+ /**
+ * The worker-resident XEP-0198 engine (created lazily on the first SM
+ * negotiation). The worker is the only party that sees all inbound
+ * traffic (one socket) and all outbound traffic (every port's send
+ * relays through here), so it is the only correct owner of the SM
+ * counters and queue once more than one tab sends — a stanza sent from
+ * a secondary tab survives resumption, and failover needs no reconnect.
+ */
+ sm: StreamManagement | null;
+ /**
+ * Ports whose join arrived while the handshake was still in flight,
+ * with the method they joined through: on failure, an '_attach' join is
+ * answered with ATTACHFAIL (the attach() contract), while a '_connect'
+ * join is told the socket closed, so its page fails the connect attempt
+ * and the embedder's reconnect logic takes over.
+ */
+ private _pendingJoins: Map;
+ private _sweepTimer: ReturnType;
+ private _graceTimer: ReturnType;
+ /** Pending death sentence for an unanswered ack probe (see _probe). */
+ private _probeTimer: ReturnType;
+ /** When the socket last delivered anything (probes count inbound only). */
+ private _lastInbound: number;
constructor() {
- this.ports = [];
+ this.ports = new Map();
this.socket = null;
+ this.session = 'none';
+ this.sm = null;
+ this._pendingJoins = new Map();
+ this._sweepTimer = null;
+ this._graceTimer = null;
+ this._probeTimer = null;
+ this._lastInbound = 0;
}
+ /**
+ * Register a newly connected port. It is admitted (and given a role) when
+ * it first speaks.
+ */
addPort(port: MessagePort): void {
- this.ports.push(port);
- port.addEventListener('message', (e: MessageEvent) => {
- const method = e.data[0];
- try {
- (this as unknown as Record)[method](e.data.splice(1));
- } catch (e) {
- console?.error(e);
- }
- });
+ port.addEventListener('message', (e: MessageEvent) => this._onPortMessage(port, e));
port.start();
+ if (!this._sweepTimer) {
+ this._sweepTimer = setInterval(() => this._sweep(), PING_INTERVAL);
+ }
}
- _connect(data: [string, string]): void {
- this.jid = data[1];
+ /**
+ * Dispatch a message from a port. Any message counts as liveness; a port
+ * that was dropped (e.g. a long-frozen tab) is re-admitted here.
+ */
+ private _onPortMessage(port: MessagePort, e: MessageEvent): void {
+ const [method, ...args] = e.data as [string, ...unknown[]];
+ if (method === '_bye') {
+ this._bye(port);
+ return;
+ }
+ if (!METHODS.includes(method)) {
+ this._post(port, 'log', 'error', `Found unhandled service worker message: ${method}`);
+ return;
+ }
+ const info = this.ports.get(port);
+ if (info) {
+ info.lastSeen = Date.now();
+ } else {
+ // New port, or a dropped one speaking again (§ re-admission).
+ const role: Role = this._hasPrimary() ? 'secondary' : 'primary';
+ this.ports.set(port, { role, lastSeen: Date.now() });
+ if (method !== '_connect' && method !== '_attach') {
+ if (this._socketLive()) {
+ // Keep the page's role in sync — this demotes a stale
+ // primary (e.g. a frozen tab that was failed over while
+ // suspended).
+ this._post(port, '_role', role, this.jid);
+ } else {
+ // The tab believes it belongs to a session that no longer
+ // exists (the socket died or was grace-closed while it was
+ // away). Tell it, so it reconnects instead of staying
+ // wedged in a connected-looking state.
+ this._post(port, '_onClose', 'The shared socket is gone');
+ }
+ }
+ }
+ if (SESSION_METHODS.includes(method) && !this._socketReady()) {
+ // The tab is talking to a dead (or still-connecting) socket —
+ // no legitimate sender exists before the socket opens, since the
+ // primary only starts the stream in reaction to _onOpen. Salvage
+ // what can be salvaged and make reality visible instead of
+ // dropping the message into the void (a send() on a CONNECTING
+ // socket would throw and lose the frame).
+ if (method === 'send') {
+ this._salvageFrame(port, args[0] as string);
+ }
+ this._post(port, '_onClose', 'The shared socket is gone');
+ return;
+ }
+ this._cancelGrace();
+ try {
+ (this as unknown as Record void>)[method](port, ...args);
+ } catch (err) {
+ console?.error(err);
+ }
+ }
+
+ /**
+ * Connect, or (when a live socket for the same user already exists)
+ * join the shared session as a secondary instead of reconnecting (§2.5).
+ * The first joiner becomes primary and drives the connection.
+ */
+ _connect(port: MessagePort, service: string, jid: string, version?: number): void {
+ if (!this._checkVersion(port, version)) {
+ return;
+ }
+ const sameUser = !this.jid || !jid || jid.split('/')[0].toLowerCase() === this.jid.split('/')[0].toLowerCase();
+ if (this._socketLive() && sameUser) {
+ // A tab (re)connecting is a good moment to verify that the
+ // shared socket is actually alive: a reloaded page joining a
+ // zombie session would otherwise only find out at the next
+ // sweep-driven probe.
+ this._probe();
+ this._joinShared(port, '_connect');
+ return;
+ }
+ this.service = service;
+ this.jid = jid;
+
+ // Per-stream SM state starts fresh; resumable state is reloaded from
+ // the engine's storage when the primary reports SM support
+ // (_smFeatures), mirroring the page-side connect() flow.
+ this.sm?.reset();
+
+ // This port drives the new connection and becomes primary.
+ for (const [p, info] of this.ports) {
+ if (p !== port && info.role === 'primary') {
+ info.role = 'secondary';
+ this._post(p, '_role', 'secondary', this.jid);
+ }
+ }
+ this.ports.get(port).role = 'primary';
+ this._post(port, '_role', 'primary', this.jid);
this._closeSocket();
- this.socket = new WebSocket(data[0], 'xmpp');
- this.socket.onopen = () => this._onOpen();
+ this.session = 'connecting';
+ this.socket = new WebSocket(service, 'xmpp');
+ // _onOpen goes only to the connecting (primary) port: every tab's
+ // Websocket layer reacts to it by sending a stream-, and a
+ // still-attached secondary doing so would corrupt the new stream.
+ this.socket.onopen = () => this._post(port, '_onOpen');
this.socket.onerror = (e) => this._onError(e);
this.socket.onclose = (e) => this._onClose(e);
this.socket.onmessage = (message) => this._onMessage(message);
}
- _attach(): void {
- if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
- this.ports.forEach((p) => p.postMessage(['_attachCallback', Status.ATTACHED, this.jid]));
+ /**
+ * Attach to the shared session, if there is one.
+ * @param port
+ * @param _service
+ * @param version - The page's SHARED_WORKER_PROTOCOL_VERSION.
+ */
+ _attach(port: MessagePort, _service?: string, version?: number): void {
+ if (!this._checkVersion(port, version)) {
+ return;
+ }
+ if (this._socketLive()) {
+ this._probe();
+ this._joinShared(port, '_attach');
} else {
- this.ports.forEach((p) => p.postMessage(['_attachCallback', Status.ATTACHFAIL]));
+ this._post(port, '_attachCallback', Status.ATTACHFAIL);
}
}
- send(str: string): void {
- this.socket!.send(str);
+ /**
+ * The primary reports that the post-SASL stream features advertise
+ * XEP-0198 (§2.3). This is the resume-vs-bind decision point: only the
+ * worker knows whether resumable state exists, so it either sends
+ * itself (answering with _smResumed/_smFailed once the server
+ * replies) or tells the requesting port to proceed with binding.
+ * @param port
+ */
+ _smFeatures(port: MessagePort): void {
+ const sm = this._ensureSM();
+ sm.serverSupported = true;
+ sm.initialize(this.jid);
+ if (sm.hasResumableState()) {
+ sm.sendResume();
+ } else {
+ this._post(port, '_smNoState');
+ }
}
- close(str: string): void {
- if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
- try {
- this.socket.send(str);
- } catch (e) {
- this.ports.forEach((p) => p.postMessage(['log', 'error', e]));
- this.ports.forEach((p) => p.postMessage(['log', 'error', "Couldn't send tag."]));
- }
+ /**
+ * The primary reports that the connect flow completed (resource bound,
+ * or legacy auth/session establishment finished): adopt the bound JID as
+ * the shared one so attaching secondaries and promotions hand out a
+ * resource the server actually knows about, start a fresh SM session if
+ * this stream's features advertised support (_smFeatures), and release
+ * any joins parked on the handshake. The is written before the
+ * parked joins drain, so it precedes anything a newly attached tab sends.
+ * @param _port
+ * @param jid - The server-assigned full JID.
+ */
+ _bound(_port: MessagePort, jid: string): void {
+ this.jid = jid;
+ if (this.sm?.serverSupported) {
+ this.sm.sendEnable(jid);
}
+ this._sessionEstablished();
}
- _onOpen(): void {
- this.ports.forEach((p) => p.postMessage(['_onOpen']));
+ /**
+ * Lazily create the worker-resident engine. Its nonzas go straight to
+ * the socket (outbound frames relay in receipt order, so nothing can be
+ * overtaken), and its lifecycle events are forwarded to the tabs as the
+ * _smEnabled/_smResumed/_smFailed messages that feed the page-side
+ * mirrors and drive the primary's connect flow.
+ */
+ private _ensureSM(): StreamManagement {
+ if (!this.sm) {
+ const sm = new StreamManagement((data: string) => {
+ this.socket?.send(data);
+ });
+ sm.onEnabled = () => this._broadcast('_smEnabled', sm.state.id, sm.state.max, sm.state.boundJid);
+ sm.onResumed = () => {
+ this.jid = sm.boundJid;
+ // The resumed session is established: release parked joins
+ // (with the bound JID) before the mirrors hear about it.
+ this._sessionEstablished();
+ this._broadcast('_smResumed', sm.boundJid);
+ };
+ // Only a failed *resumption* concerns the tabs (the primary must
+ // fall back to binding); a refused just means this
+ // stream runs without SM.
+ sm.onFailed = (_view, resumeFailed) => {
+ if (resumeFailed) {
+ this._broadcast('_smFailed');
+ }
+ };
+ this.sm = sm;
+ }
+ return this.sm;
}
- _onClose(e: CloseEvent): void {
- this.ports.forEach((p) => p.postMessage(['_onClose', e.reason]));
+ /**
+ * Liveness reply to the worker's _ping. The lastSeen update happens in
+ * the dispatcher, so any message from a port counts. This method only
+ * needs to exist.
+ * @param _port
+ */
+ _pong(_port: MessagePort): void {
+ return;
}
- _onMessage(message: MessageEvent): void {
- const o = { 'data': message.data };
- this.ports.forEach((p) => p.postMessage(['_onMessage', o]));
+ /**
+ * The page announces it is about to be frozen (its CPU stops): hand the
+ * primary role to the freshest other port right away instead of making
+ * the worker discover the freeze through missed pongs.
+ * @param port
+ */
+ _relinquish(port: MessagePort): void {
+ const info = this.ports.get(port);
+ if (info?.role !== 'primary') {
+ return;
+ }
+ if (this.session === 'connecting') {
+ this._primaryLost();
+ return;
+ }
+ // With no other port, the tab keeps the role and resumes as primary.
+ this._failover(port);
}
- _onError(error: Event): void {
- this.ports.forEach((p) => p.postMessage(['_onError', error]));
+ /**
+ * Graceful removal (sent from pagehide). If the primary leaves, the next
+ * live port is promoted on the same socket.
+ * @param port
+ */
+ _bye(port: MessagePort): void {
+ const info = this.ports.get(port);
+ if (!info) return;
+ this.ports.delete(port);
+ if (info.role === 'primary') {
+ this._primaryLost();
+ }
+ if (this.ports.size === 0) {
+ this._startGrace();
+ }
}
- _closeSocket(): void {
+ /**
+ * Relay an outbound frame to the socket, feeding it to the XEP-0198
+ * engine and reflecting messages/presences to the other tabs.
+ * @param port
+ * @param data
+ */
+ send(port: MessagePort, data: string): void {
+ const view = peekElement(data);
+ if (view?.name === 'close') {
+ // A stream-closing triggers the final ack + state clearing,
+ // and the engine writes that final straight to the socket, so
+ // onGracefulClose() must run *before* the close frame goes out.
+ this.sm?.onGracefulClose();
+ this.socket.send(data);
+ return;
+ }
+
+ this.socket.send(data);
+ if (this.sm) {
+ if (view) {
+ // A countable stanza is tracked *after* it is written, because
+ // trackOutbound() may emit an which should ride behind the
+ // stanza it covers.
+ this.sm.trackOutbound(view);
+ } else {
+ this._warnUnpeekable('outbound', data);
+ }
+ }
+ if (view) {
+ this._reflect(port, view.name, data);
+ }
+ }
+
+ /**
+ * Reflect an outbound message/presence to every tab except the sender,
+ * so all tabs can render what any one of them sent. Delivered as its own
+ * `_onStanzaSent` page message — never as `_onMessage` — because a sent
+ * stanza must not enter the receiving tabs' inbound pipeline (stanza
+ * handlers, SM counting, xmlInput). IQs are not reflected: they are
+ * request/response traffic private to the sending tab.
+ * @param sender - The port the stanza came from (gets no reflection).
+ * @param name - The frame's tag name (from the peeker).
+ * @param data - The raw outbound frame.
+ */
+ private _reflect(sender: MessagePort, name: string, data: string): void {
+ if (name !== 'message' && name !== 'presence') {
+ return;
+ }
+ for (const [port] of this.ports) {
+ if (port !== sender) {
+ this._post(port, '_onStanzaSent', data);
+ }
+ }
+ }
+
+ /**
+ * Send the stream-closing frame before disconnecting.
+ * @param port
+ * @param data
+ */
+ close(port: MessagePort, data: string): void {
+ if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
+ try {
+ this.sm?.onGracefulClose();
+ this.socket.send(data);
+ } catch (e) {
+ this._post(port, 'log', 'error', e);
+ this._post(port, 'log', 'error', "Couldn't send tag.");
+ }
+ }
+ }
+
+ /**
+ * Explicitly close the shared socket (a deliberate disconnect/logout —
+ * a mere tab close sends `_bye` instead). Joins parked on the handshake
+ * are answered with ATTACHFAIL: the session they were waiting for is
+ * not going to happen.
+ * @param _port
+ */
+ _closeSocket(_port?: MessagePort): void {
+ this.session = 'none';
+ this._cancelProbe();
+ this._failPendingJoins();
if (this.socket) {
try {
this.socket.onclose = null;
@@ -102,17 +472,423 @@ class ConnectionManager {
this.socket.onmessage = null;
this.socket.close();
} catch (e) {
- this.ports.forEach((p) => p.postMessage(['log', 'error', e]));
+ this.ports.forEach((_info, p) => this._post(p, 'log', 'error', e));
}
}
this.socket = null;
}
+
+ /**
+ * @param e
+ */
+ _onClose(e: CloseEvent): void {
+ this.session = 'none';
+ this._cancelProbe();
+ this._failPendingJoins();
+ this._broadcast('_onClose', e.reason);
+ }
+
+ /**
+ * Deliver an inbound frame to the tabs, feeding it to the XEP-0198
+ * engine first: counting and → replies happen exactly once.
+ * Lifecycle transitions (//) surface
+ * through the engine's event hooks (see _ensureSM).
+ *
+ * Until the session is established, frames go only to the primary,
+ * since only it is responsible for SASL negotiation and session setup.
+ * Once established, frames are broadcast to all tabs.
+ * @param message
+ */
+ _onMessage(message: MessageEvent): void {
+ // Any inbound traffic proves the socket alive.
+ this._lastInbound = Date.now();
+ this._cancelProbe();
+ if (this.sm && typeof message.data === 'string') {
+ const view = peekElement(message.data);
+ if (view) {
+ this.sm.onInbound(view);
+ } else {
+ this._warnUnpeekable('inbound', message.data);
+ }
+ }
+ if (this.session === 'established') {
+ this._broadcast('_onMessage', { data: message.data });
+ } else {
+ const primary = this._primaryPort();
+ if (primary) {
+ this._post(primary, '_onMessage', { data: message.data });
+ }
+ }
+ }
+
+ /**
+ * @param error
+ */
+ _onError(error: Event): void {
+ this._broadcast('_onError', error);
+ }
+
+ /**
+ * Verify that the page and this worker were built against the same
+ * page↔worker protocol. On mismatch the port is refused (and told the
+ * socket is closed, so the page fails loudly rather than misbehaving).
+ * @param port
+ * @param version - The version the page sent along with _connect/_attach.
+ * @returns true when the page speaks this worker's protocol.
+ */
+ private _checkVersion(port: MessagePort, version?: number): boolean {
+ if (version === SHARED_WORKER_PROTOCOL_VERSION) {
+ return true;
+ }
+ this._post(
+ port,
+ 'log',
+ 'fatal',
+ `Shared connection worker protocol mismatch (page: ${version}, worker: ` +
+ `${SHARED_WORKER_PROTOCOL_VERSION}) — the page and dist/shared-connection-worker.js ` +
+ `are from different builds`,
+ );
+ this._post(port, '_onClose', 'Shared connection worker protocol mismatch');
+ this.ports.delete(port);
+ return false;
+ }
+
+ /**
+ * @returns true when the socket exists and is not closing/closed. A
+ * CONNECTING socket counts: joins arriving during the handshake must
+ * be parked, not refused.
+ */
+ private _socketLive(): boolean {
+ return (
+ this.socket && this.socket.readyState !== WebSocket.CLOSED && this.socket.readyState !== WebSocket.CLOSING
+ );
+ }
+
+ /**
+ * @returns true when the socket is OPEN, i.e. ready to carry frames.
+ */
+ private _socketReady(): boolean {
+ return this.socket?.readyState === WebSocket.OPEN;
+ }
+
+ /**
+ * @returns true if any live port currently holds the primary role.
+ */
+ private _hasPrimary(): boolean {
+ return this._primaryPort() !== null;
+ }
+
+ /**
+ * @returns The port currently holding the primary role, or null.
+ */
+ private _primaryPort(): MessagePort | null {
+ for (const [port, info] of this.ports) {
+ if (info.role === 'primary') return port;
+ }
+ return null;
+ }
+
+ /**
+ * Join a port onto the shared session: it becomes secondary (or primary,
+ * if no primary is left) and is attached. Joins that arrive while the
+ * handshake is still in flight are parked.
+ * @param port
+ * @param origin - The method the join arrived through (see _pendingJoins).
+ */
+ private _joinShared(port: MessagePort, origin: '_connect' | '_attach'): void {
+ if (this.session !== 'established') {
+ if (!this._pendingJoins.has(port)) {
+ this._pendingJoins.set(port, origin);
+ }
+ return;
+ }
+ const info = this.ports.get(port);
+ let hasOtherPrimary = false;
+ for (const [p, i] of this.ports) {
+ if (p !== port && i.role === 'primary') {
+ hasOtherPrimary = true;
+ break;
+ }
+ }
+ info.role = hasOtherPrimary ? 'secondary' : info.role;
+ this._post(port, '_role', info.role, this.jid);
+ if (this.sm?.enabled) {
+ // Seed the joining tab's page-side SM mirror with the running
+ // session — it missed the _smEnabled/_smResumed broadcast.
+ this._post(port, '_smEnabled', this.sm.state.id, this.sm.state.max, this.sm.state.boundJid);
+ }
+ this._post(port, '_attachCallback', Status.ATTACHED, this.jid);
+ }
+
+ /**
+ * The stream reached its usable state (resource bound or resumed):
+ * record it and answer the joins that were parked on the handshake.
+ */
+ private _sessionEstablished(): void {
+ this.session = 'established';
+ const pending = this._pendingJoins;
+ this._pendingJoins = new Map();
+ pending.forEach((origin, p) => this.ports.has(p) && this._joinShared(p, origin));
+ }
+
+ /**
+ * Fail parked joins. The session they were waiting for died before it
+ * was established. '_attach' joins get ATTACHFAIL; '_connect' joins are
+ * told the socket closed, which fails the page's connect attempt so the
+ * embedder's reconnect logic can retry.
+ */
+ private _failPendingJoins(): void {
+ const pending = this._pendingJoins;
+ this._pendingJoins = new Map();
+ pending.forEach((origin, p) => {
+ if (origin === '_attach') {
+ this._post(p, '_attachCallback', Status.ATTACHFAIL);
+ } else {
+ this._post(p, '_onClose', 'The shared connection could not be established');
+ }
+ });
+ }
+
+ /**
+ * The primary is gone (bye, GC or freeze). If the session is established
+ * the freshest remaining port takes over on the same socket. If the
+ * handshake was still in flight, nobody else can finish it (only the
+ * primary holds credentials): kill the socket so the remaining tabs
+ * reconnect and elect a new primary.
+ */
+ private _primaryLost(): void {
+ if (this.session === 'connecting') {
+ this._closeSocket();
+ this._broadcast('_onClose', 'The connection-driving tab went away during the handshake');
+ } else {
+ this._promoteNext();
+ }
+ }
+
+ /**
+ * Promote the freshest port to primary — same socket, no reconnect.
+ * @param exclude - A port that must not be picked (e.g. the one
+ * relinquishing the role).
+ */
+ private _promoteNext(exclude?: MessagePort): void {
+ const next = this._freshestPort(exclude);
+ if (!next) return;
+ this.ports.get(next).role = 'primary';
+ this._post(next, '_promote', this.jid);
+ }
+
+ /**
+ * Hand the primary role from `port` to the freshest other port, if any.
+ * @param port - The current primary.
+ * @param minLastSeen - Only consider candidates seen at or after this
+ * timestamp (so a failover never picks an equally-dead port).
+ * @returns true if a failover happened.
+ */
+ private _failover(port: MessagePort, minLastSeen = 0): boolean {
+ const next = this._freshestPort(port, minLastSeen);
+ if (!next) return false;
+ this.ports.get(port).role = 'secondary';
+ this._post(port, '_role', 'secondary', this.jid);
+ this.ports.get(next).role = 'primary';
+ this._post(next, '_promote', this.jid);
+ return true;
+ }
+
+ /**
+ * @param exclude - A port to skip.
+ * @param minLastSeen - Minimum lastSeen for a port to qualify.
+ * @returns The most recently seen qualifying port, or null.
+ */
+ private _freshestPort(exclude?: MessagePort, minLastSeen = 0): MessagePort | null {
+ let best: MessagePort | null = null;
+ let bestSeen = minLastSeen - 1;
+ for (const [port, info] of this.ports) {
+ if (port !== exclude && info.lastSeen > bestSeen) {
+ best = port;
+ bestSeen = info.lastSeen;
+ }
+ }
+ return best;
+ }
+
+ /**
+ * A frame was sent into a dead socket. If an SM session was active, feed
+ * a stanza to the engine anyway: it lands in the unacked queue and is
+ * replayed when the session is resumed over the next socket, so the
+ * message the user just sent isn't lost to the bad timing. A
+ * still ends the session: the final can't be delivered anymore
+ * (the engine's sendRaw is null-guarded), but the persisted state is
+ * cleared so the deliberately-ended session isn't resumed later.
+ * @param port - The port the frame came from.
+ * @param data - The raw outbound frame.
+ */
+ private _salvageFrame(port: MessagePort, data: string): void {
+ if (!this.sm) return;
+ const view = peekElement(data);
+ if (view?.name === 'close') {
+ this.sm.onGracefulClose();
+ } else if (view) {
+ this.sm.trackOutbound(view);
+ if (this.sm.isTracking()) {
+ // The stanza sits in the queue and will be replayed on the
+ // next resume, so reflect it like any sent stanza.
+ this._reflect(port, view.name, data);
+ }
+ } else {
+ this._warnUnpeekable('outbound', data);
+ }
+ }
+
+ /**
+ * Report a frame the regex peeker (see parse.ts) could not parse while an
+ * SM session was active. Such a frame passes through uncounted and
+ * untracked, so the XEP-0198 handled-stanza counters drift and stanzas
+ * get duplicated (inbound) or lost (outbound) on the next resume —
+ * exactly the silent corruption SM exists to prevent, so make it loud.
+ *
+ * Only frames that look like an element open are reported: the stream
+ * prolog, whitespace keepalives and empty frames legitimately don't peek
+ * and are never countable stanzas.
+ *
+ * The opening tag is included so the peeker can actually be fixed — it is
+ * where the parse failed (peekElement only ever inspects the opening tag)
+ * and is the whole diagnostic. The report stops at the first '>' (capped),
+ * which keeps the stanza payload — message bodies and the like — out of
+ * the logs.
+ * @param direction
+ * @param frame
+ */
+ private _warnUnpeekable(direction: 'inbound' | 'outbound', frame: string): void {
+ if (!/^\s*<[a-zA-Z]/.test(frame)) {
+ return;
+ }
+ const MAX = 200;
+ const tagEnd = frame.indexOf('>');
+ const openTag = tagEnd !== -1 && tagEnd + 1 <= MAX ? frame.slice(0, tagEnd + 1) : frame.slice(0, MAX) + '…';
+ this._broadcast(
+ 'log',
+ 'error',
+ `StreamManagement: could not parse an ${direction} frame; it was not counted, ` +
+ `so the handled-stanza counters may drift. Opening tag: ${openTag}`,
+ );
+ }
+
+ /**
+ * Ping every port and act on prolonged silence. A live page answers
+ * pings from its message handler even when its timers are throttled, so:
+ * a primary silent past PRIMARY_TIMEOUT is failed over (but keeps
+ * receiving — it may merely be frozen and will learn of its demotion on
+ * thaw), and a port silent past PORT_GC_TIMEOUT is dropped for good.
+ */
+ private _sweep(): void {
+ const now = Date.now();
+ for (const [port, info] of this.ports) {
+ if (now - info.lastSeen > PORT_GC_TIMEOUT) {
+ this.ports.delete(port);
+ if (info.role === 'primary') {
+ this._primaryLost();
+ }
+ continue;
+ }
+ this._post(port, '_ping');
+ if (info.role === 'primary' && now - info.lastSeen > PRIMARY_TIMEOUT) {
+ if (this.session === 'connecting') {
+ this._primaryLost();
+ } else {
+ // Only fail over to a port that is provably fresher.
+ this._failover(port, now - PRIMARY_TIMEOUT);
+ }
+ }
+ }
+ if (this.ports.size === 0 && this._socketLive()) {
+ this._startGrace();
+ }
+ if (this.session === 'established' && now - this._lastInbound >= PING_INTERVAL) {
+ this._probe();
+ }
+ }
+
+ /**
+ * Ask the server for an ack and treat a missing reply as socket death.
+ * The doubles as a keepalive on quiet connections.
+ */
+ private _probe(): void {
+ if (this._probeTimer || !this.sm?.enabled || !this._socketReady()) {
+ return;
+ }
+ this.sm.requestAck();
+ this._probeTimer = setTimeout(() => {
+ this._probeTimer = null;
+ this._onSocketDead();
+ }, PROBE_TIMEOUT);
+ }
+
+ private _cancelProbe(): void {
+ if (this._probeTimer) {
+ clearTimeout(this._probeTimer);
+ this._probeTimer = null;
+ }
+ }
+
+ /**
+ * The probe went unanswered: the socket is transmitting into the void.
+ * Close it and tell the tabs, so they reconnect. The engine's state is
+ * untouched, so the next stream resumes and replays the unacked queue.
+ */
+ private _onSocketDead(): void {
+ this._broadcast(
+ 'log',
+ 'warn',
+ `StreamManagement: ack probe unanswered for ${PROBE_TIMEOUT}ms; presuming the socket dead`,
+ );
+ this._closeSocket();
+ this._broadcast('_onClose', 'The connection stopped responding');
+ }
+
+ /**
+ * Close the socket if no port shows up within the grace period.
+ */
+ private _startGrace(): void {
+ if (this._graceTimer) return;
+ this._graceTimer = setTimeout(() => {
+ this._graceTimer = null;
+ this._closeSocket();
+ }, SOCKET_GRACE);
+ }
+
+ private _cancelGrace(): void {
+ if (this._graceTimer) {
+ clearTimeout(this._graceTimer);
+ this._graceTimer = null;
+ }
+ }
+
+ /**
+ * Send a message to a single port (worker→page traffic is port-targeted;
+ * only genuine broadcasts use _broadcast).
+ * @param port
+ * @param msg
+ */
+ private _post(port: MessagePort, ...msg: unknown[]): void {
+ port.postMessage(msg);
+ }
+
+ /**
+ * Send a message to all live ports.
+ * @param msg
+ */
+ private _broadcast(...msg: unknown[]): void {
+ this.ports.forEach((_info, port) => port.postMessage(msg));
+ }
}
-addEventListener(
- 'connect',
- (e: MessageEvent) => {
+let manager: ConnectionManager | null = null;
+
+if (typeof addEventListener === 'function') {
+ addEventListener('connect', (e: MessageEvent) => {
manager = manager || new ConnectionManager();
manager.addPort(e.ports[0]);
- }
-);
+ });
+}
+
+export default ConnectionManager;
diff --git a/src/stream-management/dom.ts b/src/stream-management/dom.ts
new file mode 100644
index 00000000..47d8682a
--- /dev/null
+++ b/src/stream-management/dom.ts
@@ -0,0 +1,26 @@
+/**
+ * XEP-0198 Stream Management
+ *
+ * The page-side {@link StanzaView} adapter.
+ *
+ * The page counterpart of parse.ts: where the worker builds views from raw
+ * frame strings, the page builds them from the DOM Elements it already has.
+ * Page-only: XMLSerializer is a DOM global, so this module must never be
+ * imported by the shared-connection-worker bundle. Keeping it in its own
+ * file makes that boundary structural instead of comment-enforced.
+ */
+import { StanzaView } from './types';
+
+/**
+ * Build the environment-free {@link StanzaView} consumed by the XEP-0198
+ * engine from a DOM Element.
+ * @param el
+ */
+export function toStanzaView(el: Element): StanzaView {
+ const attrs: Record = {};
+ for (let i = 0; i < el.attributes.length; i++) {
+ const attr = el.attributes[i];
+ attrs[attr.name] = attr.value;
+ }
+ return { name: el.tagName, attrs, serialized: new XMLSerializer().serializeToString(el) };
+}
diff --git a/src/stream-management/engine.ts b/src/stream-management/engine.ts
new file mode 100644
index 00000000..339130ba
--- /dev/null
+++ b/src/stream-management/engine.ts
@@ -0,0 +1,470 @@
+/**
+ * XEP-0198 Stream Management engine.
+ *
+ * This class holds all SM state and logic (counters, the unacked queue, the
+ * enable/resume lifecycle) and never touches a DOM Element or a raw websocket
+ * frame, so the same class can be hosted by a page-side Connection or inside a
+ * SharedWorker. Everything it needs from a stanza is captured in a minimal
+ * {@link StanzaView}, produced by whichever side hosts it (dom.ts on the page,
+ * parse.ts in the worker). Nonzas are emitted as strings through the injected
+ * `sendRaw` function.
+ *
+ * IMPORTANT: this module must be loadable in a SharedWorker global (it is
+ * bundled into dist/shared-connection-worker.js), so keep it free of imports
+ * beyond log/constants and its worker-safe siblings, in particular no
+ * ../utils.ts or ../builder.ts, and no module-level DOM access.
+ */
+import log from '../log';
+import { NS } from '../constants';
+import {
+ QueuedStanza,
+ SMState,
+ SMStorageBackend,
+ StanzaView,
+ StreamManagementController,
+ StreamManagementOptions,
+} from './types';
+import { H_WRAP, freshState, isCountableStanza, parseH, stampDelay, xmlEscape } from './utils';
+import { MemoryStorageBackend } from './storage';
+
+/**
+ * The XEP-0198 Stream Management engine.
+ *
+ * Hosted by {@link Connection} (page) or by the shared-connection worker; fed
+ * {@link StanzaView}s by a thin per-environment adapter. Emits nonzas through
+ * the injected `sendRaw` function.
+ */
+class StreamManagement implements StreamManagementController {
+ /** Whether the current stream's features advertised . Not persisted. */
+ serverSupported: boolean;
+
+ private _sendRaw: (data: string) => void;
+ private _options: StreamManagementOptions;
+ private _storage: SMStorageBackend;
+ private _storageKey: string;
+ private _state: SMState;
+ /**
+ * Whether a has been sent and not yet answered. Deliberately
+ * not part of the persisted state: it describes the current stream's
+ * negotiation, not the resumable session, so a reloaded page must not
+ * inherit it.
+ */
+ private _resumePending: boolean;
+ /**
+ * Stanzas salvaged from a failed resumption, awaiting re-send once the
+ * fresh session reaches . Deliberately not persisted: storage is
+ * cleared on , and this buffer is best-effort redelivery.
+ */
+ private _pendingResend: QueuedStanza[];
+
+ /**
+ * @param sendRaw - Emits a serialized nonza (or re-sent stanza) towards the server.
+ * @param options
+ */
+ constructor(sendRaw: (data: string) => void, options: StreamManagementOptions = {}) {
+ this._sendRaw = sendRaw;
+ this._options = {
+ maxUnacked: 5,
+ requestResume: true,
+ ...options,
+ };
+ this._storage = this._options.storage || new MemoryStorageBackend();
+ this._storageKey = null;
+ this._state = freshState();
+ this._resumePending = false;
+ this._pendingResend = [];
+ this.serverSupported = false;
+ }
+
+ /** The live SM state. Treat as read-only outside of tests. */
+ get state(): SMState {
+ return this._state;
+ }
+
+ /** Whether has been received and the SM session is active. */
+ get enabled(): boolean {
+ return this._state.enabled;
+ }
+
+ /** Whether the current session was established by resumption. */
+ get resumed(): boolean {
+ return this._state.resumed;
+ }
+
+ /** The full JID bound when the SM session was enabled. */
+ get boundJid(): string {
+ return this._state.boundJid;
+ }
+
+ /** Whether a has been sent and not yet answered. */
+ get resumePending(): boolean {
+ return this._resumePending;
+ }
+
+ /**
+ * Set the storage key (from the user's bare JID) and load any persisted
+ * resumable state. Call before deciding between resume and fresh bind.
+ * @param jid - The user's JID (a full JID is reduced to its bare form).
+ */
+ initialize(jid: string): void {
+ this._storageKey = `strophe-sm:${jid.split('/')[0].toLowerCase()}`;
+ const stored = this._storage.load(this._storageKey);
+ if (stored) {
+ this._state = { ...freshState(), ...stored };
+ }
+ }
+
+ /**
+ * @returns true if persisted state allows attempting .
+ */
+ hasResumableState(): boolean {
+ return !!(this._state.id && this._state.resumeSupported);
+ }
+
+ /**
+ * Reset the in-memory engine (e.g. from Connection.reset()).
+ * Persisted state is NOT touched — clearing storage is tied to intent
+ * (clean close, logout, failed resume), not to connection reuse.
+ */
+ reset(): void {
+ this._state = freshState();
+ this._resumePending = false;
+ this._pendingResend = [];
+ this.serverSupported = false;
+ }
+
+ /** Remove persisted state (clean close, logout, failed resume). */
+ clearPersistedState(): void {
+ if (this._storageKey) {
+ this._storage.clear(this._storageKey);
+ }
+ }
+
+ /**
+ * Send to start a new SM session. Call after resource binding,
+ * at the point CONNECTED is emitted. At most one is sent per
+ * stream — a second attempt SHOULD get the stream killed by the server
+ * (XEP-0198 §3).
+ * @param boundJid - The full JID that was just bound.
+ */
+ sendEnable(boundJid: string): void {
+ const s = this._state;
+ if (s.enableSent) {
+ log.warn('StreamManagement.sendEnable called but was already sent for this session');
+ return;
+ }
+ s.enableSent = true;
+ s.enabled = false;
+ s.resumed = false;
+ s.id = null;
+ s.max = null;
+ s.location = null;
+ s.resumeSupported = false;
+ s.hIn = 0;
+ s.hOutAcked = 0;
+ s.sinceLastAck = 0;
+ s.unacked = [];
+ s.boundJid = boundJid;
+ const resume = this._options.requestResume ? ' resume="true"' : '';
+ const max = this._options.max ? ` max="${this._options.max}"` : '';
+ this._sendRaw(``);
+ this._persist();
+ }
+
+ /**
+ * Send for the persisted previous session. Call instead of
+ * binding, once the post-SASL stream features advertise SM support.
+ */
+ sendResume(): void {
+ const s = this._state;
+ if (!this.hasResumableState()) {
+ log.warn('StreamManagement.sendResume called without resumable state');
+ return;
+ }
+ this._resumePending = true;
+ this._sendRaw(``);
+ }
+
+ /**
+ * @returns true once has been sent, i.e. outbound stanzas are
+ * being tracked. Lets the caller skip building a {@link StanzaView}
+ * for {@link trackOutbound} when tracking is inactive, without
+ * reaching into the engine's state.
+ */
+ isTracking(): boolean {
+ return this._state.enableSent;
+ }
+
+ /**
+ * Track an outbound top-level element. Called for every element that
+ * enters the send queue; non-countable elements are ignored here.
+ * Active from the moment is sent (not from receipt —
+ * XEP-0198 starts the outbound counter at enable-send).
+ * @param view
+ */
+ trackOutbound(view: StanzaView): void {
+ const s = this._state;
+ if (!s.enableSent || !isCountableStanza(view.name)) {
+ return;
+ }
+ s.unacked.push({ name: view.name, stanza: view.serialized, queuedAt: Date.now() });
+ s.sinceLastAck += 1;
+ const max = this._options.maxUnacked;
+ if (max > 0 && s.sinceLastAck % max === 0) {
+ this.requestAck();
+ }
+ this._persist();
+ }
+
+ /**
+ * Process one inbound top-level element: count it if it is a countable
+ * stanza (when the session is enabled), otherwise handle it as an SM
+ * nonza (////). Unrecognised elements are
+ * ignored. Each host dispatches inbound elements to its own handlers
+ * independently of this call, so nothing is returned.
+ * @param view
+ */
+ onInbound(view: StanzaView): void {
+ if (isCountableStanza(view.name)) {
+ this.onInboundStanza(view.name);
+ return;
+ }
+ switch (view.name) {
+ case 'r':
+ if (this._state.enabled) this.sendAck();
+ break;
+ case 'a':
+ this._handleAck(view);
+ break;
+ case 'enabled':
+ this._handleEnabled(view);
+ break;
+ case 'resumed':
+ this._handleResumed(view);
+ break;
+ case 'failed':
+ this._handleFailed(view);
+ break;
+ }
+ }
+
+ /** Send an unrequested ack request to the server. */
+ requestAck(): void {
+ this._sendRaw(``);
+ }
+
+ /**
+ * Send an ack with the current inbound count. Used to answer ,
+ * and RECOMMENDED right before gracefully closing the stream (XEP-0198 §4)
+ * so the server doesn't redeliver stanzas that were actually received.
+ */
+ sendAck(): void {
+ this._sendRaw(``);
+ }
+
+ /**
+ * Count an inbound top-level stanza by name. Adapters call this from
+ * their dispatch loop for every inbound child element — non-countable
+ * names and inactive sessions are no-ops, so no StanzaView needs to be
+ * built for the common case.
+ * @param name - The element's local tag name.
+ */
+ onInboundStanza(name: string): void {
+ if (this._state.enabled && isCountableStanza(name)) {
+ this._state.hIn = (this._state.hIn + 1) % H_WRAP;
+ this._persist();
+ }
+ }
+
+ /**
+ * Call when the stream is about to be closed cleanly: sends a final
+ * so the server doesn't redeliver stanzas that were actually
+ * received (RECOMMENDED, XEP-0198 §4), clears persisted state (a
+ * cleanly closed stream is not resumable, XEP-0198 §Stream Closure) and
+ * deactivates the engine, so nothing sent during teardown is tracked or
+ * re-persisted.
+ */
+ onGracefulClose(): void {
+ if (this._state.enabled) {
+ this.sendAck();
+ }
+ this.clearPersistedState();
+ this._state = freshState();
+ this._resumePending = false;
+ this._pendingResend = [];
+ }
+
+ /** Overridable event hook: an SM session was established via . */
+ onEnabled(): void {
+ return;
+ }
+
+ /** Overridable event hook: the previous session was resumed via . */
+ onResumed(): void {
+ return;
+ }
+
+ /**
+ * Overridable event hook: or failed.
+ * @param _view - The nonza (inspect e.g. for ).
+ * @param _resumeFailed - true when the failure answered a (the
+ * unacked queue was salvaged for re-send on the next session), false
+ * when it answered an .
+ */
+ onFailed(_view?: StanzaView, _resumeFailed?: boolean): void {
+ return;
+ }
+
+ /**
+ * Reconcile the server-reported 'h' against the unacked queue (mod 2^32).
+ * An 'h' above our send count is logged and clamped rather than answered
+ * with the spec's stream close — a client
+ * killing the stream over a server bug only hurts the user.
+ * @param h
+ */
+ private _reconcile(h: number): void {
+ const s = this._state;
+ let delta = (h - s.hOutAcked + H_WRAP) % H_WRAP;
+ if (delta > s.unacked.length) {
+ log.error(
+ `StreamManagement: server acked ${delta} stanzas but only ` +
+ `${s.unacked.length} are unacknowledged (h=${h}, previous h=${s.hOutAcked})`,
+ );
+ delta = s.unacked.length;
+ }
+ s.unacked = s.unacked.slice(delta);
+ s.hOutAcked = h;
+ }
+
+ /**
+ * @param view
+ */
+ private _handleAck(view: StanzaView): void {
+ const s = this._state;
+ if (!s.enableSent && !s.enabled) {
+ return;
+ }
+ const h = parseH(view.attrs.h);
+ if (h === null) {
+ log.error('StreamManagement: received without a valid h attribute');
+ return;
+ }
+ this._reconcile(h);
+ s.sinceLastAck = 0;
+ this._persist();
+ }
+
+ /**
+ * @param view
+ */
+ private _handleEnabled(view: StanzaView): void {
+ const s = this._state;
+ if (!s.enableSent) {
+ log.warn('StreamManagement: received but was not sent; ignoring');
+ return;
+ }
+ s.enabled = true;
+ s.id = view.attrs.id || null;
+ const max = parseInt(view.attrs.max, 10);
+ s.max = Number.isNaN(max) ? null : max;
+ s.location = view.attrs.location || null;
+ s.resumeSupported = ['true', '1'].includes(view.attrs.resume);
+ this._resendPending();
+ this._persist();
+ this.onEnabled();
+ }
+
+ /**
+ * @param view
+ */
+ private _handleResumed(view: StanzaView): void {
+ const s = this._state;
+ if (!s.id) {
+ log.warn('StreamManagement: received without resumable state; ignoring');
+ return;
+ }
+ this._resumePending = false;
+ s.resumed = true;
+ s.enabled = true;
+ s.enableSent = true;
+ const h = parseH(view.attrs.h);
+ if (h !== null) {
+ this._reconcile(h);
+ }
+ s.sinceLastAck = 0;
+ // Re-send whatever the server didn't acknowledge (a MUST, XEP-0198
+ // §5). The entries stay in `unacked` — they're still unacknowledged —
+ // and are not re-tracked.
+ for (const entry of s.unacked) {
+ this._sendRaw(entry.stanza);
+ }
+ if (s.unacked.length) {
+ this.requestAck();
+ }
+ this._persist();
+ this.onResumed();
+ }
+
+ /**
+ * or failed. Trim the queue by the optional 'h' on
+ * , reset the dead session and clear its persisted state.
+ *
+ * Only a failed *resumption* strands sent-but-undelivered stanzas, so
+ * only then is the remaining queue salvaged for re-send once a fresh
+ * session reaches (a SHOULD, XEP-0198 §4). When
+ * answers an the stream is alive and bound — everything in
+ * `unacked` was delivered normally; re-sending it later would duplicate
+ * it.
+ * @param view
+ */
+ private _handleFailed(view: StanzaView): void {
+ const s = this._state;
+ if (!s.enableSent && !s.id) {
+ return;
+ }
+ const resumeFailed = this._resumePending;
+ this._resumePending = false;
+ const h = parseH(view.attrs.h);
+ if (h !== null) {
+ this._reconcile(h);
+ }
+ if (resumeFailed) {
+ this._pendingResend = this._pendingResend.concat(s.unacked);
+ }
+ this._state = freshState();
+ this.clearPersistedState();
+ this.onFailed(view, resumeFailed);
+ }
+
+ /**
+ * Re-send stanzas salvaged from a failed resumption on the freshly
+ * enabled session. Messages are stamped with a XEP-0203 carrying
+ * their original send time. The re-sent stanzas enter the new session's
+ * unacked queue in wire order.
+ */
+ private _resendPending(): void {
+ const pending = this._pendingResend;
+ if (!pending.length) {
+ return;
+ }
+ this._pendingResend = [];
+ const s = this._state;
+ for (const entry of pending) {
+ const stanza =
+ entry.name === 'message' ? stampDelay(entry.stanza, entry.name, entry.queuedAt) : entry.stanza;
+ this._sendRaw(stanza);
+ s.unacked.push({ ...entry, stanza });
+ s.sinceLastAck += 1;
+ }
+ this.requestAck();
+ }
+
+ /** Persist the current state, if a storage key has been configured. */
+ private _persist(): void {
+ if (this._storageKey) {
+ this._storage.save(this._storageKey, this._state);
+ }
+ }
+}
+
+export default StreamManagement;
diff --git a/src/stream-management/index.ts b/src/stream-management/index.ts
new file mode 100644
index 00000000..3285fcb4
--- /dev/null
+++ b/src/stream-management/index.ts
@@ -0,0 +1,29 @@
+/**
+ * XEP-0198 Stream Management.
+ *
+ * One engine, two hosts: the environment-free {@link StreamManagement} core
+ * (engine.ts) is owned by the page-side Connection for plain websocket
+ * connections, or by the shared connection worker when the `worker` option
+ * is set, in which case the page holds only a {@link StreamManagementMirror}.
+ * The engine consumes {@link StanzaView}s produced by a thin per-environment
+ * adapter: dom.ts (Element → view, page) or parse.ts (frame string → view,
+ * worker).
+ *
+ * The worker bundle (dist/shared-connection-worker.js) imports engine.ts and
+ * parse.ts directly so the page-only modules (dom.ts, mirror.ts) stay out of
+ * it by construction.
+ */
+export { default } from './engine';
+export { MemoryStorageBackend, SessionStorageBackend } from './storage';
+export { StreamManagementMirror } from './mirror';
+export { isCountableStanza, stampDelay } from './utils';
+export { toStanzaView } from './dom';
+export { peekElement } from './parse';
+export type {
+ StanzaView,
+ QueuedStanza,
+ SMState,
+ SMStorageBackend,
+ StreamManagementController,
+ StreamManagementOptions,
+} from './types';
diff --git a/src/stream-management/mirror.ts b/src/stream-management/mirror.ts
new file mode 100644
index 00000000..b3284146
--- /dev/null
+++ b/src/stream-management/mirror.ts
@@ -0,0 +1,134 @@
+/**
+ * XEP-0198 Stream Management
+ *
+ * The page-side state mirror for worker mode.
+ *
+ * Page-only: never imported by the shared-connection-worker bundle.
+ */
+import { freshState } from './utils';
+import { SMState, StanzaView, StreamManagementController } from './types';
+
+/**
+ * A page-side stand-in for the worker-resident engine: when the connection
+ * runs through a SharedWorker, the worker owns all counting, queueing and
+ * nonza handling, and the page only mirrors the session state so that
+ * `Connection.hasResumed()` and friends keep working.
+ */
+export class StreamManagementMirror implements StreamManagementController {
+ serverSupported = false;
+ private _state: SMState = freshState();
+
+ /* Read surface. Reflects the worker-owned session state. */
+
+ get state(): SMState {
+ return this._state;
+ }
+
+ get enabled(): boolean {
+ return this._state.enabled;
+ }
+
+ get resumed(): boolean {
+ return this._state.resumed;
+ }
+
+ get boundJid(): string {
+ return this._state.boundJid;
+ }
+
+ get resumePending(): boolean {
+ // The worker owns resumption; the page never has a in flight.
+ return false;
+ }
+
+ /* Inert active surface. The worker owns all counting/queueing/nonzas. */
+
+ initialize(_jid: string): void {
+ return;
+ }
+
+ hasResumableState(): boolean {
+ return false;
+ }
+
+ reset(): void {
+ this._state = freshState();
+ this.serverSupported = false;
+ }
+
+ sendResume(): void {
+ return;
+ }
+
+ sendEnable(_boundJid: string): void {
+ return;
+ }
+
+ isTracking(): boolean {
+ return false;
+ }
+
+ trackOutbound(_view: StanzaView): void {
+ return;
+ }
+
+ onInbound(_view: StanzaView): void {
+ return;
+ }
+
+ onInboundStanza(_name: string): void {
+ return;
+ }
+
+ onGracefulClose(): void {
+ return;
+ }
+
+ /* Overridable event hooks (a shim overrides these to re-emit on its bus). */
+
+ onEnabled(): void {
+ return;
+ }
+
+ onResumed(): void {
+ return;
+ }
+
+ onFailed(_view?: StanzaView, _resumeFailed?: boolean): void {
+ return;
+ }
+
+ /* Mirror updates, driven by the worker's lifecycle messages. */
+
+ /**
+ * @param id - The SM-ID of the fresh session.
+ * @param max - The server's preferred maximum resumption time.
+ * @param boundJid - The JID that was bound when the session was enabled.
+ */
+ _onEnabled(id: string, max: number, boundJid: string): void {
+ const s = this._state;
+ s.enabled = true;
+ s.id = id;
+ s.max = max;
+ s.boundJid = boundJid;
+ this.onEnabled();
+ }
+
+ /**
+ * @param boundJid - The worker's boundJid for the resumed session.
+ */
+ _onResumed(boundJid: string): void {
+ const s = this._state;
+ s.resumed = true;
+ s.enabled = true;
+ s.boundJid = boundJid;
+ this.onResumed();
+ }
+
+ _onFailed(): void {
+ this._state = freshState();
+ // _smFailed is only broadcast for a failed *resumption* (a refused
+ // stays inside the worker), so resumeFailed is always true.
+ this.onFailed(undefined, true);
+ }
+}
diff --git a/src/stream-management/parse.ts b/src/stream-management/parse.ts
new file mode 100644
index 00000000..9d794964
--- /dev/null
+++ b/src/stream-management/parse.ts
@@ -0,0 +1,55 @@
+/**
+ * XEP-0198 Stream Management
+ *
+ * The worker-side {@link StanzaView} adapter:
+ * DOM-free peeking at the opening tag of a websocket frame.
+ *
+ * `DOMParser` is a Window interface that doesn't exist in a SharedWorker
+ * global, so the worker-resident XEP-0198 engine can't parse frames the way
+ * the page does (see dom.ts). It doesn't need to: RFC 7395 delivers exactly
+ * one top-level stanza or nonza per websocket frame, so the engine's
+ * {@link StanzaView} (tag name plus a few attributes) can be extracted from
+ * the opening tag with a regex. Divergence between the page and worker
+ * environments is prevented by sharing the engine, not the parser.
+ */
+import type { StanzaView } from './types';
+
+const OPENING_TAG = /^\s*<([a-zA-Z][^\s/>]*)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*\/?>/;
+const ATTRIBUTE = /([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
+
+/**
+ * Unescapes invalid xml characters (duplicated from ../utils.ts so the worker
+ * bundle doesn't pull in utils and its dependencies).
+ * @param text
+ */
+function xmlUnescape(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/'/g, "'")
+ .replace(/"/g, '"');
+}
+
+/**
+ * Extract a {@link StanzaView} from a raw websocket frame by inspecting its
+ * opening tag. Namespace prefixes are stripped from the tag name and
+ * attribute values are XML-unescaped; the frame itself becomes the view's
+ * `serialized` payload.
+ * @param frame - The raw frame string.
+ * @returns The view, or null if the frame doesn't start with a parseable tag.
+ */
+export function peekElement(frame: string): StanzaView | null {
+ const m = OPENING_TAG.exec(frame);
+ if (!m) {
+ return null;
+ }
+ const name = m[1].includes(':') ? m[1].slice(m[1].indexOf(':') + 1) : m[1];
+ const attrs: Record = {};
+ ATTRIBUTE.lastIndex = 0;
+ let attr;
+ while ((attr = ATTRIBUTE.exec(m[2])) !== null) {
+ attrs[attr[1]] = xmlUnescape(attr[2] !== undefined ? attr[2] : attr[3]);
+ }
+ return { name, attrs, serialized: frame };
+}
diff --git a/src/stream-management/storage.ts b/src/stream-management/storage.ts
new file mode 100644
index 00000000..721efcb7
--- /dev/null
+++ b/src/stream-management/storage.ts
@@ -0,0 +1,87 @@
+/**
+ * XEP-0198 Stream Management
+ *
+ * Storage backends for resumable session state.
+ *
+ * MemoryStorageBackend is environment-free and safe in the worker bundle.
+ * SessionStorageBackend requires a browser page context, but only at
+ * construction time, there is no module-level DOM access here.
+ */
+import log from '../log';
+import { SMState, SMStorageBackend } from './types';
+
+/**
+ * In-memory storage backend (Node, tests, or non-resumable setups).
+ * State is serialized on save so callers can't mutate stored state by reference.
+ */
+export class MemoryStorageBackend implements SMStorageBackend {
+ private store: Map;
+
+ constructor() {
+ this.store = new Map();
+ }
+
+ /**
+ * @param key
+ */
+ load(key: string): SMState {
+ const stored = this.store.get(key);
+ return stored ? JSON.parse(stored) : null;
+ }
+
+ /**
+ * @param key
+ * @param state
+ */
+ save(key: string, state: SMState): void {
+ this.store.set(key, JSON.stringify(state));
+ }
+
+ /**
+ * @param key
+ */
+ clear(key: string): void {
+ this.store.delete(key);
+ }
+}
+
+/**
+ * sessionStorage-backed storage backend (browser pages).
+ * Survives page reloads within a tab, which is exactly the resumption window.
+ */
+export class SessionStorageBackend implements SMStorageBackend {
+ constructor() {
+ if (typeof sessionStorage === 'undefined') {
+ throw new Error('SessionStorageBackend requires a sessionStorage global (browser page context)');
+ }
+ }
+
+ /**
+ * @param key
+ */
+ load(key: string): SMState {
+ const stored = sessionStorage.getItem(key);
+ if (!stored) return null;
+ try {
+ return JSON.parse(stored);
+ } catch (e) {
+ log.warn(`Discarding unparseable SM state for ${key}: ${e.message}`);
+ return null;
+ }
+ }
+
+ /**
+ * @param key
+ * @param state
+ */
+ save(key: string, state: SMState): void {
+ sessionStorage.setItem(key, JSON.stringify(state));
+ }
+
+ /**
+ * @param key
+ */
+ clear(key: string): void {
+ sessionStorage.removeItem(key);
+ }
+}
diff --git a/src/stream-management/types.ts b/src/stream-management/types.ts
new file mode 100644
index 00000000..964df4f7
--- /dev/null
+++ b/src/stream-management/types.ts
@@ -0,0 +1,117 @@
+/**
+ * XEP-0198 Stream Management
+ *
+ * Everything in this module is environment-free (no DOM, no worker globals)
+ * so it can be imported from both the page bundle and the standalone
+ * shared-connection-worker bundle.
+ */
+
+/**
+ * A minimal, environment-free view of a top-level stanza or nonza.
+ *
+ * The page adapter builds it from a DOM Element (see dom.ts), the worker
+ * adapter from a raw frame string (see parse.ts).
+ */
+export interface StanzaView {
+ name: string; /** The local (unprefixed) tag name of the top-level element. */
+ attrs: Record; /** The attributes of the top-level element. */
+ serialized: string; /** The serialized element used as the unacked queue payload. */
+}
+
+/** An entry in the unacked queue. */
+export interface QueuedStanza {
+ name: string; /** The local tag name (needed to decide XEP-0203 delay-stamping on re-send). */
+ stanza: string; /** The serialized stanza. */
+ queuedAt: number; /** When the stanza was first queued (ms since epoch) the XEP-0203 delay stamp value. */
+}
+
+/** The serialisable Stream Management session state. */
+export interface SMState {
+ /**
+ * Whether has been sent on the current stream. The XEP starts the
+ * two counters asymmetrically: outbound tracking begins at enable-*send*,
+ * inbound counting at -*receipt* (XEP-0198 §4, note).
+ */
+ enableSent: boolean;
+ enabled: boolean; /** Whether has been received; inbound counting is active. */
+ id: string; /** The SM-ID from ; sent as `previd` when resuming. */
+ boundJid: string; /** The full JID that was bound when was sent (re-applied resume). */
+ max: number; /** Server's preferred max resumption time in seconds (from ). */
+ location: string; /** Server's preferred reconnection location (from ). Stored but not acted upon. */
+ resumeSupported: boolean; /** Whether the server allows this session to be resumed. */
+ resumed: boolean; /** Whether the current session was established by resuming a previous one. */
+ hIn: number; /** Count of inbound stanzas handled by us. The client's 'h', mod 2^32. */
+ hOutAcked: number; /** The last 'h' value acknowledged by the server. */
+ unacked: QueuedStanza[]; /** Outbound stanzas sent but not yet acknowledged by the server. */
+ sinceLastAck: number; /** Outbound countable stanzas since the server's last . */
+}
+
+/**
+ * Pluggable persistence for resumable SM state.
+ * Implementations must be synchronous. The handler runs inside the
+ * synchronous stanza dispatch and state must be consistent immediately after.
+ */
+export interface SMStorageBackend {
+ load(key: string): SMState | null;
+ save(key: string, state: SMState): void;
+ clear(key: string): void;
+}
+
+export interface StreamManagementOptions {
+ maxUnacked?: number; /** Request an ack via after this many outbound stanzas. 0 disables. Default: 5. */
+ requestResume?: boolean; /** Ask for a resumable session (resume="true" on ). Default: true. */
+ max?: number; /** The client's preferred max resumption time in seconds (max attribute on ). */
+ storage?: SMStorageBackend; /** Storage backend for resumable state. Default: per-instance MemoryStorageBackend. */
+}
+
+/**
+ * The Stream Management surface a page-side Connection depends on, implemented
+ * by both the worker-free engine (engine.ts) and the worker-mode page mirror
+ * (mirror.ts). Typing `Connection.sm` against this (rather than the concrete
+ * engine class) is what lets the mirror be a peer implementation instead of a
+ * subclass full of neutralised overrides: any member added here must be
+ * implemented by both sides, so the mirror can never silently inherit a live
+ * engine method it was meant to render inert.
+ */
+export interface StreamManagementController {
+ /** Whether the current stream's features advertised . */
+ serverSupported: boolean;
+ /** The live SM session state (fields are mutable; the reference is not reassigned). */
+ readonly state: SMState;
+ /** Whether has been received and the session is active. */
+ readonly enabled: boolean;
+ /** Whether the current session was established by resumption. */
+ readonly resumed: boolean;
+ /** The full JID bound when the session was enabled. */
+ readonly boundJid: string;
+ /** Whether a has been sent and not yet answered. */
+ readonly resumePending: boolean;
+
+ /** Load persisted resumable state for the given (bare) JID. */
+ initialize(jid: string): void;
+ /** Whether persisted state allows attempting . */
+ hasResumableState(): boolean;
+ /** Reset the in-memory session state. */
+ reset(): void;
+ /** Send for the persisted previous session. */
+ sendResume(): void;
+ /** Send to start a new session. */
+ sendEnable(boundJid: string): void;
+ /** Whether outbound stanzas are currently being tracked (i.e. was sent). */
+ isTracking(): boolean;
+ /** Track an outbound top-level element. */
+ trackOutbound(view: StanzaView): void;
+ /** Process an inbound top-level element (count a stanza or handle a nonza). */
+ onInbound(view: StanzaView): void;
+ /** Count an inbound top-level stanza by name. */
+ onInboundStanza(name: string): void;
+ /** End the session cleanly: final and clear persisted state. */
+ onGracefulClose(): void;
+
+ /** Overridable event hook: a session was established via . */
+ onEnabled(): void;
+ /** Overridable event hook: the previous session was resumed via . */
+ onResumed(): void;
+ /** Overridable event hook: or failed. */
+ onFailed(view?: StanzaView, resumeFailed?: boolean): void;
+}
diff --git a/src/stream-management/utils.ts b/src/stream-management/utils.ts
new file mode 100644
index 00000000..f7b7670c
--- /dev/null
+++ b/src/stream-management/utils.ts
@@ -0,0 +1,95 @@
+/**
+ * XEP-0198 Stream Management
+ *
+ * Environment-free helpers.
+ *
+ * IMPORTANT: this module must be loadable in a SharedWorker global (it is
+ * bundled into dist/shared-connection-worker.js), so keep it free of imports
+ * beyond constants (no ../utils.ts or ../builder.ts, and no *module-level DOM access).
+ */
+import { NS } from '../constants';
+import { SMState } from './types';
+
+/** The 'h' counter is an unsignedInt that wraps back to zero (XEP-0198 §4). */
+export const H_WRAP = 2 ** 32;
+
+/** Stanza names that count towards the 'h' counters (XEP-0198 §4). */
+const COUNTABLE = ['iq', 'presence', 'message'];
+
+/**
+ * Escapes invalid xml characters (duplicated from ../utils.ts so the worker
+ * bundle doesn't pull in utils and its dependencies).
+ * @param text
+ */
+export function xmlEscape(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/'/g, ''')
+ .replace(/"/g, '"');
+}
+
+/**
+ * @returns A fresh, inactive SM state object.
+ */
+export function freshState(): SMState {
+ return {
+ boundJid: null,
+ enableSent: false,
+ enabled: false,
+ hIn: 0,
+ hOutAcked: 0,
+ id: null,
+ location: null,
+ max: null,
+ resumeSupported: false,
+ resumed: false,
+ sinceLastAck: 0,
+ unacked: [],
+ };
+}
+
+/**
+ * Parse an 'h' attribute value into a wrapped unsigned 32-bit counter value.
+ * @param value - The attribute value (may be undefined).
+ * @returns The parsed value, or null if missing or invalid.
+ */
+export function parseH(value: string): number {
+ if (typeof value !== 'string') return null;
+ const h = parseInt(value, 10);
+ return Number.isNaN(h) || h < 0 ? null : h % H_WRAP;
+}
+
+/**
+ * @param name - A top-level element's local tag name.
+ * @returns true if the element counts towards the SM 'h' counters.
+ */
+export function isCountableStanza(name: string): boolean {
+ return COUNTABLE.includes(name);
+}
+
+/**
+ * Insert a XEP-0203 child into a serialized stanza (DOM-free string
+ * surgery). Used when re-sending salvaged stanzas after a failed resumption,
+ * so the receiving client can show the original send time.
+ * @param serialized - The serialized stanza.
+ * @param name - The stanza's local tag name.
+ * @param queuedAt - The original queueing time (ms since epoch).
+ * @returns The stanza with a child, or the unmodified input if it
+ * already carries one (or is malformed).
+ */
+export function stampDelay(serialized: string, name: string, queuedAt: number): string {
+ if (serialized.includes(NS.DELAY)) {
+ return serialized;
+ }
+ const delay = ``;
+ if (serialized.endsWith('/>')) {
+ return `${serialized.slice(0, -2)}>${delay}${name}>`;
+ }
+ const idx = serialized.lastIndexOf(`${name}`);
+ if (idx === -1) {
+ return serialized;
+ }
+ return serialized.slice(0, idx) + delay + serialized.slice(idx);
+}
diff --git a/src/utils.ts b/src/utils.ts
index 459c19db..4f17105e 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -199,7 +199,7 @@ export function stripWhitespace(stanza: Element): Element {
if (node.nodeName.toLowerCase() === 'body') {
return;
}
- if (node.nodeType === ElementType.TEXT && !(/\S/).test(node.nodeValue)) {
+ if (node.nodeType === ElementType.TEXT && !/\S/.test(node.nodeValue)) {
stanza.removeChild(node);
} else if (node.nodeType === ElementType.NORMAL) {
stripWhitespace(node as Element);
@@ -472,12 +472,12 @@ export function copyElement(node: Node): Element | Text | undefined {
* @returns Escaped text.
*/
export function xmlescape(text: string): string {
- text = text.replace(/\&/g, '&');
- text = text.replace(//g, '>');
- text = text.replace(/'/g, ''');
- text = text.replace(/"/g, '"');
- return text;
+ return text
+ .replace(/\&/g, '&')
+ .replace(//g, '>')
+ .replace(/'/g, ''')
+ .replace(/"/g, '"');
}
/**
diff --git a/src/websocket.ts b/src/websocket.ts
index 6cba2ec3..4da983e9 100644
--- a/src/websocket.ts
+++ b/src/websocket.ts
@@ -11,7 +11,7 @@ import type Connection from './connection';
import Builder, { $build } from './builder';
import log from './log';
import { NS, ErrorCondition, Status } from './constants';
-import {WebsocketLike} from 'types';
+import {WebsocketLike} from './types';
/**
* Helper class that handles WebSocket Connections
diff --git a/src/worker-websocket.ts b/src/worker-websocket.ts
index e73996d8..ad3e6692 100644
--- a/src/worker-websocket.ts
+++ b/src/worker-websocket.ts
@@ -2,8 +2,10 @@ import type Connection from './connection';
import Websocket from './websocket';
import log from './log';
import Builder, { $build } from './builder';
-import { LOG_LEVELS, NS, Status } from './constants';
-import { WebsocketLike } from 'types';
+import { LOG_LEVELS, NS, SHARED_WORKER_PROTOCOL_VERSION, Status } from './constants';
+import { toElement } from './utils';
+import { WebsocketLike } from './types';
+import type { StreamManagementMirror } from './stream-management';
/**
* Helper class that handles a websocket connection inside a shared worker.
@@ -12,6 +14,7 @@ class WorkerWebsocket extends Websocket {
worker: SharedWorker;
_messageHandler: (m: MessageEvent) => void;
socket: WebsocketLike | null;
+ private _lifecycleAttached: boolean;
/**
* Create and initialize a WorkerWebsocket object.
@@ -47,7 +50,8 @@ class WorkerWebsocket extends Websocket {
this._messageHandler = (m: MessageEvent) => this._onInitialMessage(m);
this.worker.port.start();
this.worker.port.onmessage = (ev) => this._onWorkerMessage(ev);
- this.worker.port.postMessage(['_connect', this._conn.service, this._conn.jid]);
+ this.worker.port.postMessage(['_connect', this._conn.service, this._conn.jid, SHARED_WORKER_PROTOCOL_VERSION]);
+ this._attachLifecycleListeners();
}
/**
@@ -59,7 +63,176 @@ class WorkerWebsocket extends Websocket {
this._conn.connect_callback = callback;
this.worker.port.start();
this.worker.port.onmessage = (ev) => this._onWorkerMessage(ev);
- this.worker.port.postMessage(['_attach', this._conn.service]);
+ this.worker.port.postMessage(['_attach', this._conn.service, SHARED_WORKER_PROTOCOL_VERSION]);
+ this._attachLifecycleListeners();
+ }
+
+ /**
+ * Called by the worker to assign this tab's role. A secondary shares the
+ * already-established session, so it must not treat inbound frames as its
+ * own connection handshake.
+ * @param role
+ * @param jid - The shared connection's JID.
+ */
+ _role(role: 'primary' | 'secondary', jid?: string): void {
+ this._conn.role = role;
+ if (role === 'secondary') {
+ this._messageHandler = (m: MessageEvent) => this._onMessage(m);
+ if (jid) this._conn.jid = jid;
+ }
+ this._conn.onRoleChanged(role);
+ }
+
+ /**
+ * Called by the worker when this tab is promoted to primary after the
+ * previous primary went away. Same socket — no reconnect happens.
+ * @param jid - The shared connection's JID.
+ */
+ _promote(jid?: string): void {
+ this._conn.role = 'primary';
+ if (jid) this._conn.jid = jid;
+ this._conn.onRoleChanged('primary');
+ }
+
+ /**
+ * Ask the worker to negotiate XEP-0198 for the freshly authenticated
+ * stream (§2.3): it sends if it holds resumable state,
+ * otherwise it replies with _smNoState and the connection proceeds to
+ * bind a resource.
+ */
+ _smFeatures(): void {
+ this.worker.port.postMessage(['_smFeatures']);
+ }
+
+ /**
+ * Report that the connect flow completed (resource bound, or legacy
+ * auth/session establishment finished). Sent with or without SM: the
+ * worker adopts the bound JID as the shared one, releases tabs parked on
+ * the handshake, and starts a fresh SM session itself when this stream's
+ * features advertised support.
+ * @param jid - The server-assigned full JID.
+ */
+ _bound(jid: string): void {
+ this.worker.port.postMessage(['_bound', jid]);
+ }
+
+ /**
+ * Liveness probe from the worker. Answered from this message handler —
+ * which runs even when the browser throttles this tab's timers — so a
+ * merely-backgrounded tab never looks dead to the worker.
+ */
+ _ping(): void {
+ this.worker.port.postMessage(['_pong']);
+ }
+
+ /**
+ * Called by the worker when another tab sent a message or presence
+ * stanza over the shared connection. Handed to its own overridable hook
+ * — never into the inbound pipeline (_dataRecv), where it would hit
+ * stanza handlers, SM counting and xmlInput as if it were received
+ * traffic.
+ * @param data - The serialized stanza.
+ */
+ _onStanzaSent(data: string): void {
+ this._conn.onForeignStanzaSent(toElement(data));
+ }
+
+ /**
+ * Called by the worker when it has no resumable state: continue the
+ * connect flow with resource binding.
+ */
+ _smNoState(): void {
+ this._conn._proceedToBind();
+ }
+
+ /**
+ * Called by the worker when a fresh SM session was established.
+ * @param id - The SM-ID.
+ * @param max - The server's preferred maximum resumption time.
+ * @param boundJid - The JID that was bound when the session was enabled.
+ */
+ _smEnabled(id: string, max: number, boundJid: string): void {
+ (this._conn.sm as StreamManagementMirror)?._onEnabled(id, max, boundJid);
+ }
+
+ /**
+ * Called by the worker when the previous session was resumed. Every tab
+ * adopts the originally bound JID; the primary — which drove the connect
+ * flow — additionally restores the connection state and emits CONNECTED
+ * (the same actions a non-worker connection applies on ).
+ * @param jid - The worker's boundJid.
+ */
+ _smResumed(jid: string): void {
+ const conn = this._conn;
+ (conn.sm as StreamManagementMirror)?._onResumed(jid);
+ conn.jid = jid;
+ if (conn.role === 'primary') {
+ conn.do_bind = false;
+ conn.authenticated = true;
+ conn.restored = true;
+ conn._changeConnectStatus(Status.CONNECTED, null);
+ }
+ }
+
+ /**
+ * Called by the worker when resumption failed: the primary falls back
+ * to binding a resource on this same stream (the salvaged queue stays
+ * in the worker and is re-sent after the next ).
+ */
+ _smFailed(): void {
+ const conn = this._conn;
+ (conn.sm as StreamManagementMirror)?._onFailed();
+ if (conn.role === 'primary') {
+ conn.do_bind = true;
+ conn._proceedToBind();
+ }
+ }
+
+ /**
+ * Wire the page lifecycle into the worker's port bookkeeping: `_bye` on
+ * pagehide (graceful removal + failover), `_relinquish` on freeze (hand
+ * the primary role over *before* this tab's CPU stops), and a `_pong`
+ * when the page comes back (which also re-admits this port if the worker
+ * dropped it while we were away). Routine liveness is worker-driven
+ * ping/pong (see {@link WorkerWebsocket#_ping}) — deliberately no
+ * page-side timers, because hidden tabs may only run timers once every
+ * ten minutes.
+ */
+ private _attachLifecycleListeners(): void {
+ if (this._lifecycleAttached || typeof window === 'undefined') {
+ return;
+ }
+ window.addEventListener('pagehide', () => this.worker.port.postMessage(['_bye']));
+ window.addEventListener('pageshow', () => this.worker.port.postMessage(['_pong']));
+ document.addEventListener('freeze', () => this.worker.port.postMessage(['_relinquish']));
+ document.addEventListener('resume', () => this.worker.port.postMessage(['_pong']));
+ this._lifecycleAttached = true;
+ }
+
+ /**
+ * Called when the worker reports that the shared socket closed or died.
+ * Unlike a page-owned websocket this can arrive while this tab believes
+ * it is still connecting. The worker owns the socket, and e.g. its
+ * connection attempt to the server can fail. The base implementation
+ * ignores closes in that state (it only knows real CloseEvents), which
+ * would leave this tab stuck in CONNECTING forever, so fail the
+ * connection attempt instead: the embedder's reconnect logic takes it
+ * from there.
+ * @param e - The close reason from the worker, or a close event.
+ */
+ _onClose(e?: CloseEvent | string): void {
+ if (this._conn.connected && !this._conn.disconnecting) {
+ log.error('Websocket closed unexpectedly');
+ this._conn._doDisconnect();
+ } else if (!this._conn.connected && !this._conn.disconnecting) {
+ const reason =
+ typeof e === 'string' ? e : 'The shared websocket connection could not be established or was lost.';
+ log.error(`Shared websocket closed while connecting: ${reason}`);
+ this._conn._changeConnectStatus(Status.CONNFAIL, reason);
+ this._conn._doDisconnect();
+ } else {
+ log.debug('Websocket closed');
+ }
}
/**
diff --git a/tests/sm-parse.test.ts b/tests/sm-parse.test.ts
new file mode 100644
index 00000000..5ffb3e7f
--- /dev/null
+++ b/tests/sm-parse.test.ts
@@ -0,0 +1,58 @@
+import { peekElement } from '../src/stream-management/parse';
+import { describe, it, expect } from 'vitest';
+
+describe('peekElement', () => {
+ it('parses stanzas with attributes and children', () => {
+ const frame =
+ "hi";
+ const view = peekElement(frame);
+ expect(view.name).toBe('message');
+ expect(view.attrs).toEqual({
+ from: 'juliet@example.net/balcony',
+ to: 'romeo@example.net',
+ type: 'chat',
+ });
+ expect(view.serialized).toBe(frame);
+ });
+
+ it('parses real SM nonza frames', () => {
+ // verbatim Prosody frames
+ expect(peekElement("")).toMatchObject(
+ { name: 'enabled', attrs: { xmlns: 'urn:xmpp:sm:3', max: '600', resume: 'true', id: 'uaojrU5jIfN7' } },
+ );
+ expect(peekElement("")).toMatchObject({ name: 'a', attrs: { h: '1' } });
+ expect(peekElement("").name).toBe('r');
+ expect(peekElement("").attrs.previd).toBe(
+ 'uaojrU5jIfN7',
+ );
+ });
+
+ it('tolerates leading whitespace and newlines between attributes', () => {
+ const view = peekElement('\n ');
+ expect(view.name).toBe('presence');
+ expect(view.attrs).toEqual({ from: 'a@b/c', type: 'unavailable' });
+ });
+
+ it('strips namespace prefixes from the tag name', () => {
+ const view = peekElement(
+ "",
+ );
+ expect(view.name).toBe('features');
+ });
+
+ it('unescapes attribute values', () => {
+ const view = peekElement('');
+ expect(view.attrs.previd).toBe('ab"& {
+ expect(peekElement('')).toMatchObject({ name: 'iq', attrs: {} });
+ });
+
+ it('returns null for junk', () => {
+ expect(peekElement('')).toBeNull();
+ expect(peekElement('plain text')).toBeNull();
+ expect(peekElement('')).toBeNull();
+ expect(peekElement('')).toBeNull();
+ });
+});
diff --git a/tests/stream-management-connection.test.ts b/tests/stream-management-connection.test.ts
new file mode 100644
index 00000000..8013e636
--- /dev/null
+++ b/tests/stream-management-connection.test.ts
@@ -0,0 +1,254 @@
+import { Strophe, $msg, MemoryStorageBackend } from '../dist/strophe.node.esm.js';
+import { describe, it, expect } from 'vitest';
+
+const SM_NS = 'urn:xmpp:sm:3';
+const BIND_NS = 'urn:ietf:params:xml:ns:xmpp-bind';
+const { Status } = Strophe;
+
+function parse(xml: string): Element {
+ return new DOMParser().parseFromString(xml, 'text/xml').documentElement;
+}
+
+/**
+ * Nonzas on the wire are re-serialized from parsed Elements, so attribute
+ * order isn't stable — match them semantically instead of by string.
+ */
+function isNonza(s: string | undefined, name: string, attrs: Record = {}): boolean {
+ if (!s || !s.startsWith(`<${name}`)) return false;
+ const el = parse(s);
+ return (
+ el.tagName === name &&
+ el.getAttribute('xmlns') === SM_NS &&
+ Object.entries(attrs).every(([k, v]) => el.getAttribute(k) === v)
+ );
+}
+
+/**
+ * A websocket connection with a stubbed socket that records the wire.
+ */
+function makeConn(options: Record = {}) {
+ const wire: string[] = [];
+ const statuses: number[] = [];
+ const conn = new Strophe.Connection('wss://example.org/xmpp-websocket', {
+ enableStreamManagement: true,
+ ...options,
+ });
+ (conn._proto as any).socket = { send: (s: string) => wire.push(s), readyState: 1 };
+ conn.connected = true;
+ conn.connect_callback = (status: number) => {
+ statuses.push(status);
+ };
+ conn.jid = 'romeo@example.net';
+ return { conn, wire, statuses };
+}
+
+/** Feed an inbound frame through the connection's dispatch. */
+function recv(conn: InstanceType, xml: string) {
+ conn._dataRecv(parse(`${xml}`));
+}
+
+/** Simulate the post-SASL stream features. */
+function sendFeatures(conn: InstanceType, extra = ``) {
+ (conn as any)._onStreamFeaturesAfterSASL(parse(`${extra}`));
+}
+
+/** Answer the bind IQ with a server-assigned full JID. */
+function bindResult(conn: InstanceType, jid = 'romeo@example.net/orchard') {
+ recv(conn, `${jid}`);
+}
+
+/** Drive a connection through bind + enable + enabled. */
+function establish(conn: InstanceType, id = 'sm-id-1') {
+ sendFeatures(conn);
+ bindResult(conn);
+ recv(conn, ``);
+}
+
+describe('Stream Management connection integration', () => {
+ it('creates the engine when enabled, but only negotiates over websocket', () => {
+ expect(makeConn().conn.sm).toBeDefined();
+ expect(new Strophe.Connection('wss://example.org/ws').sm).toBeUndefined();
+
+ // The transport can be swapped after construction (e.g. XEP-0156
+ // discovery), so the engine exists for a BOSH-constructed connection
+ // too — but the post-SASL features must not start SM negotiation on
+ // a BOSH stream.
+ const bosh = new Strophe.Connection('http://fake', { enableStreamManagement: true });
+ expect(bosh.sm).toBeDefined();
+ const sent: string[] = [];
+ bosh.rawOutput = (data: string) => sent.push(data);
+ (bosh as any)._onStreamFeaturesAfterSASL(
+ new DOMParser().parseFromString(
+ ``,
+ 'text/xml',
+ ).documentElement,
+ );
+ expect(bosh.sm.serverSupported).toBe(true);
+ (bosh as any)._onSessionReady();
+ expect(bosh.sm.isTracking()).toBe(false); // no sent over BOSH
+ });
+
+ it('negotiates a fresh SM session at the CONNECTED-emission point', () => {
+ const { conn, wire, statuses } = makeConn();
+ sendFeatures(conn);
+ expect(conn.sm.serverSupported).toBe(true);
+ // no resumable state → the normal bind flow ran
+ expect(wire.some((s) => s.includes(BIND_NS))).toBe(true);
+ bindResult(conn);
+ expect(conn.jid).toBe('romeo@example.net/orchard');
+ expect(isNonza(wire.at(-1), 'enable', { resume: 'true' })).toBe(true);
+ expect(statuses).toContain(Status.CONNECTED);
+ expect(conn.sm.boundJid).toBe('romeo@example.net/orchard');
+
+ // outbound tracking is active from enable-send (before )
+ conn.send($msg({ to: 'juliet@example.net' }).c('body').t('early'));
+ expect(conn.sm.state.unacked.length).toBe(1);
+ // ...but inbound counting only starts at receipt
+ recv(conn, 'too early');
+ expect(conn.sm.state.hIn).toBe(0);
+
+ recv(conn, ``);
+ expect(conn.isStreamManagementEnabled()).toBe(true);
+ expect(conn.hasResumed()).toBe(false);
+ expect(conn.sm.state.id).toBe('sm-id-1');
+ });
+
+ it('does not negotiate SM when the server omits the feature', () => {
+ const { conn, wire } = makeConn();
+ sendFeatures(conn, '');
+ bindResult(conn);
+ expect(conn.sm.serverSupported).toBe(false);
+ expect(wire.some((s) => s.includes('', () => {
+ const { conn, wire } = makeConn();
+ establish(conn);
+ recv(conn, '1');
+ recv(conn, '');
+ recv(conn, ``);
+ expect(isNonza(wire.at(-1), 'a', { h: '2' })).toBe(true);
+ });
+
+ it('tracks all outbound stanzas via _queueData and requests acks in FIFO order', () => {
+ const { conn, wire } = makeConn({ streamManagement: { maxUnacked: 3 } });
+ establish(conn);
+ const before = wire.length;
+ for (let i = 1; i <= 3; i++) {
+ conn.send($msg({ to: 'juliet@example.net' }).c('body').t(`m${i}`));
+ }
+ const sent = wire.slice(before);
+ expect(conn.sm.state.unacked.length).toBe(3);
+ // the went out *after* the stanza that triggered it
+ expect(sent.findIndex((s) => isNonza(s, 'r'))).toBe(sent.findIndex((s) => s.includes('m3')) + 1);
+ recv(conn, ``);
+ expect(conn.sm.state.unacked.length).toBe(0);
+ });
+
+ it('resumes a dropped session and restores the bound JID (invalid-from regression)', () => {
+ const storage = new MemoryStorageBackend();
+ const first = makeConn({ streamManagement: { storage } });
+ establish(first.conn);
+ recv(first.conn, '1');
+ recv(first.conn, '2');
+ first.conn.send($msg({ to: 'juliet@example.net' }).c('body').t('lost'));
+ // the socket dies uncleanly here — resumable state is persisted
+
+ const second = makeConn({ streamManagement: { storage } });
+ // simulate what a naive reconnect does: a freshly generated resource
+ second.conn.jid = 'romeo@example.net/fresh-resource';
+ sendFeatures(second.conn);
+ // resumption is attempted instead of binding
+ expect(second.wire.length).toBe(1);
+ expect(isNonza(second.wire[0], 'resume', { h: '2', previd: 'sm-id-1' })).toBe(true);
+ expect(second.statuses).not.toContain(Status.BINDREQUIRED);
+
+ recv(second.conn, ``);
+ // the resumed session's bound JID is re-applied before CONNECTED:
+ expect(second.conn.jid).toBe('romeo@example.net/orchard');
+ expect(second.conn.hasResumed()).toBe(true);
+ expect(second.conn.do_bind).toBe(false);
+ expect(second.conn.authenticated).toBe(true);
+ expect(second.conn.restored).toBe(true);
+ expect(second.statuses).toContain(Status.CONNECTED);
+ // the unacknowledged stanza was re-sent, followed by an ack request
+ expect(second.wire.filter((s) => s.includes('lost')).length).toBe(1);
+ expect(isNonza(second.wire.at(-1), 'r')).toBe(true);
+ expect(second.conn.sm.state.hIn).toBe(2); // counters carried over
+ });
+
+ it('falls back to binding when resumption fails and salvages the queue', () => {
+ const storage = new MemoryStorageBackend();
+ const first = makeConn({ streamManagement: { storage } });
+ establish(first.conn);
+ first.conn.send($msg({ to: 'juliet@example.net' }).c('body').t('acked'));
+ first.conn.send($msg({ to: 'juliet@example.net' }).c('body').t('salvaged'));
+
+ const second = makeConn({ streamManagement: { storage }, explicitResourceBinding: true });
+ sendFeatures(second.conn);
+ expect(isNonza(second.wire.at(-1), 'resume', { h: '0', previd: 'sm-id-1' })).toBe(true);
+ recv(
+ second.conn,
+ ``
+ );
+ // per the XEP the client may bind on this same stream
+ expect(second.statuses).toContain(Status.BINDREQUIRED);
+ expect(second.conn.do_bind).toBe(true);
+ expect(storage.load('strophe-sm:romeo@example.net')).toBeNull();
+
+ second.conn.bind();
+ bindResult(second.conn, 'romeo@example.net/newresource');
+ expect(isNonza(second.wire.at(-1), 'enable', { resume: 'true' })).toBe(true);
+ recv(second.conn, ``);
+ // acked the first message; the second was salvaged,
+ // re-sent delay-stamped on the new session and queued again
+ expect(second.wire.some((s) => s.includes('acked'))).toBe(false);
+ const resent = second.wire.find((s) => s.includes('salvaged'));
+ expect(resent).toContain('urn:xmpp:delay');
+ expect(resent).toContain('stamp="');
+ expect(isNonza(second.wire.at(-1), 'r')).toBe(true);
+ expect(second.conn.sm.state.unacked.length).toBe(1);
+ expect(second.conn.hasResumed()).toBe(false);
+ });
+
+ it('runs on without rebinding when the server refuses a fresh ', () => {
+ const { conn, wire, statuses } = makeConn();
+ sendFeatures(conn);
+ bindResult(conn);
+ expect(isNonza(wire.at(-1), 'enable', { resume: 'true' })).toBe(true);
+ expect(statuses).toContain(Status.CONNECTED);
+ const statusesBefore = [...statuses];
+ conn.send($msg({ to: 'juliet@example.net' }).c('body').t('delivered'));
+ expect(conn.sm.state.unacked.length).toBe(1);
+
+ // is refused (not a ): the stream is already bound,
+ // so SM just goes inactive — no fallback bind, no BINDREQUIRED.
+ recv(conn, ``);
+ expect(statuses).toEqual(statusesBefore);
+ expect(conn.isStreamManagementEnabled()).toBe(false);
+
+ // the already-delivered stanza is not salvaged onto a later session
+ conn.sm.serverSupported = true;
+ conn.sm.sendEnable('romeo@example.net/orchard');
+ const before = wire.length;
+ recv(conn, ``);
+ expect(wire.slice(before).some((s) => s.includes('delivered'))).toBe(false);
+ });
+
+ it('sends a final and clears persisted state on graceful disconnect', () => {
+ const storage = new MemoryStorageBackend();
+ const { conn, wire } = makeConn({ streamManagement: { storage } });
+ establish(conn);
+ recv(conn, '1');
+ expect(storage.load('strophe-sm:romeo@example.net')).not.toBeNull();
+
+ conn.disconnect('done');
+ const ackIdx = wire.findIndex((s) => isNonza(s, 'a', { h: '1' }));
+ const closeIdx = wire.findIndex((s) => s.includes('urn:ietf:params:xml:ns:xmpp-framing'));
+ expect(ackIdx).toBeGreaterThan(-1);
+ expect(closeIdx).toBeGreaterThan(ackIdx);
+ expect(storage.load('strophe-sm:romeo@example.net')).toBeNull();
+ // the teardown presence was not tracked into a (now dead) queue
+ expect(conn.sm.state.unacked.length).toBe(0);
+ });
+});
diff --git a/tests/stream-management.test.ts b/tests/stream-management.test.ts
new file mode 100644
index 00000000..6f5d0956
--- /dev/null
+++ b/tests/stream-management.test.ts
@@ -0,0 +1,424 @@
+import { StreamManagement, MemoryStorageBackend, SessionStorageBackend } from '../dist/strophe.node.esm.js';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+
+const SM_NS = 'urn:xmpp:sm:3';
+
+/**
+ * Build a StanzaView the way an adapter would.
+ */
+function view(name: string, attrs: Record = {}, serialized?: string) {
+ return { name, attrs, serialized: serialized ?? `<${name}/>` };
+}
+
+/**
+ * Create an engine with a capturing sendRaw and a memory backend.
+ */
+function make(options: Record = {}) {
+ const sent: string[] = [];
+ const storage = new MemoryStorageBackend();
+ const sm = new StreamManagement((data: string) => sent.push(data), { storage, ...options });
+ return { sm, sent, storage };
+}
+
+/**
+ * Drive an engine into an enabled session.
+ */
+function enable(sm: InstanceType, enabledAttrs: Record = {}) {
+ sm.sendEnable('romeo@example.net/orchard');
+ sm.onInbound(view('enabled', { id: 'some-long-sm-id', resume: 'true', ...enabledAttrs }));
+}
+
+describe('StreamManagement core', () => {
+ describe('enabling', () => {
+ it('is inert before is sent', () => {
+ const { sm, sent } = make();
+ sm.trackOutbound(view('message', {}, 'hi'));
+ sm.onInbound(view('message'));
+ sm.onInbound(view('r'));
+ expect(sm.state.unacked.length).toBe(0);
+ expect(sm.state.hIn).toBe(0);
+ expect(sent.length).toBe(0);
+ });
+
+ it('emits with resume and max', () => {
+ const { sm, sent } = make({ max: 300 });
+ sm.sendEnable('romeo@example.net/orchard');
+ expect(sent).toEqual([``]);
+ expect(sm.state.enableSent).toBe(true);
+ expect(sm.state.enabled).toBe(false);
+ expect(sm.boundJid).toBe('romeo@example.net/orchard');
+ });
+
+ it('omits resume when requestResume is false', () => {
+ const { sm, sent } = make({ requestResume: false });
+ sm.sendEnable('romeo@example.net/orchard');
+ expect(sent).toEqual([``]);
+ });
+
+ it('sends at most once per session', () => {
+ const { sm, sent } = make();
+ sm.sendEnable('romeo@example.net/orchard');
+ sm.sendEnable('romeo@example.net/orchard');
+ expect(sent.length).toBe(1);
+ });
+
+ it('stores id/max/location/resumeSupported from and fires onEnabled', () => {
+ const { sm } = make();
+ const spy = vi.fn();
+ sm.onEnabled = spy;
+ sm.sendEnable('romeo@example.net/orchard');
+ sm.onInbound(view('enabled', { id: 'abc', resume: '1', max: '600', location: '[::1]:5222' }));
+ expect(sm.enabled).toBe(true);
+ expect(sm.state.id).toBe('abc');
+ expect(sm.state.max).toBe(600);
+ expect(sm.state.location).toBe('[::1]:5222');
+ expect(sm.state.resumeSupported).toBe(true);
+ expect(spy).toHaveBeenCalledOnce();
+ });
+
+ it('ignores when was not sent', () => {
+ const { sm } = make();
+ sm.onInbound(view('enabled', { id: 'abc', resume: 'true' }));
+ expect(sm.enabled).toBe(false);
+ });
+ });
+
+ describe('counter-start asymmetry (XEP-0198 §4 note)', () => {
+ it('tracks outbound from enable-send, before arrives', () => {
+ const { sm } = make();
+ sm.sendEnable('romeo@example.net/orchard');
+ sm.trackOutbound(view('message', {}, 'early'));
+ expect(sm.state.unacked.length).toBe(1);
+ });
+
+ it('does not count inbound until is received', () => {
+ const { sm } = make();
+ sm.sendEnable('romeo@example.net/orchard');
+ sm.onInbound(view('message'));
+ expect(sm.state.hIn).toBe(0);
+ sm.onInbound(view('enabled', { id: 'abc', resume: 'true' }));
+ sm.onInbound(view('message'));
+ sm.onInbound(view('iq'));
+ expect(sm.state.hIn).toBe(2);
+ });
+ });
+
+ describe('counting and acks', () => {
+ it('only counts iq/presence/message', () => {
+ const { sm } = make();
+ enable(sm);
+ for (const name of ['iq', 'presence', 'message']) {
+ sm.onInbound(view(name));
+ sm.trackOutbound(view(name));
+ }
+ for (const name of ['open', 'close', 'features', 'challenge', 'r', 'a']) {
+ sm.onInbound(view(name));
+ sm.trackOutbound(view(name));
+ }
+ expect(sm.state.hIn).toBe(3);
+ expect(sm.state.unacked.length).toBe(3);
+ });
+
+ it('answers with ', () => {
+ const { sm, sent } = make();
+ enable(sm);
+ sm.onInbound(view('message'));
+ sm.onInbound(view('message'));
+ sm.onInbound(view('r'));
+ expect(sent.at(-1)).toBe(``);
+ });
+
+ it('sendAck emits the current inbound count (final ack before close)', () => {
+ const { sm, sent } = make();
+ enable(sm);
+ sm.onInbound(view('presence'));
+ sm.sendAck();
+ expect(sent.at(-1)).toBe(``);
+ });
+
+ it('trims the unacked queue on and resets sinceLastAck', () => {
+ const { sm } = make();
+ enable(sm);
+ sm.trackOutbound(view('message', {}, '1'));
+ sm.trackOutbound(view('message', {}, '2'));
+ sm.trackOutbound(view('message', {}, '3'));
+ sm.onInbound(view('a', { h: '2' }));
+ expect(sm.state.unacked.map((e: { stanza: string }) => e.stanza)).toEqual(['3']);
+ expect(sm.state.hOutAcked).toBe(2);
+ expect(sm.state.sinceLastAck).toBe(0);
+ });
+
+ it('clamps when the server acks more than was sent', () => {
+ const { sm } = make();
+ enable(sm);
+ sm.trackOutbound(view('message', {}, '1'));
+ sm.onInbound(view('a', { h: '10' }));
+ expect(sm.state.unacked.length).toBe(0);
+ expect(sm.state.hOutAcked).toBe(10);
+ });
+
+ it('handles the 2^32 wraparound in reconciliation', () => {
+ const { sm } = make();
+ enable(sm);
+ sm.state.hOutAcked = 4294967294;
+ sm.trackOutbound(view('message', {}, '1'));
+ sm.trackOutbound(view('message', {}, '2'));
+ sm.trackOutbound(view('message', {}, '3'));
+ sm.onInbound(view('a', { h: '1' }));
+ expect(sm.state.unacked.length).toBe(0);
+ expect(sm.state.hOutAcked).toBe(1);
+ });
+
+ it('wraps the inbound counter at 2^32', () => {
+ const { sm, sent } = make();
+ enable(sm);
+ sm.state.hIn = 4294967295;
+ sm.onInbound(view('message'));
+ expect(sm.state.hIn).toBe(0);
+ sm.sendAck();
+ expect(sent.at(-1)).toBe(``);
+ });
+
+ it('requests an ack every maxUnacked outbound stanzas', () => {
+ const { sm, sent } = make({ maxUnacked: 3 });
+ enable(sm);
+ const countAckRequests = () => sent.filter((s) => s === ``).length;
+ for (let i = 0; i < 5; i++) sm.trackOutbound(view('message', {}, `${i}`));
+ expect(countAckRequests()).toBe(1);
+ sm.trackOutbound(view('message', {}, '6'));
+ expect(countAckRequests()).toBe(2);
+ sm.onInbound(view('a', { h: '6' }));
+ for (let i = 0; i < 3; i++) sm.trackOutbound(view('message', {}, `${7 + i}`));
+ expect(countAckRequests()).toBe(3);
+ });
+
+ it('never requests acks when maxUnacked is 0', () => {
+ const { sm, sent } = make({ maxUnacked: 0 });
+ enable(sm);
+ for (let i = 0; i < 10; i++) sm.trackOutbound(view('message', {}, `${i}`));
+ expect(sent.filter((s) => s === ``).length).toBe(0);
+ });
+ });
+
+ describe('resumption', () => {
+ it('reports resumable state only when the server allowed resumption', () => {
+ const { sm } = make();
+ sm.sendEnable('romeo@example.net/orchard');
+ sm.onInbound(view('enabled', { id: 'abc' }));
+ expect(sm.hasResumableState()).toBe(false);
+ const other = make().sm;
+ enable(other);
+ expect(other.hasResumableState()).toBe(true);
+ });
+
+ it('emits with h and an escaped previd', () => {
+ const { sm, sent } = make();
+ sm.sendEnable('romeo@example.net/orchard');
+ sm.onInbound(view('enabled', { id: 'ab"&`);
+ });
+
+ it('on reconciles h, resends the remainder and keeps it queued', () => {
+ const { sm, sent } = make();
+ const spy = vi.fn();
+ sm.onResumed = spy;
+ enable(sm);
+ sm.trackOutbound(view('message', {}, '1'));
+ sm.trackOutbound(view('message', {}, '2'));
+ sm.trackOutbound(view('message', {}, '3'));
+ const before = sent.length;
+ sm.onInbound(view('resumed', { h: '1', previd: 'some-long-sm-id' }));
+ expect(sent.slice(before)).toEqual([
+ '2',
+ '3',
+ ``,
+ ]);
+ expect(sm.state.unacked.length).toBe(2);
+ expect(sm.resumed).toBe(true);
+ expect(sm.enabled).toBe(true);
+ expect(spy).toHaveBeenCalledOnce();
+ });
+
+ it('ignores without resumable state', () => {
+ const { sm } = make();
+ sm.onInbound(view('resumed', { h: '1', previd: 'bogus' }));
+ expect(sm.resumed).toBe(false);
+ });
+ });
+
+ describe('failed resumption (queue salvage)', () => {
+ it('trims by the h on , resets state and clears storage', () => {
+ const { sm, storage } = make();
+ const spy = vi.fn();
+ sm.onFailed = spy;
+ sm.initialize('romeo@example.net');
+ enable(sm);
+ sm.onInbound(view('message'));
+ sm.trackOutbound(view('message', {}, '1'));
+ sm.trackOutbound(view('message', {}, '2'));
+ expect(storage.load('strophe-sm:romeo@example.net')).not.toBeNull();
+ sm.onInbound(view('failed', { h: '1' }, ``));
+ expect(sm.state.enableSent).toBe(false);
+ expect(sm.state.enabled).toBe(false);
+ expect(sm.state.id).toBe(null);
+ expect(sm.state.hIn).toBe(0);
+ expect(sm.state.unacked.length).toBe(0);
+ expect(storage.load('strophe-sm:romeo@example.net')).toBeNull();
+ expect(spy).toHaveBeenCalledOnce();
+ });
+
+ it('resends the salvaged queue once a fresh session reaches ', () => {
+ const { sm, sent } = make();
+ enable(sm);
+ sm.trackOutbound(view('message', {}, 'lost?'));
+ sm.trackOutbound(view('iq', {}, ''));
+ sm.sendResume(); // a reconnect attempts to resume...
+ sm.onInbound(view('failed', {})); // ...which the server refuses
+ const before = sent.length;
+ enable(sm);
+ const resent = sent.slice(before + 1); // skip the itself
+ expect(resent.length).toBe(3);
+ expect(resent[0]).toMatch(
+ /^lost\?<\/body><\/message>$/
+ );
+ expect(resent[1]).toBe(''); // only messages are delay-stamped
+ expect(resent[2]).toBe(``);
+ expect(sm.state.unacked.length).toBe(2); // requeued on the new session, in wire order
+ });
+
+ it('does not salvage when a fresh (not a ) is refused', () => {
+ const { sm, sent } = make();
+ enable(sm);
+ // The stream is alive and bound; this stanza was delivered normally.
+ sm.trackOutbound(view('message', {}, 'delivered'));
+ sm.onInbound(view('failed', {})); // the server refuses the
+ const before = sent.length;
+ enable(sm); // a later fresh session
+ // nothing is re-sent: re-sending a delivered stanza would duplicate it
+ expect(sent.slice(before + 1)).toEqual([]);
+ expect(sm.state.unacked.length).toBe(0);
+ });
+ });
+
+ describe('resumePending', () => {
+ it('is set by sendResume and cleared by ', () => {
+ const { sm } = make();
+ enable(sm);
+ expect(sm.resumePending).toBe(false);
+ sm.sendResume();
+ expect(sm.resumePending).toBe(true);
+ sm.onInbound(view('resumed', { h: '0', previd: 'some-long-sm-id' }));
+ expect(sm.resumePending).toBe(false);
+ });
+
+ it('is cleared by and by reset()', () => {
+ const { sm } = make();
+ enable(sm);
+ sm.sendResume();
+ sm.onInbound(view('failed', {}));
+ expect(sm.resumePending).toBe(false);
+ enable(sm);
+ sm.sendResume();
+ sm.reset();
+ expect(sm.resumePending).toBe(false);
+ });
+ });
+
+ describe('delay stamping', () => {
+ it('handles self-closing stanzas', () => {
+ const { sm, sent } = make();
+ enable(sm);
+ sm.trackOutbound(view('message', {}, ''));
+ sm.sendResume();
+ sm.onInbound(view('failed', {}));
+ enable(sm);
+ expect(sent.find((s) => s.startsWith(''))).toMatch(
+ /^<\/message>$/
+ );
+ });
+
+ it('does not stamp twice', () => {
+ const { sm, sent } = make();
+ enable(sm);
+ const stamped = '';
+ sm.trackOutbound(view('message', {}, stamped));
+ sm.sendResume();
+ sm.onInbound(view('failed', {}));
+ enable(sm);
+ expect(sent.filter((s) => s === stamped).length).toBe(1);
+ });
+ });
+
+ describe('persistence', () => {
+ it('persists across engine instances (page reload scenario)', () => {
+ const { sm, storage } = make();
+ sm.initialize('Romeo@Example.Net/orchard'); // keyed by lowercased bare JID
+ enable(sm);
+ sm.onInbound(view('message'));
+ sm.onInbound(view('message'));
+ sm.trackOutbound(view('message', {}, 'unacked'));
+
+ const sent2: string[] = [];
+ const sm2 = new StreamManagement((data: string) => sent2.push(data), { storage });
+ sm2.initialize('romeo@example.net');
+ expect(sm2.hasResumableState()).toBe(true);
+ expect(sm2.state.hIn).toBe(2);
+ expect(sm2.state.id).toBe('some-long-sm-id');
+ expect(sm2.boundJid).toBe('romeo@example.net/orchard');
+ sm2.sendResume();
+ expect(sent2).toEqual([``]);
+ sm2.onInbound(view('resumed', { h: '0', previd: 'some-long-sm-id' }));
+ expect(sent2.at(-2)).toBe('unacked');
+ });
+
+ it('does not persist without initialize()', () => {
+ const { sm, storage } = make();
+ enable(sm);
+ sm.onInbound(view('message'));
+ const sm2 = new StreamManagement(() => {}, { storage });
+ sm2.initialize('romeo@example.net');
+ expect(sm2.hasResumableState()).toBe(false);
+ });
+
+ it('reset() clears memory but not storage; clearPersistedState() clears storage', () => {
+ const { sm, storage } = make();
+ sm.initialize('romeo@example.net');
+ enable(sm);
+ sm.reset();
+ expect(sm.enabled).toBe(false);
+ expect(storage.load('strophe-sm:romeo@example.net')).not.toBeNull();
+ sm.initialize('romeo@example.net');
+ expect(sm.hasResumableState()).toBe(true);
+ sm.clearPersistedState();
+ expect(storage.load('strophe-sm:romeo@example.net')).toBeNull();
+ });
+ });
+
+ describe('SessionStorageBackend', () => {
+ afterEach(() => {
+ delete (globalThis as any).sessionStorage;
+ });
+
+ it('throws when sessionStorage is unavailable', () => {
+ expect(() => new SessionStorageBackend()).toThrow();
+ });
+
+ it('round-trips via a sessionStorage global', () => {
+ const store = new Map();
+ (globalThis as any).sessionStorage = {
+ getItem: (k: string) => store.get(k) ?? null,
+ setItem: (k: string, v: string) => store.set(k, v),
+ removeItem: (k: string) => store.delete(k),
+ };
+ const backend = new SessionStorageBackend();
+ const { sm } = make({ storage: backend });
+ sm.initialize('romeo@example.net');
+ enable(sm);
+ expect(backend.load('strophe-sm:romeo@example.net').id).toBe('some-long-sm-id');
+ backend.clear('strophe-sm:romeo@example.net');
+ expect(backend.load('strophe-sm:romeo@example.net')).toBeNull();
+ });
+ });
+});
diff --git a/tests/worker-arbitration.test.ts b/tests/worker-arbitration.test.ts
new file mode 100644
index 00000000..d3774c0b
--- /dev/null
+++ b/tests/worker-arbitration.test.ts
@@ -0,0 +1,493 @@
+import ConnectionManager, {
+ PING_INTERVAL,
+ PORT_GC_TIMEOUT,
+ PRIMARY_TIMEOUT,
+ SOCKET_GRACE,
+} from '../src/shared-connection-worker';
+import { SHARED_WORKER_PROTOCOL_VERSION, Status } from '../src/constants';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+const SERVICE = 'wss://example.org/xmpp-websocket';
+const JID = 'romeo@example.net/orchard';
+const VERSION = SHARED_WORKER_PROTOCOL_VERSION;
+
+/**
+ * A page-side MessagePort double: records worker→page messages and lets the
+ * test emit page→worker messages.
+ */
+class MockPort {
+ sent: unknown[][] = [];
+ private listeners: ((e: { data: unknown[] }) => void)[] = [];
+
+ addEventListener(type: string, fn: (e: { data: unknown[] }) => void): void {
+ if (type === 'message') this.listeners.push(fn);
+ }
+
+ start(): void {}
+
+ postMessage(msg: unknown[]): void {
+ this.sent.push(msg);
+ }
+
+ /** Simulate a page→worker message. */
+ emit(...data: unknown[]): void {
+ this.listeners.forEach((fn) => fn({ data }));
+ }
+
+ /** All received messages with the given method name. */
+ msgs(name: string): unknown[][] {
+ return this.sent.filter((m) => m[0] === name);
+ }
+}
+
+class FakeWebSocket {
+ static CONNECTING = 0;
+ static OPEN = 1;
+ static CLOSING = 2;
+ static CLOSED = 3;
+ static instances: FakeWebSocket[] = [];
+
+ url: string;
+ // Frames are only relayed once the socket is OPEN, which it is by the
+ // time the primary reports _bound (that follows _onOpen → SASL → bind).
+ readyState = FakeWebSocket.OPEN;
+ sent: string[] = [];
+ onopen: () => void;
+ onerror: (e: unknown) => void;
+ onclose: (e: unknown) => void;
+ onmessage: (m: unknown) => void;
+
+ constructor(url: string, _protocol: string) {
+ this.url = url;
+ FakeWebSocket.instances.push(this);
+ }
+
+ send(data: string): void {
+ this.sent.push(data);
+ }
+
+ close(): void {
+ this.readyState = FakeWebSocket.CLOSED;
+ }
+}
+
+function makeManager() {
+ const manager = new ConnectionManager();
+ const join = () => {
+ const port = new MockPort();
+ manager.addPort(port as unknown as MessagePort);
+ return port;
+ };
+ return { manager, join };
+}
+
+/** Join a port and let it drive/share the connection via _connect. */
+function connect(port: MockPort, jid = JID) {
+ port.emit('_connect', SERVICE, jid, VERSION);
+}
+
+/** Report bind completion, establishing the session (see _bound). */
+function bind(port: MockPort, jid = JID) {
+ port.emit('_bound', jid);
+}
+
+/** _attach with the protocol version, as the page sends it. */
+function attach(port: MockPort) {
+ port.emit('_attach', SERVICE, VERSION);
+}
+
+describe('shared-connection-worker arbitration', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ FakeWebSocket.instances = [];
+ vi.stubGlobal('WebSocket', FakeWebSocket);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it('makes the first connecting port primary and opens the socket', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ expect(FakeWebSocket.instances.length).toBe(1);
+ expect(p1.msgs('_role')).toEqual([['_role', 'primary', JID]]);
+ });
+
+ it('joins a second _connect as secondary over the established session (no reconnect)', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ const p2 = join();
+ connect(p2);
+ expect(FakeWebSocket.instances.length).toBe(1); // idempotent _connect
+ expect(p2.msgs('_role')).toEqual([['_role', 'secondary', JID]]);
+ expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHED, JID]]);
+ // per-port addressing: the primary got no attach callback
+ expect(p1.msgs('_attachCallback')).toEqual([]);
+ });
+
+ it('reconnects when a different user connects', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ const p2 = join();
+ connect(p2, 'juliet@example.net/balcony');
+ expect(FakeWebSocket.instances.length).toBe(2);
+ expect(p2.msgs('_role')).toEqual([['_role', 'primary', 'juliet@example.net/balcony']]);
+ expect(p1.msgs('_role').at(-1)).toEqual(['_role', 'secondary', 'juliet@example.net/balcony']);
+ });
+
+ it('attaches only the requesting port, and fails without a socket', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ attach(p1);
+ expect(p1.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHFAIL]]);
+
+ connect(p1);
+ bind(p1);
+ const p2 = join();
+ attach(p2);
+ expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHED, JID]]);
+ expect(p2.msgs('_role')).toEqual([['_role', 'secondary', JID]]);
+ // per-port addressing: p1 only ever saw its own (failed) attach reply
+ expect(p1.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHFAIL]]);
+ });
+
+ it('parks joins that arrive during the handshake and answers them with the bound JID', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1, 'romeo@example.net'); // pre-bind JID, no resource yet
+ const p2 = join();
+ attach(p2);
+ const p3 = join();
+ p3.emit('_connect', SERVICE, 'romeo@example.net', VERSION);
+ // the handshake is still in flight: nobody is told ATTACHED (which
+ // would hand out a pre-bind JID on an unauthenticated stream)
+ expect(p2.msgs('_attachCallback')).toEqual([]);
+ expect(p3.msgs('_attachCallback')).toEqual([]);
+ expect(FakeWebSocket.instances.length).toBe(1);
+
+ bind(p1, JID);
+ expect(p2.msgs('_role')).toEqual([['_role', 'secondary', JID]]);
+ expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHED, JID]]);
+ expect(p3.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHED, JID]]);
+ });
+
+ it('fails parked joins when the socket dies mid-handshake', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ const p2 = join();
+ attach(p2);
+ FakeWebSocket.instances[0].onclose({ reason: 'boom' });
+ expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHFAIL]]);
+ });
+
+ it('fails a parked _connect join with _onClose instead of ATTACHFAIL, so the page retries', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ const p2 = join();
+ connect(p2); // same user, handshake in flight: parked
+ FakeWebSocket.instances[0].onclose({ reason: 'boom' });
+ // ATTACHFAIL is the attach() contract; a connect()-initiated page
+ // would treat it as terminal and wedge, so it gets _onClose, which
+ // fails the connect attempt and lets the embedder reconnect.
+ expect(p2.msgs('_attachCallback')).toEqual([]);
+ expect(p2.msgs('_onClose').length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('closes the socket when the connecting primary leaves mid-handshake', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ const p2 = join();
+ attach(p2);
+ p1.emit('_bye');
+ // nobody else can finish the handshake: the socket is killed, the
+ // parked join fails, and the remaining tab is told to reconnect
+ expect(FakeWebSocket.instances[0].readyState).toBe(FakeWebSocket.CLOSED);
+ expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHFAIL]]);
+ expect(p2.msgs('_onClose').length).toBe(1);
+ });
+
+ it('rejects a page speaking a different protocol version', () => {
+ const { manager, join } = makeManager();
+ const p1 = join();
+ p1.emit('_connect', SERVICE, JID, VERSION + 1);
+ expect(FakeWebSocket.instances.length).toBe(0);
+ expect(p1.msgs('_onClose').length).toBe(1);
+ expect(p1.msgs('log').some((m) => m[1] === 'fatal')).toBe(true);
+ expect(manager.ports.size).toBe(0); // not admitted
+ });
+
+ it('broadcasts inbound traffic to all live ports', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ const p2 = join();
+ attach(p2);
+ FakeWebSocket.instances[0].onmessage({ data: '' });
+ expect(p1.msgs('_onMessage')).toEqual([['_onMessage', { 'data': '' }]]);
+ expect(p2.msgs('_onMessage')).toEqual([['_onMessage', { 'data': '' }]]);
+ });
+
+ it('targets handshake frames at the primary only — a concurrently connecting tab hears nothing', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1, 'romeo@example.net');
+ const p2 = join();
+ connect(p2, 'romeo@example.net'); // races in mid-handshake and is parked
+ const socket = FakeWebSocket.instances[0];
+ expect(FakeWebSocket.instances.length).toBe(1);
+
+ socket.onmessage({ data: "" });
+ socket.onmessage({ data: '' });
+ // only the driving primary hears the handshake: the parked tab would
+ // treat these frames as its own handshake and start a second SASL
+ // negotiation on the shared socket
+ expect(p1.msgs('_onMessage').length).toBe(2);
+ expect(p2.msgs('_onMessage')).toEqual([]);
+
+ bind(p1);
+ socket.onmessage({ data: '' });
+ expect(p1.msgs('_onMessage').length).toBe(3);
+ expect(p2.msgs('_onMessage')).toEqual([['_onMessage', { 'data': '' }]]);
+ // the parked tab was told its role (which swaps its message handler)
+ // before it received its first raw frame
+ const roleIdx = p2.sent.findIndex((m) => m[0] === '_role');
+ const frameIdx = p2.sent.findIndex((m) => m[0] === '_onMessage');
+ expect(roleIdx).toBeGreaterThan(-1);
+ expect(roleIdx).toBeLessThan(frameIdx);
+ });
+
+ it('relays send from any port to the socket', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ const p2 = join();
+ attach(p2);
+ p2.emit('send', '');
+ expect(FakeWebSocket.instances[0].sent).toEqual(['']);
+ });
+
+ it('reflects sent messages and presences to the other tabs, never the sender', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ const p2 = join();
+ attach(p2);
+ const p3 = join();
+ attach(p3);
+ // reflection is an arbitration feature: it works without SM (no
+ // engine exists in this test)
+ const msg = "hi";
+ p1.emit('send', msg);
+ expect(p1.msgs('_onStanzaSent')).toEqual([]);
+ expect(p2.msgs('_onStanzaSent')).toEqual([['_onStanzaSent', msg]]);
+ expect(p3.msgs('_onStanzaSent')).toEqual([['_onStanzaSent', msg]]);
+ // symmetric: a secondary's send reaches the primary and the other secondary
+ p2.emit('send', "");
+ expect(p1.msgs('_onStanzaSent')).toEqual([['_onStanzaSent', ""]]);
+ expect(p3.msgs('_onStanzaSent').length).toBe(2);
+ // p2 only ever saw p1's message, not its own presence
+ expect(p2.msgs('_onStanzaSent')).toEqual([['_onStanzaSent', msg]]);
+ });
+
+ it('does not reflect IQs, nonzas or close frames', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ const p2 = join();
+ attach(p2);
+ p1.emit('send', "");
+ p1.emit('send', '');
+ expect(p2.msgs('_onStanzaSent')).toEqual([]);
+ });
+
+ it('promotes a secondary when the primary says _bye — same socket', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ const p2 = join();
+ attach(p2);
+ p1.emit('_bye');
+ expect(p2.msgs('_promote')).toEqual([['_promote', JID]]);
+ expect(FakeWebSocket.instances.length).toBe(1); // no reconnect
+ // the departed port no longer receives broadcasts
+ FakeWebSocket.instances[0].onmessage({ data: '' });
+ expect(p1.msgs('_onMessage')).toEqual([]);
+ expect(p2.msgs('_onMessage').length).toBe(1);
+ });
+
+ it('fails a silent primary over to a fresh port but keeps delivering to it', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ const p2 = join();
+ attach(p2);
+ // p1 goes silent (e.g. a background-throttled tab); p2 answers pings
+ for (let i = 0; i < 3; i++) {
+ vi.advanceTimersByTime(PING_INTERVAL);
+ p2.emit('_pong');
+ }
+ expect(p2.msgs('_promote')).toEqual([['_promote', JID]]);
+ expect(p1.msgs('_role').at(-1)).toEqual(['_role', 'secondary', JID]);
+ // ...but the silent port still receives everything: silence means
+ // throttled-or-frozen, not gone, and delivery is not gated on it
+ FakeWebSocket.instances[0].onmessage({ data: '' });
+ expect(p1.msgs('_onMessage').length).toBe(1);
+ expect(p2.msgs('_onMessage').length).toBe(1);
+ });
+
+ it('does not fail over when no fresher port exists', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ vi.advanceTimersByTime(PRIMARY_TIMEOUT + 2 * PING_INTERVAL);
+ // sole (silent) port: it keeps the primary role
+ expect(p1.msgs('_role').filter((m) => m[1] === 'secondary')).toEqual([]);
+ expect(p1.msgs('_promote')).toEqual([]);
+ });
+
+ it('fails over immediately when the primary announces a freeze (_relinquish)', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ const p2 = join();
+ attach(p2);
+ p1.emit('_relinquish');
+ expect(p1.msgs('_role').at(-1)).toEqual(['_role', 'secondary', JID]);
+ expect(p2.msgs('_promote')).toEqual([['_promote', JID]]);
+ expect(FakeWebSocket.instances.length).toBe(1); // same socket
+ });
+
+ it('keeps a sole port primary on _relinquish', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ p1.emit('_relinquish');
+ expect(p1.msgs('_role').filter((m) => m[1] === 'secondary')).toEqual([]);
+ });
+
+ it('drops a port only after PORT_GC_TIMEOUT and re-admits it when it speaks', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ const p2 = join();
+ attach(p2);
+ const socket = FakeWebSocket.instances[0];
+ // p2 (a secondary) goes completely silent; p1 stays live
+ const sweeps = Math.ceil(PORT_GC_TIMEOUT / PING_INTERVAL) + 1;
+ for (let i = 0; i < sweeps; i++) {
+ vi.advanceTimersByTime(PING_INTERVAL);
+ p1.emit('_pong');
+ }
+ socket.onmessage({ data: '' });
+ expect(p2.msgs('_onMessage')).toEqual([]); // dropped for good
+ expect(p1.msgs('_onMessage').length).toBe(1);
+
+ // it speaks again: re-admitted (as secondary) and receives again
+ p2.emit('_pong');
+ expect(p2.msgs('_role').at(-1)).toEqual(['_role', 'secondary', JID]);
+ socket.onmessage({ data: '' });
+ expect(p2.msgs('_onMessage').length).toBe(1);
+ });
+
+ it('answers messages sent into a dead socket with _onClose instead of dropping them', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ p1.emit('_closeSocket');
+ p1.emit('send', '');
+ expect(p1.msgs('_onClose').length).toBe(1);
+ });
+
+ it('answers a send on a still-connecting socket with _onClose instead of throwing', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ const socket = FakeWebSocket.instances[0];
+ socket.readyState = FakeWebSocket.CONNECTING; // handshake not finished
+ p1.emit('send', '');
+ // a send() on a CONNECTING socket throws and loses the frame, so the
+ // dispatcher intercepts it: nothing hits the wire, the tab is told
+ expect(socket.sent).toEqual([]);
+ expect(p1.msgs('_onClose').length).toBe(1);
+ });
+
+ it('tells a returning port about a dead socket instead of assigning it a role', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ const socket = FakeWebSocket.instances[0];
+ socket.readyState = FakeWebSocket.OPEN;
+ p1.emit('_bye');
+ vi.advanceTimersByTime(SOCKET_GRACE + 1);
+ expect(socket.readyState).toBe(FakeWebSocket.CLOSED);
+
+ // a tab returns (e.g. thawed after everything else went away): it is
+ // told the session is gone, not handed a role on a dead socket
+ const p2 = join();
+ p2.emit('_pong');
+ expect(p2.msgs('_onClose').length).toBe(1);
+ expect(p2.msgs('_role')).toEqual([]);
+ });
+
+ it('closes the socket only after a grace period once the last port leaves', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ const socket = FakeWebSocket.instances[0];
+ socket.readyState = FakeWebSocket.OPEN;
+ p1.emit('_bye');
+ vi.advanceTimersByTime(SOCKET_GRACE - 1);
+ expect(socket.readyState).toBe(FakeWebSocket.OPEN);
+ vi.advanceTimersByTime(1);
+ expect(socket.readyState).toBe(FakeWebSocket.CLOSED);
+ });
+
+ it('a port joining during the grace period keeps the socket alive', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ connect(p1);
+ bind(p1);
+ const socket = FakeWebSocket.instances[0];
+ socket.readyState = FakeWebSocket.OPEN;
+ p1.emit('_bye');
+ vi.advanceTimersByTime(SOCKET_GRACE - 1);
+ const p2 = join();
+ attach(p2);
+ expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHED, JID]]);
+ expect(p2.msgs('_role')).toEqual([['_role', 'primary', JID]]); // nobody else left
+ vi.advanceTimersByTime(SOCKET_GRACE * 2);
+ expect(socket.readyState).toBe(FakeWebSocket.OPEN);
+ });
+
+ it('rejects unknown methods without touching state', () => {
+ const { join } = makeManager();
+ const p1 = join();
+ p1.emit('constructor', 'x');
+ p1.emit('_promoteNext');
+ expect(p1.msgs('log').length).toBe(2);
+ expect(FakeWebSocket.instances.length).toBe(0);
+ });
+});
diff --git a/tests/worker-handshake.test.ts b/tests/worker-handshake.test.ts
new file mode 100644
index 00000000..069bb107
--- /dev/null
+++ b/tests/worker-handshake.test.ts
@@ -0,0 +1,175 @@
+/**
+ * End-to-end multi-tab handshake: two real Connections (page side) bridged to
+ * one real ConnectionManager (worker side) over one fake socket.
+ *
+ * This is the regression test for the concurrent-connect race: when two tabs
+ * connect at the same time (e.g. a session-restored window), exactly ONE of
+ * them may drive the handshake. The other join is parked by the worker and
+ * must not hear the handshake frames — a tab whose _messageHandler is still
+ * _onInitialMessage would treat them as its *own* handshake and start a
+ * second SASL negotiation on the shared socket.
+ */
+import ConnectionManager from '../src/shared-connection-worker';
+import { Strophe } from '../dist/strophe.node.esm.js';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+const SERVICE = 'wss://example.org/xmpp-websocket';
+const { Status } = Strophe;
+
+class FakeWebSocket {
+ static CONNECTING = 0;
+ static OPEN = 1;
+ static CLOSING = 2;
+ static CLOSED = 3;
+ static instances: FakeWebSocket[] = [];
+
+ url: string;
+ readyState = FakeWebSocket.OPEN;
+ sent: string[] = [];
+ onopen: () => void;
+ onerror: (e: unknown) => void;
+ onclose: (e: unknown) => void;
+ onmessage: (m: { data: string }) => void;
+
+ constructor(url: string, _protocol: string) {
+ this.url = url;
+ FakeWebSocket.instances.push(this);
+ }
+
+ send(data: string): void {
+ this.sent.push(data);
+ }
+
+ close(): void {
+ this.readyState = FakeWebSocket.CLOSED;
+ }
+}
+
+/** The worker side shared by all FakeSharedWorkers in a test. */
+let manager: ConnectionManager;
+
+/**
+ * The page's end of a synchronous in-memory MessagePort pair. Each
+ * FakeSharedWorker wires one of these to a worker-side counterpart that is
+ * registered with the shared ConnectionManager, so page and worker run the
+ * real protocol against each other.
+ */
+class PagePort {
+ onmessage: ((ev: { data: unknown[] }) => void) | null = null;
+ received: unknown[][] = [];
+ private _toWorker: (msg: unknown[]) => void;
+
+ constructor(toWorker: (msg: unknown[]) => void) {
+ this._toWorker = toWorker;
+ }
+
+ start(): void {}
+
+ postMessage(msg: unknown[]): void {
+ this._toWorker(msg);
+ }
+
+ /** Deliver a worker→page message. */
+ _deliver(msg: unknown[]): void {
+ this.received.push(msg);
+ this.onmessage?.({ data: msg });
+ }
+}
+
+class FakeSharedWorker {
+ port: PagePort;
+ onerror: unknown;
+
+ constructor(_url: string, _name?: string) {
+ const listeners: ((e: { data: unknown[] }) => void)[] = [];
+ const workerPort = {
+ addEventListener: (type: string, fn: (e: { data: unknown[] }) => void) => {
+ if (type === 'message') listeners.push(fn);
+ },
+ start: () => {},
+ postMessage: (msg: unknown[]) => this.port._deliver(msg),
+ };
+ this.port = new PagePort((msg) => listeners.forEach((fn) => fn({ data: msg })));
+ manager.addPort(workerPort as unknown as MessagePort);
+ }
+}
+
+describe('concurrent connect through the shared worker', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ manager = new ConnectionManager();
+ FakeWebSocket.instances = [];
+ vi.stubGlobal('WebSocket', FakeWebSocket);
+ vi.stubGlobal('SharedWorker', FakeSharedWorker);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it('lets exactly one of two concurrently connecting tabs drive the handshake', () => {
+ const statusesA: number[] = [];
+ const statusesB: number[] = [];
+ const connA = new Strophe.Connection(SERVICE, { worker: 'shared-connection-worker.js' });
+ const connB = new Strophe.Connection(SERVICE, { worker: 'shared-connection-worker.js' });
+ connA.connect('romeo@example.net', 'secret', (s: number) => statusesA.push(s));
+ connB.connect('romeo@example.net', 'secret', (s: number) => statusesB.push(s));
+
+ // both tabs share one socket; the second join was parked, not a reconnect
+ expect(FakeWebSocket.instances.length).toBe(1);
+ const socket = FakeWebSocket.instances[0];
+ const server = (xml: string) => socket.onmessage({ data: xml });
+
+ socket.onopen();
+ // _onOpen is port-targeted: only the primary opened the stream
+ expect(socket.sent.filter((s) => s.startsWith('');
+ server(
+ '' +
+ 'PLAIN' +
+ '',
+ );
+
+ // THE regression assertion: exactly one tab reacted to the handshake
+ // frames with a SASL negotiation
+ expect(socket.sent.filter((s) => s.startsWith('');
+ // the stream restart likewise came from the primary alone
+ expect(socket.sent.filter((s) => s.startsWith('');
+ server(
+ '' +
+ '' +
+ '',
+ );
+ expect(socket.sent.filter((s) => s.includes('xmpp-bind')).length).toBe(1);
+
+ server(
+ '' +
+ 'romeo@example.net/orchard' +
+ '',
+ );
+
+ // A drove the handshake to CONNECTED; B was attached to the shared
+ // session with the bound JID (not its own pre-bind one)
+ expect(statusesA).toContain(Status.CONNECTED);
+ expect(connA.role).toBe('primary');
+ expect(statusesB).toContain(Status.ATTACHED);
+ expect(statusesB).not.toContain(Status.CONNECTED);
+ expect(connB.role).toBe('secondary');
+ expect(connB.jid).toBe('romeo@example.net/orchard');
+
+ // live traffic now reaches both tabs
+ let gotA = 0;
+ let gotB = 0;
+ connA.addHandler(() => ++gotA && true, null, 'message');
+ connB.addHandler(() => ++gotB && true, null, 'message');
+ server('hi');
+ expect(gotA).toBe(1);
+ expect(gotB).toBe(1);
+ });
+});
diff --git a/tests/worker-sm.test.ts b/tests/worker-sm.test.ts
new file mode 100644
index 00000000..bd16cf4b
--- /dev/null
+++ b/tests/worker-sm.test.ts
@@ -0,0 +1,545 @@
+import ConnectionManager, { PING_INTERVAL, PROBE_TIMEOUT } from '../src/shared-connection-worker';
+import { SHARED_WORKER_PROTOCOL_VERSION } from '../src/constants';
+import { Strophe, $msg } from '../dist/strophe.node.esm.js';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+const SM_NS = 'urn:xmpp:sm:3';
+const SERVICE = 'wss://example.org/xmpp-websocket';
+const VERSION = SHARED_WORKER_PROTOCOL_VERSION;
+const { Status } = Strophe;
+
+/** A page-side MessagePort double (see worker-arbitration.test.ts). */
+class MockPort {
+ sent: unknown[][] = [];
+ private listeners: ((e: { data: unknown[] }) => void)[] = [];
+
+ addEventListener(type: string, fn: (e: { data: unknown[] }) => void): void {
+ if (type === 'message') this.listeners.push(fn);
+ }
+
+ start(): void {}
+
+ postMessage(msg: unknown[]): void {
+ this.sent.push(msg);
+ }
+
+ emit(...data: unknown[]): void {
+ this.listeners.forEach((fn) => fn({ data }));
+ }
+
+ msgs(name: string): unknown[][] {
+ return this.sent.filter((m) => m[0] === name);
+ }
+}
+
+class FakeWebSocket {
+ static CONNECTING = 0;
+ static OPEN = 1;
+ static CLOSING = 2;
+ static CLOSED = 3;
+ static instances: FakeWebSocket[] = [];
+
+ url: string;
+ readyState = FakeWebSocket.OPEN;
+ sent: string[] = [];
+ onopen: () => void;
+ onerror: (e: unknown) => void;
+ onclose: (e: unknown) => void;
+ onmessage: (m: { data: string }) => void;
+
+ constructor(url: string, _protocol: string) {
+ this.url = url;
+ FakeWebSocket.instances.push(this);
+ }
+
+ send(data: string): void {
+ this.sent.push(data);
+ }
+
+ close(): void {
+ this.readyState = FakeWebSocket.CLOSED;
+ }
+}
+
+describe('worker-resident stream management', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ FakeWebSocket.instances = [];
+ vi.stubGlobal('WebSocket', FakeWebSocket);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ /** Drive a manager to an enabled SM session with one primary port. */
+ function establish() {
+ const manager = new ConnectionManager();
+ const p1 = new MockPort();
+ manager.addPort(p1 as unknown as MessagePort);
+ p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION);
+ const socket = FakeWebSocket.instances.at(-1);
+ p1.emit('_smFeatures');
+ p1.emit('_bound', 'romeo@example.net/orchard');
+ socket.onmessage({ data: `` });
+ return { manager, p1, socket };
+ }
+
+ it('negotiates, counts and answers at the socket, with no page round-trip', () => {
+ const manager = new ConnectionManager();
+ const p1 = new MockPort();
+ manager.addPort(p1 as unknown as MessagePort);
+ p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION);
+ const socket = FakeWebSocket.instances[0];
+
+ p1.emit('_smFeatures');
+ expect(p1.msgs('_smNoState').length).toBe(1); // nothing to resume yet
+
+ p1.emit('_bound', 'romeo@example.net/orchard');
+ expect(manager.jid).toBe('romeo@example.net/orchard'); // shared JID tracks the bound one
+ expect(socket.sent.at(-1)).toBe(``);
+
+ socket.onmessage({ data: `` });
+ expect(p1.msgs('_smEnabled')).toEqual([['_smEnabled', 'sm-1', 600, 'romeo@example.net/orchard']]);
+
+ const framesBefore = p1.msgs('_onMessage').length;
+ socket.onmessage({ data: "1" });
+ socket.onmessage({ data: `` });
+ expect(socket.sent.at(-1)).toBe(``);
+ // frames still reach the tabs
+ expect(p1.msgs('_onMessage').length).toBe(framesBefore + 2);
+ });
+
+ it('writes the periodic behind the stanza that triggers it, not ahead of it', () => {
+ const { manager, p1, socket } = establish();
+ // maxUnacked defaults to 5: the 5th outbound stanza triggers an ,
+ // which must land on the wire *after* that stanza (matching the page's
+ // _queueData FIFO), so the server counts it before answering.
+ for (let i = 1; i <= 5; i++) {
+ p1.emit('send', `${i}`);
+ }
+ const fifthIdx = socket.sent.findIndex((s) => s.includes('5'));
+ const rIdx = socket.sent.findIndex((s) => s === ``);
+ expect(fifthIdx).toBeGreaterThan(-1);
+ expect(rIdx).toBe(fifthIdx + 1);
+ expect(manager.sm.state.unacked.length).toBe(5);
+ });
+
+ it('replays a stanza sent from a secondary tab after resumption', () => {
+ const { manager, p1, socket } = establish();
+
+ // a secondary attaches and receives the *bound* JID
+ const p2 = new MockPort();
+ manager.addPort(p2 as unknown as MessagePort);
+ p2.emit('_attach', SERVICE, VERSION);
+ expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHED, 'romeo@example.net/orchard']]);
+
+ // the secondary sends; the stanza enters the worker-owned queue
+ p2.emit('send', "from p2");
+ expect(socket.sent.at(-1)).toContain('from p2');
+ expect(manager.sm.state.unacked.length).toBe(1);
+
+ // the primary tab dies — the secondary is promoted with the bound JID
+ p1.emit('_bye');
+ expect(p2.msgs('_promote')).toEqual([['_promote', 'romeo@example.net/orchard']]);
+
+ // the socket dies uncleanly; the promoted tab reconnects
+ socket.readyState = FakeWebSocket.CLOSED;
+ p2.emit('_connect', SERVICE, 'romeo@example.net', VERSION);
+ const socket2 = FakeWebSocket.instances[1];
+ p2.emit('_smFeatures');
+ const resume = socket2.sent.find((s) => s.startsWith('` });
+ expect(p2.msgs('_smResumed')).toEqual([['_smResumed', 'romeo@example.net/orchard']]);
+ // the secondary's stanza was replayed from the worker queue on the new socket
+ expect(socket2.sent.filter((s) => s.includes('from p2')).length).toBe(1);
+ expect(socket2.sent.at(-1)).toBe(``);
+ expect(manager.sm.state.unacked.length).toBe(1); // still unacknowledged
+ socket2.onmessage({ data: `` });
+ expect(manager.sm.state.unacked.length).toBe(0);
+ });
+
+ it('broadcasts _smFailed on failed resumption and salvages the queue', () => {
+ const { manager, p1, socket } = establish();
+ p1.emit('send', "salvaged");
+ expect(manager.sm.state.unacked.length).toBe(1);
+
+ socket.readyState = FakeWebSocket.CLOSED;
+ p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION);
+ const socket2 = FakeWebSocket.instances[1];
+ p1.emit('_smFeatures');
+ expect(socket2.sent.some((s) => s.startsWith('`,
+ });
+ expect(p1.msgs('_smFailed').length).toBe(1);
+
+ // the primary binds again and reports; the fresh session resends the salvaged stanza
+ p1.emit('_bound', 'romeo@example.net/fresh');
+ expect(manager.jid).toBe('romeo@example.net/fresh');
+ socket2.onmessage({ data: `` });
+ const resent = socket2.sent.find((s) => s.includes('salvaged'));
+ expect(resent).toContain('urn:xmpp:delay');
+ expect(p1.msgs('_smEnabled').at(-1)).toEqual(['_smEnabled', 'sm-2', null, 'romeo@example.net/fresh']);
+ });
+
+ it('does not broadcast _smFailed when a fresh is refused', () => {
+ const manager = new ConnectionManager();
+ const p1 = new MockPort();
+ manager.addPort(p1 as unknown as MessagePort);
+ p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION);
+ const socket = FakeWebSocket.instances.at(-1);
+ p1.emit('_smFeatures'); // nothing to resume → _smNoState, no
+ p1.emit('_bound', 'romeo@example.net/orchard'); // sends a fresh
+
+ socket.onmessage({ data: `` }); // the server refuses it
+ // answering an is not the tabs' concern (the stream
+ // is bound and runs without SM); only a failed *resume* triggers rebind
+ expect(p1.msgs('_smFailed').length).toBe(0);
+ });
+
+ it('clears resume-pending on reconnect so a dropped resume cannot leave a stale flag', () => {
+ const { manager, p1, socket } = establish();
+ socket.readyState = FakeWebSocket.CLOSED;
+ p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION);
+ const socket2 = FakeWebSocket.instances[1];
+ p1.emit('_smFeatures'); // resumable → sent, resume-pending set
+ expect(socket2.sent.some((s) => s.startsWith('/ reply; the
+ // reconnect resets the engine, so the flag does not survive to make a
+ // later refused look like a failed resume
+ socket2.readyState = FakeWebSocket.CLOSED;
+ p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION);
+ expect(manager.sm.resumePending).toBe(false);
+ });
+
+ it('reports frames the peeker cannot parse while SM is active (counter-drift guard)', () => {
+ const { p1, socket } = establish();
+ const before = p1.msgs('log').length;
+
+ // both look like stanzas but the regex peeker returns null (unquoted
+ // attribute), so they pass through uncounted/untracked — surface it
+ socket.onmessage({ data: 'secret body' }); // inbound
+ p1.emit('send', ''); // outbound
+
+ const logs = p1.msgs('log').slice(before);
+ expect(logs.length).toBe(2);
+ expect(logs[0][1]).toBe('error');
+ expect(logs[1][1]).toBe('error');
+ // the opening tag is logged (so the peeker can be fixed)...
+ expect(logs[0][2]).toContain('inbound');
+ expect(logs[0][2]).toContain('');
+ expect(logs[1][2]).toContain('outbound');
+ expect(logs[1][2]).toContain('');
+ // ...but the payload after the opening tag is not
+ expect(logs[0][2]).not.toContain('secret body');
+ });
+
+ it('stays quiet for benign non-element frames (prolog, whitespace, empty)', () => {
+ const { p1, socket } = establish();
+ const before = p1.msgs('log').length;
+ socket.onmessage({ data: '' });
+ socket.onmessage({ data: ' ' });
+ socket.onmessage({ data: '' });
+ expect(p1.msgs('log').length).toBe(before);
+ });
+
+ it('salvages a stanza sent into a dead socket and replays it after resumption', () => {
+ const { manager, p1, socket } = establish();
+ socket.readyState = FakeWebSocket.CLOSED;
+ p1.emit('send', "rescued");
+ // the tab is told the truth instead of a silent drop...
+ expect(p1.msgs('_onClose').length).toBe(1);
+ expect(socket.sent.some((s) => s.includes('rescued'))).toBe(false);
+ // ...and the stanza survives in the worker-owned SM queue
+ expect(manager.sm.state.unacked.length).toBe(1);
+
+ // the tab reconnects; the worker resumes and replays the stanza
+ p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION);
+ const socket2 = FakeWebSocket.instances[1];
+ p1.emit('_smFeatures');
+ expect(socket2.sent.some((s) => s.startsWith('` });
+ expect(socket2.sent.filter((s) => s.includes('rescued')).length).toBe(1);
+ });
+
+ it('reflects a salvaged stanza to the other tabs (it will be replayed on resume)', () => {
+ const { manager, p1, socket } = establish();
+ const p2 = new MockPort();
+ manager.addPort(p2 as unknown as MessagePort);
+ p2.emit('_attach', SERVICE, VERSION);
+
+ socket.readyState = FakeWebSocket.CLOSED;
+ p1.emit('send', "rescued");
+ // the sender is told the socket is gone, but the other tab still
+ // learns of the send: the stanza sits in the SM queue and will be
+ // replayed on the next resume
+ expect(p1.msgs('_onClose').length).toBe(1);
+ expect(p2.msgs('_onStanzaSent').length).toBe(1);
+ expect(p1.msgs('_onStanzaSent')).toEqual([]);
+ });
+
+ it('a sent into a dead socket still ends the resumable session', () => {
+ const { manager, p1, socket } = establish();
+ socket.readyState = FakeWebSocket.CLOSED;
+ p1.emit('send', '');
+ expect(p1.msgs('_onClose').length).toBe(1);
+ expect(manager.sm.state.enabled).toBe(false);
+
+ // the deliberately-ended session is not resumed on the next stream
+ p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION);
+ p1.emit('_smFeatures');
+ expect(p1.msgs('_smNoState').length).toBe(2); // one from establish(), one now
+ expect(FakeWebSocket.instances[1].sent.some((s) => s.startsWith(' {
+ const { manager } = establish();
+ const p2 = new MockPort();
+ manager.addPort(p2 as unknown as MessagePort);
+ p2.emit('_attach', SERVICE, VERSION);
+ // the joiner missed the _smEnabled broadcast, so it is seeded,
+ // before ATTACHED, so the mirror is consistent when the page's
+ // attach callback runs
+ const smIdx = p2.sent.findIndex((m) => m[0] === '_smEnabled');
+ const attachedIdx = p2.sent.findIndex((m) => m[0] === '_attachCallback');
+ expect(p2.msgs('_smEnabled')).toEqual([['_smEnabled', 'sm-1', 600, 'romeo@example.net/orchard']]);
+ expect(smIdx).toBeLessThan(attachedIdx);
+ });
+
+ it('probes a quiet stream with and declares the socket dead when unanswered', () => {
+ const { manager, p1, socket } = establish();
+ const before = socket.sent.length;
+
+ // the stream goes quiet; the sweep sends an ack probe
+ vi.advanceTimersByTime(PING_INTERVAL);
+ expect(socket.sent.slice(before)).toEqual([``]);
+
+ // port liveness traffic is not socket traffic and must not save it
+ p1.emit('_pong');
+ vi.advanceTimersByTime(PROBE_TIMEOUT);
+ expect(manager.socket).toBe(null); // the zombie was closed
+ expect(p1.msgs('_onClose').length).toBe(1);
+
+ // the engine kept its state: the tab reconnects and the worker resumes
+ p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION);
+ p1.emit('_smFeatures');
+ const socket2 = FakeWebSocket.instances.at(-1);
+ expect(socket2.sent.some((s) => s.startsWith(' {
+ const { manager, p1, socket } = establish();
+ vi.advanceTimersByTime(PING_INTERVAL);
+ expect(socket.sent.at(-1)).toBe(``);
+
+ socket.onmessage({ data: `` });
+ vi.advanceTimersByTime(PROBE_TIMEOUT + 1000);
+ expect(manager.socket).not.toBe(null);
+ expect(p1.msgs('_onClose').length).toBe(0);
+
+ // still quiet: the next sweep probes again (doubles as a keepalive)
+ vi.advanceTimersByTime(PING_INTERVAL);
+ expect(socket.sent.filter((s) => s === ``).length).toBe(2);
+ });
+
+ it('probes immediately when a tab joins the established session', () => {
+ const { manager, p1, socket } = establish();
+ const before = socket.sent.length;
+
+ const p2 = new MockPort();
+ manager.addPort(p2 as unknown as MessagePort);
+ p2.emit('_connect', SERVICE, 'romeo@example.net', VERSION);
+ expect(p2.msgs('_attachCallback')).toEqual([['_attachCallback', Status.ATTACHED, 'romeo@example.net/orchard']]);
+ // the joining tab put the socket's liveness to the test right away
+ expect(socket.sent.slice(before)).toEqual([``]);
+
+ vi.advanceTimersByTime(PROBE_TIMEOUT);
+ expect(p1.msgs('_onClose').length).toBe(1);
+ expect(p2.msgs('_onClose').length).toBe(1);
+ });
+
+ it('does not probe without an SM-enabled stream', () => {
+ const manager = new ConnectionManager();
+ const p1 = new MockPort();
+ manager.addPort(p1 as unknown as MessagePort);
+ p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION);
+ const socket = FakeWebSocket.instances.at(-1);
+ p1.emit('_bound', 'romeo@example.net/orchard'); // no _smFeatures → no engine
+
+ vi.advanceTimersByTime(3 * PING_INTERVAL + PROBE_TIMEOUT);
+ expect(socket.sent.some((s) => s.startsWith(' before relaying and clears the session', () => {
+ const { manager, p1, socket } = establish();
+ socket.onmessage({ data: "1" });
+
+ p1.emit('send', '');
+ const ackIdx = socket.sent.findIndex((s) => s === ``);
+ const closeIdx = socket.sent.findIndex((s) => s.includes('xmpp-framing'));
+ expect(ackIdx).toBeGreaterThan(-1);
+ expect(closeIdx).toBeGreaterThan(ackIdx);
+ expect(manager.sm.state.enabled).toBe(false);
+
+ // a later stream has nothing to resume (one _smNoState from the
+ // initial negotiation in establish(), one from this reconnect)
+ socket.readyState = FakeWebSocket.CLOSED;
+ p1.emit('_connect', SERVICE, 'romeo@example.net', VERSION);
+ p1.emit('_smFeatures');
+ expect(p1.msgs('_smNoState').length).toBe(2);
+ expect(FakeWebSocket.instances[1].sent.some((s) => s.startsWith(' {
+ class FakeSharedWorkerPort {
+ posted: unknown[][] = [];
+ onmessage: ((ev: { data: unknown[] }) => void) | null = null;
+ start(): void {}
+ postMessage(msg: unknown[]): void {
+ this.posted.push(msg);
+ }
+ }
+ class FakeSharedWorker {
+ port = new FakeSharedWorkerPort();
+ onerror: unknown;
+ constructor(_url: string, _name?: string) {}
+ }
+
+ beforeEach(() => vi.stubGlobal('SharedWorker', FakeSharedWorker));
+ afterEach(() => vi.unstubAllGlobals());
+
+ function parse(xml: string): Element {
+ return new DOMParser().parseFromString(xml, 'text/xml').documentElement;
+ }
+
+ function makeConn(options: Record = {}) {
+ const statuses: number[] = [];
+ const conn = new Strophe.Connection(SERVICE, {
+ worker: '/dist/shared-connection-worker.js',
+ enableStreamManagement: true,
+ ...options,
+ });
+ (conn._proto as any)._setSocket();
+ conn.connected = true;
+ conn.connect_callback = (s: number) => statuses.push(s);
+ conn.jid = 'romeo@example.net';
+ const port = (conn._proto as any).worker.port as InstanceType;
+ const fromWorker = (...msg: unknown[]) => (conn._proto as any)._onWorkerMessage({ data: msg });
+ fromWorker('_role', 'primary', 'romeo@example.net');
+ return { conn, port, statuses, fromWorker };
+ }
+
+ function sendFeatures(conn: InstanceType) {
+ (conn as any)._onStreamFeaturesAfterSASL(
+ parse(``),
+ );
+ }
+
+ it('delegates the resume decision to the worker and applies _smResumed', () => {
+ const { conn, port, statuses, fromWorker } = makeConn();
+ sendFeatures(conn);
+ expect(port.posted.at(-1)).toEqual(['_smFeatures']);
+ expect(statuses).not.toContain(Status.BINDREQUIRED); // flow paused, no bind attempted
+
+ fromWorker('_smResumed', 'romeo@example.net/orchard');
+ expect(conn.jid).toBe('romeo@example.net/orchard');
+ expect(conn.hasResumed()).toBe(true);
+ expect(conn.do_bind).toBe(false);
+ expect(conn.restored).toBe(true);
+ expect(statuses).toContain(Status.CONNECTED);
+ });
+
+ it('proceeds to bind on _smNoState and reports the bound JID', () => {
+ const { conn, port, statuses, fromWorker } = makeConn();
+ sendFeatures(conn);
+ fromWorker('_smNoState');
+ // the bind IQ went out through the worker
+ expect(port.posted.some((m) => m[0] === 'send' && String(m[1]).includes('xmpp-bind'))).toBe(true);
+ (conn as any)._dataRecv(
+ parse(
+ '' +
+ 'romeo@example.net/orchard',
+ ),
+ );
+ expect(port.posted.at(-1)).toEqual(['_bound', 'romeo@example.net/orchard']);
+ expect(statuses).toContain(Status.CONNECTED);
+
+ fromWorker('_smEnabled', 'sm-1', 600, 'romeo@example.net/orchard');
+ expect(conn.isStreamManagementEnabled()).toBe(true);
+ expect(conn.sm.boundJid).toBe('romeo@example.net/orchard');
+ expect(conn.hasResumed()).toBe(false);
+ });
+
+ it('falls back to binding on _smFailed', () => {
+ const { conn, statuses, fromWorker } = makeConn({ explicitResourceBinding: true });
+ sendFeatures(conn);
+ fromWorker('_smFailed');
+ expect(conn.do_bind).toBe(true);
+ expect(statuses).toContain(Status.BINDREQUIRED);
+ expect(conn.sm.enabled).toBe(false);
+ });
+
+ it('answers the worker ping with a pong', () => {
+ const { port, fromWorker } = makeConn();
+ fromWorker('_ping');
+ expect(port.posted.at(-1)).toEqual(['_pong']);
+ });
+
+ it('fails the connect attempt when the worker reports the socket gone while connecting', () => {
+ const { conn, statuses, fromWorker } = makeConn();
+ conn.connected = false; // the connect attempt is still in flight
+ fromWorker('_onClose', 'The shared connection could not be established');
+ // The base Websocket ignores closes while not connected, which would
+ // leave the tab in CONNECTING forever; the worker transport must
+ // fail the attempt so the embedder's reconnect logic can retry.
+ expect(statuses).toContain(Status.CONNFAIL);
+ expect(statuses).toContain(Status.DISCONNECTED);
+ });
+
+ it('routes reflected stanzas to onForeignStanzaSent, not the inbound pipeline', () => {
+ const { conn, fromWorker } = makeConn();
+ const seen: Element[] = [];
+ conn.onForeignStanzaSent = (el: Element) => seen.push(el);
+ let inbound = 0;
+ conn.addHandler(
+ () => {
+ inbound += 1;
+ return true;
+ },
+ null,
+ 'message',
+ );
+ fromWorker(
+ '_onStanzaSent',
+ "from another tab",
+ );
+ expect(seen.length).toBe(1);
+ expect(seen[0].tagName).toBe('message');
+ expect(seen[0].getAttribute('to')).toBe('juliet@example.net');
+ // a sent stanza must not look like received traffic
+ expect(inbound).toBe(0);
+ expect(conn.sm.state.hIn).toBe(0);
+ });
+
+ it('the mirror is inert: the page neither counts nor queues', () => {
+ const { conn, fromWorker } = makeConn();
+ sendFeatures(conn);
+ fromWorker('_smResumed', 'romeo@example.net/orchard');
+ conn.send($msg({ to: 'juliet@example.net' }).c('body').t('hi'));
+ expect(conn.sm.state.unacked.length).toBe(0); // the worker owns the queue
+ (conn as any)._dataRecv(parse(''));
+ expect(conn.sm.state.hIn).toBe(0); // the worker owns the counters
+ expect(conn.hasResumed()).toBe(true); // ...but the state is mirrored
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index 9700162b..da9bd5c7 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,6 +1,15 @@
import { defineConfig } from 'vitest/config';
+import { fileURLToPath } from 'node:url';
export default defineConfig({
+ resolve: {
+ // tsconfig has baseUrl: "./src", so src modules import each other via
+ // bare specifiers (e.g. `from 'utils'`); mirror that for vitest.
+ alias: {
+ 'utils': fileURLToPath(new URL('./src/utils.ts', import.meta.url)),
+ 'types': fileURLToPath(new URL('./src/types.ts', import.meta.url)),
+ },
+ },
test: {
include: ['tests/*.test.ts'],
setupFiles: ['./tests/setup.ts'],