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}`; + } + const idx = serialized.lastIndexOf(`/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'],