Skip to content

Commit

Permalink
feat: support multiple chat components sharing a subscription (#2913)
Browse files Browse the repository at this point in the history
makes sessionId to be unique per component
makes groupId unique for a given chatId per browser instance
  • Loading branch information
gavinbarron committed Feb 8, 2024
1 parent 188b1cb commit eeea1d6
Show file tree
Hide file tree
Showing 11 changed files with 144 additions and 63 deletions.
22 changes: 8 additions & 14 deletions packages/mgt-chat/src/statefulClient/Caching/SubscriptionCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,41 +12,36 @@ import { IDBPObjectStore } from 'idb';

type CachedSubscriptionData = CacheItem & {
chatId: string;
sessionId: string;
subscriptions: Subscription[];
lastAccessDateTime: string;
};

const buildCacheKey = (chatId: string, sessionId: string): string => `${chatId}:${sessionId}`;

export class SubscriptionsCache {
private get cache(): CacheStore<CachedSubscriptionData> {
const conversation: CacheSchema = schemas.conversation;
return CacheService.getCache<CachedSubscriptionData>(conversation, conversation.stores.subscriptions);
}

public async loadSubscriptions(chatId: string, sessionId: string): Promise<CachedSubscriptionData | undefined> {
public async loadSubscriptions(chatId: string): Promise<CachedSubscriptionData | undefined> {
if (isConversationCacheEnabled()) {
const cacheKey = buildCacheKey(chatId, sessionId);
let data;
await this.cache.transaction(async (store: IDBPObjectStore<unknown, [string], string, 'readwrite'>) => {
data = (await store.get(cacheKey)) as CachedSubscriptionData | undefined;
data = (await store.get(chatId)) as CachedSubscriptionData | undefined;
if (data) {
data.lastAccessDateTime = new Date().toISOString();
await store.put(data, cacheKey);
await store.put(data, chatId);
}
});
return data || undefined;
}
return undefined;
}

public async cacheSubscription(chatId: string, sessionId: string, subscriptionRecord: Subscription): Promise<void> {
public async cacheSubscription(chatId: string, subscriptionRecord: Subscription): Promise<void> {
await this.cache.transaction(async (store: IDBPObjectStore<unknown, [string], string, 'readwrite'>) => {
log('cacheSubscription', subscriptionRecord);
const cacheKey = buildCacheKey(chatId, sessionId);

let cacheEntry = (await store.get(cacheKey)) as CachedSubscriptionData | undefined;
let cacheEntry = (await store.get(chatId)) as CachedSubscriptionData | undefined;
if (cacheEntry && cacheEntry.chatId === chatId) {
const subIndex = cacheEntry.subscriptions.findIndex(s => s.resource === subscriptionRecord.resource);
if (subIndex !== -1) {
Expand All @@ -57,7 +52,6 @@ export class SubscriptionsCache {
} else {
cacheEntry = {
chatId,
sessionId,
subscriptions: [subscriptionRecord],
// we're cheating a bit here to ensure that we have a defined lastAccessDateTime
// but we're updating the value for all cases before storing it.
Expand All @@ -66,12 +60,12 @@ export class SubscriptionsCache {
}
cacheEntry.lastAccessDateTime = new Date().toISOString();

await store.put(cacheEntry, cacheKey);
await store.put(cacheEntry, chatId);
});
}

public deleteCachedSubscriptions(chatId: string, sessionId: string): Promise<void> {
return this.cache.delete(buildCacheKey(chatId, sessionId));
public deleteCachedSubscriptions(chatId: string): Promise<void> {
return this.cache.delete(chatId);
}

public loadInactiveSubscriptions(inactivityThreshold: string): Promise<CachedSubscriptionData[]> {
Expand Down
8 changes: 6 additions & 2 deletions packages/mgt-chat/src/statefulClient/GraphConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { GraphEndpoint } from '@microsoft/mgt-element';
import { replaceOrAppendSessionId } from './replaceOrAppendSessionId';

export class GraphConfig {
public static ackAsString = false;
Expand All @@ -30,11 +31,14 @@ export class GraphConfig {
return GraphConfig.useCanary ? `${GraphConfig.baseCanaryUrl}/subscriptions` : '/subscriptions';
}

public static adjustNotificationUrl(url: string): string {
public static adjustNotificationUrl(url: string, sessionId = 'default'): string {
if (GraphConfig.useCanary && url) {
url = url.replace('https://graph.microsoft.com/1.0', GraphConfig.baseCanaryUrl);
url = url.replace('https://graph.microsoft.com/beta', GraphConfig.baseCanaryUrl);
}
return url.replace(GraphConfig.webSocketsPrefix, '');
url = url.replace(GraphConfig.webSocketsPrefix, '');
// update or append sessionid
url = replaceOrAppendSessionId(url, sessionId);
return url;
}
}
40 changes: 18 additions & 22 deletions packages/mgt-chat/src/statefulClient/GraphNotificationClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
import { GraphConfig } from './GraphConfig';
import { SubscriptionsCache } from './Caching/SubscriptionCache';
import { Timer } from '../utils/Timer';
import { getOrGenerateGroupId } from './getOrGenerateGroupId';

export const appSettings = {
defaultSubscriptionLifetimeInMinutes: 10,
Expand Down Expand Up @@ -84,14 +85,12 @@ export class GraphNotificationClient {
/**
* Removes any active timers that may exist to prevent memory leaks and perf issues.
* Call this method when the component that depends an instance of this class is being removed from the DOM
* i.e
*/
public async tearDown() {
public tearDown() {
log('cleaning up graph notification resources');
if (this.cleanupTimeout) this.timer.clearTimeout(this.cleanupTimeout);
if (this.renewalTimeout) this.timer.clearTimeout(this.renewalTimeout);
this.timer.close();
await this.unsubscribeFromChatNotifications(this.chatId, this.sessionId);
}

private readonly getToken = async () => {
Expand Down Expand Up @@ -177,7 +176,7 @@ export class GraphNotificationClient {
private readonly cacheSubscription = async (subscriptionRecord: Subscription): Promise<void> => {
log(subscriptionRecord);

await this.subscriptionCache.cacheSubscription(this.chatId, this.sessionId, subscriptionRecord);
await this.subscriptionCache.cacheSubscription(this.chatId, subscriptionRecord);

// only start timer once. undefined for renewalInterval is semaphore it has stopped.
if (this.renewalTimeout === undefined) this.startRenewalTimer();
Expand All @@ -190,7 +189,7 @@ export class GraphNotificationClient {
).toISOString();
const subscriptionDefinition: Subscription = {
changeType: changeTypes.join(','),
notificationUrl: `${GraphConfig.webSocketsPrefix}?groupId=${this.chatId}&sessionId=${this.sessionId}`,
notificationUrl: `${GraphConfig.webSocketsPrefix}?groupId=${getOrGenerateGroupId(this.chatId)}`,
resource: resourcePath,
expirationDateTime,
includeResourceData: true,
Expand Down Expand Up @@ -227,8 +226,7 @@ export class GraphNotificationClient {

private readonly renewalTimer = async () => {
log(`running subscription renewal timer for chatId: ${this.chatId} sessionId: ${this.sessionId}`);
const subscriptions =
(await this.subscriptionCache.loadSubscriptions(this.chatId, this.sessionId))?.subscriptions || [];
const subscriptions = (await this.subscriptionCache.loadSubscriptions(this.chatId))?.subscriptions || [];
if (subscriptions.length === 0) {
log(`No subscriptions found in session state. Stop renewal timer ${this.renewalTimeout}.`);
if (this.renewalTimeout) this.timer.clearTimeout(this.renewalTimeout);
Expand Down Expand Up @@ -260,7 +258,7 @@ export class GraphNotificationClient {
new Date().getTime() + appSettings.defaultSubscriptionLifetimeInMinutes * 60 * 1000
);

const subscriptionCache = await this.subscriptionCache.loadSubscriptions(this.chatId, this.sessionId);
const subscriptionCache = await this.subscriptionCache.loadSubscriptions(this.chatId);
const awaits: Promise<unknown>[] = [];
for (const subscription of subscriptionCache?.subscriptions || []) {
if (!subscription.id) continue;
Expand Down Expand Up @@ -291,7 +289,7 @@ export class GraphNotificationClient {
withCredentials: false
};
const connection = new HubConnectionBuilder()
.withUrl(GraphConfig.adjustNotificationUrl(notificationUrl), connectionOptions)
.withUrl(GraphConfig.adjustNotificationUrl(notificationUrl, this.sessionId), connectionOptions)
.withAutomaticReconnect()
.configureLogging(LogLevel.Information)
.build();
Expand Down Expand Up @@ -352,7 +350,7 @@ export class GraphNotificationClient {
await Promise.all(tasks);
tasks = [];
for (const inactive of inactiveSubs) {
tasks.push(this.subscriptionCache.deleteCachedSubscriptions(inactive.chatId, inactive.sessionId));
tasks.push(this.subscriptionCache.deleteCachedSubscriptions(inactive.chatId));
}
this.startCleanupTimer();
};
Expand All @@ -363,41 +361,39 @@ export class GraphNotificationClient {
this.connection = undefined;
}

private async unsubscribeFromChatNotifications(chatId: string, sessionId: string) {
private async unsubscribeFromChatNotifications(chatId: string) {
await this.closeSignalRConnection();
const cacheData = await this.subscriptionCache.loadSubscriptions(chatId, sessionId);
const cacheData = await this.subscriptionCache.loadSubscriptions(chatId);
if (cacheData) {
await Promise.all([
this.removeSubscriptions(cacheData.subscriptions),
this.subscriptionCache.deleteCachedSubscriptions(chatId, sessionId)
this.subscriptionCache.deleteCachedSubscriptions(chatId)
]);
}
}

public async subscribeToChatNotifications(chatId: string, sessionId: string) {
// if we have a "previous" chat state at present, unsubscribe for the previous chatId
if (this.chatId && this.sessionId && chatId !== this.chatId) {
await this.unsubscribeFromChatNotifications(this.chatId, this.sessionId);
}
this.chatId = chatId;
this.sessionId = sessionId;
// MGT uses a per-user cache, so no concerns of loading the cached data for another user.
const cacheData = await this.subscriptionCache.loadSubscriptions(chatId, sessionId);
const cacheData = await this.subscriptionCache.loadSubscriptions(chatId);
if (cacheData) {
// check subscription validity & renew if all still valid otherwise recreate
const someExpired = cacheData.subscriptions.some(
s => s.expirationDateTime && new Date(s.expirationDateTime) <= new Date()
);
// for a given user + app + chatId + sessionId they only get one websocket and receive all notifications via that websocket.
const webSocketUrl = cacheData.subscriptions.find(s => s.notificationUrl)?.notificationUrl;
if (someExpired) {
await this.removeSubscriptions(cacheData.subscriptions);
} else if (webSocketUrl) {
if (!someExpired && webSocketUrl) {
// if we have a websocket url and all the subscriptions are valid, we can reuse the websocket and return before recreating subscriptions.
await this.createSignalRConnection(webSocketUrl);
await this.renewChatSubscriptions();
return;
} else if (someExpired) {
// if some are expired, remove them and continue to recreate the subscription
await this.removeSubscriptions(cacheData.subscriptions);
}
await this.subscriptionCache.deleteCachedSubscriptions(chatId, sessionId);
await this.subscriptionCache.deleteCachedSubscriptions(chatId);
}
const promises: Promise<unknown>[] = [];
promises.push(this.subscribeToResource(`/chats/${chatId}/messages`, ['created', 'updated', 'deleted']));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,8 @@ class StatefulGraphChatClient implements StatefulClient<GraphChatClient> {
/**
* Provides a method to clean up any resources being used internally when a consuming component is being removed from the DOM
*/
public async tearDown() {
await this._notificationClient?.tearDown();
public tearDown() {
this._notificationClient?.tearDown();
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* -------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
* See License in the project root for license information.
* -------------------------------------------------------------------------------------------
*/

import { expect } from '@open-wc/testing';
import { allChatScopes } from './chatOperationScopes';

Expand Down
26 changes: 26 additions & 0 deletions packages/mgt-chat/src/statefulClient/getOrGenerateGroupId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* -------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
* See License in the project root for license information.
* -------------------------------------------------------------------------------------------
*/

import { v4 as uuid } from 'uuid';

const keyPrefix = 'mgt-chat-group-id';

/**
* reads a string from session storage, or if there is no string for the keyName, generate a new uuid and place in storage
*/
export const getOrGenerateGroupId = (chatId: string) => {
const key = `${keyPrefix}::${chatId}`;
const value = localStorage.getItem(key);

if (value) {
return value;
} else {
const newValue = uuid();
localStorage.setItem(key, newValue);
return newValue;
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* -------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
* See License in the project root for license information.
* -------------------------------------------------------------------------------------------
*/

import { replaceOrAppendSessionId } from './replaceOrAppendSessionId';
import { expect } from '@open-wc/testing';

describe('replaceOrAppendSessionId tests', () => {
it('should replace existing sessionid', async () => {
const result = replaceOrAppendSessionId('https://graph.microsoft.com/1.0/me?sessionid=default', '123');
await expect(result).to.equal('https://graph.microsoft.com/1.0/me?sessionid=123');
});
it('should replace existing sessionid when there are query string params after sessionid', async () => {
const result = replaceOrAppendSessionId('https://graph.microsoft.com/1.0/me?sessionid=default&foo=bar', '123');
await expect(result).to.equal('https://graph.microsoft.com/1.0/me?sessionid=123&foo=bar');
});
it('should replace existing sessionid when there are query string params before sessionid', async () => {
const result = replaceOrAppendSessionId('https://graph.microsoft.com/1.0/me?foo=bar&sessionid=default', '123');
await expect(result).to.equal('https://graph.microsoft.com/1.0/me?foo=bar&sessionid=123');
});
it('should append sessionid there is a query string params with sessionid as a substring', async () => {
const result = replaceOrAppendSessionId('https://graph.microsoft.com/1.0/me?foosessionid=bar', '123');
await expect(result).to.equal('https://graph.microsoft.com/1.0/me?foosessionid=bar&sessionid=123');
});
it('should replace sessionid there is also a query string params with sessionid as a substring', async () => {
const result = replaceOrAppendSessionId(
'https://graph.microsoft.com/1.0/me?foosessionid=bar&sessionid=default&bar=too',
'123'
);
await expect(result).to.equal('https://graph.microsoft.com/1.0/me?foosessionid=bar&sessionid=123&bar=too');
});
it('should append sessionid with ? when no query params present', async () => {
const result = replaceOrAppendSessionId('https://graph.microsoft.com/1.0/me', '123');
await expect(result).to.equal('https://graph.microsoft.com/1.0/me?sessionid=123');
});
it('should append sessionid with & when query params are present', async () => {
const result = replaceOrAppendSessionId('https://graph.microsoft.com/1.0/me?foo=bar', '123');
await expect(result).to.equal('https://graph.microsoft.com/1.0/me?foo=bar&sessionid=123');
});
});
18 changes: 18 additions & 0 deletions packages/mgt-chat/src/statefulClient/replaceOrAppendSessionId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* -------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
* See License in the project root for license information.
* -------------------------------------------------------------------------------------------
*/

export const replaceOrAppendSessionId = (url: string, sessionId: string) => {
// match on any whole query parameter of sessionid and include the value
const sessionIdRegex = /([?&]{1})sessionid=[^&]*/;
if (url.match(sessionIdRegex)) {
url = url.replace(sessionIdRegex, `$1sessionid=${sessionId}`);
} else {
const paramSeparator = url.indexOf('?') > -1 ? '&' : '?';
url = `${url}${paramSeparator}sessionid=${sessionId}`;
}
return url;
};
25 changes: 2 additions & 23 deletions packages/mgt-chat/src/statefulClient/useGraphChatClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,12 @@ import { v4 as uuid } from 'uuid';
import { StatefulGraphChatClient } from './StatefulGraphChatClient';
import { log } from '@microsoft/mgt-element';

/**
* The key name to use for storing the sessionId in session storage
*/
const keyName = 'mgt-chat-session-id';

/**
* reads a string from session storage, or if there is no string for the keyName, generate a new uuid and place in storage
*/
const getOrGenerateSessionId = () => {
const value = sessionStorage.getItem(keyName);

if (value) {
return value;
} else {
const newValue = uuid();
sessionStorage.setItem(keyName, newValue);
return newValue;
}
};

/**
* Provides a stable sessionId for the lifetime of the browser tab.
* @returns a string that is either read from session storage or generated and placed in session storage
*/
const useSessionId = (): string => {
// when a function is passed to useState, it is only invoked on the first render
const [sessionId] = useState<string>(getOrGenerateSessionId);
const [sessionId] = useState<string>(() => uuid());

return sessionId;
};
Expand All @@ -63,7 +42,7 @@ export const useGraphChatClient = (chatId: string): StatefulGraphChatClient => {
useEffect(() => {
return () => {
log('invoked clean up effect');
void chatClient.tearDown();
chatClient.tearDown();
};
}, [chatClient]);

Expand Down
7 changes: 7 additions & 0 deletions packages/mgt-chat/src/utils/Timer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* -------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
* See License in the project root for license information.
* -------------------------------------------------------------------------------------------
*/

import { v4 as uuid } from 'uuid';
import { TimerWork } from './timerWorker';

Expand Down
Loading

0 comments on commit eeea1d6

Please sign in to comment.