From 34bb6a647c37f2e34b01d0e431f708a36a2ddce0 Mon Sep 17 00:00:00 2001 From: Simon Woolf Date: Wed, 15 May 2024 18:03:29 +0100 Subject: [PATCH] Support optional multiple recovery scopes New client option where, if specified, the SDK's recovery key will be persisted under that identifier (in sessionstorage), so only another library instance which specifies the same recoveryScope 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. renamed recoveryScope to recoveryKeyStorageName --- ably.d.ts | 12 +++++++ src/common/lib/transport/connectionmanager.ts | 36 +++++++++++-------- test/browser/connection.test.js | 32 ++++++++++++++++- 3 files changed, 64 insertions(+), 16 deletions(-) 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();