Skip to content

Commit

Permalink
Support optional multiple recovery scopes
Browse files Browse the repository at this point in the history
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
  • Loading branch information
SimonWoolf committed May 17, 2024
1 parent dba5fa0 commit 34bb6a6
Show file tree
Hide file tree
Showing 3 changed files with 64 additions and 16 deletions.
12 changes: 12 additions & 0 deletions ably.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,18 @@ export interface ClientOptions<Plugins = CorePlugins> 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.
*
Expand Down
36 changes: 21 additions & 15 deletions src/common/lib/transport/connectionmanager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -908,7 +900,7 @@ class ConnectionManager extends EventEmitter {
* state for later recovery. Only applicable in the browser context.
*/
unpersistConnection(): void {
clearSessionRecoverData();
this.clearSessionRecoverData();
}

/*********************
Expand Down Expand Up @@ -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;
Expand Down
32 changes: 31 additions & 1 deletion test/browser/connection.test.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();

Expand Down

0 comments on commit 34bb6a6

Please sign in to comment.