diff --git a/ably.d.ts b/ably.d.ts index 2f6adef4e..533b57015 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -463,6 +463,18 @@ export interface ClientOptions extends AuthOptions { */ recover?: string | recoverConnectionCallback; + /** + * If specified, the SDK's internal persistence mechanism for storing the recovery key + * over page loads (see the `recover` client option) will store the recovery key under + * this identifier (in sessionstorage), so only another library instance which specifies + * the same recoveryKeyStorageName will attempt to recover from it. This is useful if you have + * multiple ably-js instances sharing a given origin (the origin being the scope of + * sessionstorage), as otherwise the multiple instances will overwrite each other's + * recovery keys, and after a reload they will all try and recover the same connection, + * which is not permitted and will cause broken behaviour. + */ + recoveryKeyStorageName?: string; + /** * When `false`, the client will use an insecure connection. The default is `true`, meaning a TLS connection will be used to connect to Ably. * diff --git a/src/common/lib/transport/connectionmanager.ts b/src/common/lib/transport/connectionmanager.ts index 07cd440c4..3d811909e 100644 --- a/src/common/lib/transport/connectionmanager.ts +++ b/src/common/lib/transport/connectionmanager.ts @@ -31,17 +31,6 @@ const haveSessionStorage = () => typeof Platform.WebStorage !== 'undefined' && P const noop = function () {}; const transportPreferenceName = 'ably-transport-preference'; -const sessionRecoveryName = 'ably-connection-recovery'; -function getSessionRecoverData() { - return haveSessionStorage() && Platform.WebStorage?.getSession?.(sessionRecoveryName); -} -function setSessionRecoverData(value: any) { - return haveSessionStorage() && Platform.WebStorage?.setSession?.(sessionRecoveryName, value); -} -function clearSessionRecoverData() { - return haveSessionStorage() && Platform.WebStorage?.removeSession?.(sessionRecoveryName); -} - function bundleWith(dest: ProtocolMessage, src: ProtocolMessage, maxSize: number) { let action; if (dest.channel !== src.channel) { @@ -431,12 +420,15 @@ class ConnectionManager extends EventEmitter { } const recoverFn = this.options.recover, - lastSessionData = getSessionRecoverData(); + lastSessionData = this.getSessionRecoverData(), + sessionRecoveryName = this.sessionRecoveryName(); if (lastSessionData && typeof recoverFn === 'function') { Logger.logAction( Logger.LOG_MINOR, 'ConnectionManager.getTransportParams()', - 'Calling clientOptions-provided recover function with last session data', + 'Calling clientOptions-provided recover function with last session data (recovery scope: ' + + sessionRecoveryName + + ')', ); recoverFn(lastSessionData, (shouldRecover?: boolean) => { if (shouldRecover) { @@ -893,7 +885,7 @@ class ConnectionManager extends EventEmitter { if (haveSessionStorage()) { const recoveryKey = this.createRecoveryKey(); if (recoveryKey) { - setSessionRecoverData({ + this.setSessionRecoverData({ recoveryKey: recoveryKey, disconnectedAt: Date.now(), location: globalObject.location, @@ -908,7 +900,7 @@ class ConnectionManager extends EventEmitter { * state for later recovery. Only applicable in the browser context. */ unpersistConnection(): void { - clearSessionRecoverData(); + this.clearSessionRecoverData(); } /********************* @@ -1960,6 +1952,20 @@ class ConnectionManager extends EventEmitter { }; }); } + + sessionRecoveryName() { + return this.options.recoveryKeyStorageName || 'ably-connection-recovery'; + } + + getSessionRecoverData() { + return haveSessionStorage() && Platform.WebStorage?.getSession?.(this.sessionRecoveryName()); + } + setSessionRecoverData(value: any) { + return haveSessionStorage() && Platform.WebStorage?.setSession?.(this.sessionRecoveryName(), value); + } + clearSessionRecoverData() { + return haveSessionStorage() && Platform.WebStorage?.removeSession?.(this.sessionRecoveryName()); + } } export default ConnectionManager; diff --git a/test/browser/connection.test.js b/test/browser/connection.test.js index 870b0a9f7..cddec7528 100644 --- a/test/browser/connection.test.js +++ b/test/browser/connection.test.js @@ -1,7 +1,7 @@ 'use strict'; define(['shared_helper', 'chai'], function (helper, chai) { - var expect = chai.expect; + var { expect, assert } = chai; var closeAndFinish = helper.closeAndFinish; var monitorConnection = helper.monitorConnection; var simulateDroppedConnection = helper.simulateDroppedConnection; @@ -350,6 +350,36 @@ define(['shared_helper', 'chai'], function (helper, chai) { }); }); + it('page_refresh_with_multiple_recovery_scopes', async () => { + const realtimeOpts = { recover: (_, cb) => cb(true) }, + opts1 = Object.assign({ recoveryKeyStorageName: 'recovery-1' }, realtimeOpts), + opts2 = Object.assign({ recoveryKeyStorageName: 'recovery-2' }, realtimeOpts), + realtime1 = helper.AblyRealtime(opts1), + realtime2 = helper.AblyRealtime(opts2), + refreshEvent = new Event('beforeunload', { bubbles: true }); + + await Promise.all([realtime1.connection.once('connected'), realtime2.connection.once('connected')]); + const connId1 = realtime1.connection.id; + const connId2 = realtime2.connection.id; + + document.dispatchEvent(refreshEvent); + + simulateDroppedConnection(realtime1); + simulateDroppedConnection(realtime2); + + await new Promise((res) => setTimeout(res, 1000)); + + const newRealtime1 = helper.AblyRealtime(opts1); + const newRealtime2 = helper.AblyRealtime(opts2); + await Promise.all([newRealtime1.connection.once('connected'), newRealtime2.connection.once('connected')]); + assert.equal(connId1, newRealtime1.connection.id); + assert.equal(connId2, newRealtime2.connection.id); + + await Promise.all( + [realtime1, realtime2, newRealtime1, newRealtime2].map((rt) => helper.closeAndFinishAsync(rt)), + ); + }); + it('persist_preferred_transport', function (done) { var realtime = helper.AblyRealtime();