Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cc-sdk): added-health-check-and-keep-alives #2

Merged
Merged
15 changes: 15 additions & 0 deletions packages/@webex/plugin-cc/__mocks__/workerMock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class Worker {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created this so that we can mock the worker thread in tests. Kept it ouside so it can be reused.

constructor(stringUrl) {
this.url = stringUrl;
this.onmessage = () => {};
}

postMessage(msg) {
this.onmessage(msg);
}

terminate() {}
}

global.Worker = Worker;
global.URL.createObjectURL = jest.fn(() => 'blob:http://localhost:3000/12345');
40 changes: 28 additions & 12 deletions packages/@webex/plugin-cc/src/cc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ import {
StationLoginResponse,
StationLogoutResponse,
StationReLoginResponse,
SubscribeRequest,
} from './types';
import {READY, CC_FILE, EMPTY_STRING} from './constants';
import HttpRequest from './services/core/HttpRequest';
import WebCallingService from './services/WebCallingService';
import {AGENT, WEB_RTC_PREFIX} from './services/constants';
import {WebSocketManager} from './services/core/WebSocket/WebSocketManager';
import Services from './services';
import LoggerProxy from './logger-proxy';
import {ConnectionService} from './services/core/WebSocket/connection-service';
import {Logout} from './services/agent/types';
import {getErrorDetails} from './services/core/Utils';

Expand All @@ -26,9 +29,10 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter
private $config: CCPluginConfig;
private $webex: WebexSDK;
private agentConfig: IAgentProfile;
private registered = false;
private httpRequest: HttpRequest;
private webSocketManager: WebSocketManager;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decided to add an instance of the WebSocketManager here as now we will directly call the initWebSocket method to establish a connection with the web socket and use that Promise to setup the config (shown below).

private webCallingService: WebCallingService;
private connectionService: ConnectionService;
private services: Services;

constructor(...args) {
Expand All @@ -48,7 +52,14 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter
webex: this.$webex,
});

this.services = Services.getInstance();
this.webSocketManager = new WebSocketManager({webex: this.$webex});

this.connectionService = new ConnectionService(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: This is just an instance creation. We do not call any methods of this conneionService directly. Is my understanding correct?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but since we are setting up the event listeners on init, it will call the internal handlers.

this.webSocketManager,
this.getConnectionConfig()
);

this.services = Services.getInstance(this.webSocketManager);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we will inject the manager for reasons that will be explained in the Service class implementation.


this.webCallingService = new WebCallingService(this.$webex, this.$config.callingClientConfig);

Expand Down Expand Up @@ -76,17 +87,10 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter
* @private
*/
private async connectWebsocket() {
const connectionConfig = {
force: this.$config?.force ?? true,
isKeepAliveEnabled: this.$config?.isKeepAliveEnabled ?? false,
clientType: this.$config?.clientType ?? 'WebexCCSDK',
allowMultiLogin: this.$config?.allowMultiLogin ?? true,
};

try {
return this.httpRequest
.subscribeNotifications({
body: connectionConfig,
return this.webSocketManager
.initWebSocket({
body: this.getConnectionConfig(),
})
.then(async (data: WelcomeEvent) => {
const agentId = data.agentId;
Expand Down Expand Up @@ -186,4 +190,16 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter

return WEB_RTC_PREFIX + this.agentConfig.agentId;
}

/**
* This method returns the connection configuration.
*/
private getConnectionConfig(): SubscribeRequest {
return {
force: this.$config?.force ?? true,
isKeepAliveEnabled: this.$config?.isKeepAliveEnabled ?? false,
clientType: this.$config?.clientType ?? 'WebexCCSDK',
allowMultiLogin: this.$config?.allowMultiLogin ?? true,
};
}
}
71 changes: 1 addition & 70 deletions packages/@webex/plugin-cc/src/services/core/HttpRequest.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,7 @@
import {SUBSCRIBE_API, WCC_API_GATEWAY, WEBSOCKET_EVENT_TIMEOUT} from '../constants';
import {
WebexSDK,
HTTP_METHODS,
SubscribeRequest,
IHttpResponse,
WelcomeResponse,
WelcomeEvent,
RequestBody,
} from '../../types';
import IWebSocket from '../WebSocket/types';
import WebSocket from '../WebSocket';
import {CC_EVENTS, SubscribeResponse} from '../config/types';
import {EVENT} from '../../constants';

export type EventHandler = {(data: any): void};
import {WebexSDK, HTTP_METHODS, IHttpResponse, RequestBody} from '../../types';

class HttpRequest {
private webSocket: IWebSocket;
private webex: WebexSDK;
private eventHandlers: Map<string, EventHandler>;
private static instance: HttpRequest;

public static getInstance(options?: {webex: WebexSDK}): HttpRequest {
Expand All @@ -32,58 +15,6 @@ class HttpRequest {
private constructor(options: {webex: WebexSDK}) {
const {webex} = options;
this.webex = webex;
this.webSocket = new WebSocket({
parent: this.webex,
});
this.eventHandlers = new Map();

// Centralized WebSocket event listener
this.webSocket.on(EVENT, (eventData) => {
this.webex.logger.log(`Received event: ${eventData.type}`);
const handler = this.eventHandlers.get(eventData.type);
if (handler) {
handler(eventData.data);
}
});
}

public getWebSocket(): IWebSocket {
return this.webSocket;
}

/* This calls subscribeNotifications and establishes a websocket connection
* It sends the request and then listens for the Welcome event
* If the Welcome event is received, it resolves the promise
* If the Welcome event is not received, it rejects the promise
*/
public async subscribeNotifications(options: {body: SubscribeRequest}): Promise<WelcomeResponse> {
const {body} = options;
const eventType = CC_EVENTS.WELCOME;
const subscribeResponse: SubscribeResponse = await this.webex.request({
service: WCC_API_GATEWAY,
resource: SUBSCRIBE_API,
method: HTTP_METHODS.POST,
body,
});

return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.webex.logger.error('Timeout waiting for event');
this.eventHandlers.delete(eventType);
reject(new Error('Timeout waiting for event'));
}, WEBSOCKET_EVENT_TIMEOUT);

// Store the event handler
this.eventHandlers.set(eventType, (data: WelcomeEvent) => {
clearTimeout(timeoutId);
this.eventHandlers.delete(eventType);
resolve(data);
});

this.webSocket.connectWebSocket({
webSocketUrl: subscribeResponse.body.webSocketUrl,
});
});
}

public async request(options: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import {WebexSDK, SubscribeRequest, HTTP_METHODS, WelcomeResponse} from '../../../types';
import {SUBSCRIBE_API, WCC_API_GATEWAY} from '../../constants';
import {SubscribeResponse} from '../../config/types';
import LoggerProxy from '../../../logger-proxy';
import workerScript from './keepalive.worker';
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note -
This is a workaround, currently I was not able to find any way to import the script the same way we were doing in agent desktop (as that is a node server).
However this workaround can be improved (later) wherein we read from the .js script file rather than exporting the string.

import {KEEPALIVE_WORKER_INTERVAL, CLOSE_SOCKET_TIMEOUT} from '../constants';

export class WebSocketManager extends EventTarget {
private websocket: WebSocket;
shouldReconnect: boolean;
isSocketClosed: boolean;
private isWelcomeReceived: boolean;
private url: string | null = null;
private forceCloseWebSocketOnTimeout: boolean;
private isConnectionLost: boolean;
private webex: WebexSDK;
private welcomePromiseResolve:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a needed change so that when we call the initWebSocket from the cc.ts, we are able to return a promise to resolve the initial registration. This differs from agentx which was calling it from the aqm-reqs and hence it could be handled via signals, however since we call it from cc.ts, I had to add this here for that.
It works fine (as shown in the GIF).

| ((value: WelcomeResponse | PromiseLike<WelcomeResponse>) => void)
| null = null;

private keepaliveWorker: Worker;

constructor(options: {webex: WebexSDK}) {
super();
const {webex} = options;
this.webex = webex;
this.shouldReconnect = true;
this.websocket = {} as WebSocket;
this.isSocketClosed = false;
this.isWelcomeReceived = false;
this.forceCloseWebSocketOnTimeout = false;
this.isConnectionLost = false;

const workerScriptBlob = new Blob([workerScript], {type: 'application/javascript'});
this.keepaliveWorker = new Worker(URL.createObjectURL(workerScriptBlob));
}

async initWebSocket(options: {body: SubscribeRequest}): Promise<WelcomeResponse> {
const connectionConfig = options.body;
await this.register(connectionConfig);

return new Promise((resolve, reject) => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again as mentioned, this promise is for the registration flow, if the connect fails, we simply reject the promise, it works for both cases.

this.welcomePromiseResolve = resolve;
this.connect().catch((error) => {
LoggerProxy.logger.error(`[WebSocketStatus] | Error in connecting Websocket ${error}`);
reject(error);
});
});
}

close(shouldReconnect: boolean, reason = 'Unknown') {
if (!this.isSocketClosed && this.shouldReconnect) {
this.shouldReconnect = shouldReconnect;
this.websocket.close();
this.keepaliveWorker.postMessage({type: 'terminate'});
LoggerProxy.logger.error(
`[WebSocketStatus] | event=webSocketClose | WebSocket connection closed manually REASON: ${reason}`
);
}
}

private async register(connectionConfig: SubscribeRequest) {
try {
const subscribeResponse: SubscribeResponse = await this.webex.request({
service: WCC_API_GATEWAY,
resource: SUBSCRIBE_API,
method: HTTP_METHODS.POST,
body: connectionConfig,
});
this.url = subscribeResponse.body.webSocketUrl;
} catch (e) {
LoggerProxy.logger.error(
`Register API Failed, Request to RoutingNotifs websocket registration API failed ${e}`
);
}
}

private async connect() {
if (!this.url) {
return undefined;
}
LoggerProxy.logger.log(
`[WebSocketStatus] | event=webSocketConnecting | Connecting to WebSocket: ${this.url}`
);
this.websocket = new WebSocket(this.url);

return new Promise((resolve, reject) => {
this.websocket.onopen = () => {
this.isSocketClosed = false;
this.shouldReconnect = true;

this.websocket.send(JSON.stringify({keepalive: 'true'}));
this.keepaliveWorker.onmessage = (keepAliveEvent: {data: any}) => {
if (keepAliveEvent?.data?.type === 'keepalive') {
this.websocket.send(JSON.stringify({keepalive: 'true'}));
}

if (keepAliveEvent?.data?.type === 'closeSocket' && this.isConnectionLost) {
this.forceCloseWebSocketOnTimeout = true;
this.close(true, 'WebSocket did not auto close within 16 secs');
LoggerProxy.logger.error(
'[webSocketTimeout] | event=webSocketTimeout | WebSocket connection closed forcefully'
);
}
};

this.keepaliveWorker.postMessage({
type: 'start',
intervalDuration: KEEPALIVE_WORKER_INTERVAL, // Keepalive interval
isSocketClosed: this.isSocketClosed,
closeSocketTimeout: CLOSE_SOCKET_TIMEOUT, // Close socket timeout
});
};

this.websocket.onerror = (event: any) => {
LoggerProxy.logger.error(
`[WebSocketStatus] | event=socketConnectionFailed | WebSocket connection failed ${event}`
);
reject();
};

this.websocket.onclose = async (event: any) => {
this.webSocketOnCloseHandler(event);
};

this.websocket.onmessage = (e: MessageEvent) => {
this.dispatchEvent(new CustomEvent('message', {detail: e.data}));
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per discussion with Ravi, I have switched over to using events here as opposed to singals. I felt this was a more straight-forward solution. Now, I am not using custom event handlers here as it was working with events, however I would like someone to point out any edge cases or issues that might arise (most things are working including reconnect logic).

const eventData = JSON.parse(e.data);

if (eventData.type === 'Welcome') {
this.isWelcomeReceived = true;
if (this.welcomePromiseResolve) {
this.welcomePromiseResolve(eventData.data as WelcomeResponse);
this.welcomePromiseResolve = null;
}
}

if (eventData.type === 'AGENT_MULTI_LOGIN') {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, all have been taken from agent desktop, so if we do not require this, we can remove them.
But overall the logic is pretty much the same.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is okay to keep this as this whole copy-paste effort is keeping the WxCC Desktop use cases in mind

this.close(false, 'multiLogin');
LoggerProxy.logger.error(
'[WebSocketStatus] | event=agentMultiLogin | WebSocket connection closed by agent multiLogin'
);
}
};
});
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
private async webSocketOnCloseHandler(event: any) {
this.isSocketClosed = true;
this.keepaliveWorker.postMessage({type: 'terminate'});
if (this.shouldReconnect) {
this.dispatchEvent(new Event('socketClose'));
let issueReason;
if (this.forceCloseWebSocketOnTimeout) {
issueReason = 'WebSocket auto close timed out. Forcefully closed websocket.';
} else {
const onlineStatus = navigator.onLine;
LoggerProxy.logger.info(`[WebSocketStatus] | desktop online status is ${onlineStatus}`);
issueReason = !onlineStatus
? 'network issue'
: 'missing keepalive from either desktop or notif service';
}
LoggerProxy.logger.error(
`[WebSocketStatus] | event=webSocketClose | WebSocket connection closed REASON: ${issueReason}`
);
this.forceCloseWebSocketOnTimeout = false;
}
}
}
Loading
Loading