Skip to content

Commit

Permalink
feat: multi purpose websocket client
Browse files Browse the repository at this point in the history
  • Loading branch information
ifaouibadi committed Jul 13, 2023
1 parent 3532215 commit 1b6c50e
Show file tree
Hide file tree
Showing 4 changed files with 223 additions and 1 deletion.
178 changes: 178 additions & 0 deletions src/lib/WebSocketClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/* eslint-disable class-methods-use-this */
import { v4 as genUuid } from 'uuid';
import camelcaseKeysDeep from 'camelcase-keys-deep';
import type {
IMiddlewareWebSocketSubscriptionMessage,
ITopHeader,
ITransaction,
WebSocketChannelName,
} from '../types';
import {
WEB_SOCKET_SOURCE,
WEB_SOCKET_CHANNELS,
WEB_SOCKET_SUBSCRIBE,
WEB_SOCKET_UNSUBSCRIBE,
WEB_SOCKET_RECONNECT_TIMEOUT,
handleUnknownError,
NETWORK_MAINNET,
} from '../popup/utils';

class WebSocketClient {
private static instance: WebSocketClient;

wsClient: WebSocket = new WebSocket(NETWORK_MAINNET.websocketUrl);

isWsConnected: boolean = false;

subscribersQueue: IMiddlewareWebSocketSubscriptionMessage[] = [];

subscribers: Record<WebSocketChannelName, Record<string, (
payload: ITransaction | ITopHeader
) => void>> = {
[WEB_SOCKET_CHANNELS.Transactions]: {},
[WEB_SOCKET_CHANNELS.MicroBlocks]: {},
[WEB_SOCKET_CHANNELS.KeyBlocks]: {},
[WEB_SOCKET_CHANNELS.Object]: {},
};

handleWebsocketOpen() {
this.isWsConnected = true;
try {
this.subscribersQueue.forEach((message) => {
this.wsClient.send(JSON.stringify(message));
});
} catch (error) {
handleUnknownError(error);
setTimeout(() => {
this.handleWebsocketOpen();
}, WEB_SOCKET_RECONNECT_TIMEOUT);
}
}

private handleWebsocketClose() {
this.isWsConnected = false;
}

isConnected(): boolean {
return this.isWsConnected;
}

subscribeForChannel(
message: IMiddlewareWebSocketSubscriptionMessage,
callback: (payload: any) => void,
) {
if (this.isWsConnected) {
Object.keys(WEB_SOCKET_SOURCE).forEach((source) => {
this.wsClient.send(JSON.stringify({
...message,
source,
}));
});
}

this.subscribersQueue.push(message);

const uuid = genUuid();
this.subscribers[message.payload][uuid] = callback;
return () => {
delete this.subscribers[message.payload][uuid];
};
}

subscribeForAccountUpdates(address: string, callback: (payload: ITransaction) => void) {
return this.subscribeForChannel(
{
op: WEB_SOCKET_SUBSCRIBE,
payload: WEB_SOCKET_CHANNELS.Object,
target: address,
},
callback,
);
}

subscribeForTransactionsUpdates(callback: (payload: ITransaction) => void) {
return this.subscribeForChannel(
{
op: WEB_SOCKET_SUBSCRIBE,
payload: WEB_SOCKET_CHANNELS.Transactions,
},
callback,
);
}

subscribeForMicroBlocksUpdates(callback: (payload: ITopHeader) => void) {
return this.subscribeForChannel(
{
op: WEB_SOCKET_SUBSCRIBE,
payload: WEB_SOCKET_CHANNELS.MicroBlocks,
},
callback,
);
}

subscribeForKeyBlocksUpdates(callback: (payload: ITopHeader) => void) {
return this.subscribeForChannel(
{
op: WEB_SOCKET_SUBSCRIBE,
payload: WEB_SOCKET_CHANNELS.KeyBlocks,
},
callback,
);
}

private handleWebsocketMessage(message: MessageEvent) {
if (!message.data) {
return;
}
try {
const data = camelcaseKeysDeep(JSON.parse(message.data));

if (!data.payload) {
return;
}

// Call all subscribers for the channel
Object.values(this.subscribers[data.subscription as WebSocketChannelName]).forEach(
(subscriberCb) => subscriberCb(data.payload),
);
} catch (error) {
handleUnknownError(error);
}
}

disconnect() {
this.subscribersQueue.forEach((message) => {
Object.keys(WEB_SOCKET_SOURCE).forEach((source) => {
this.wsClient.send(JSON.stringify({
...message,
source,
op: WEB_SOCKET_UNSUBSCRIBE,
}));
});
});
this.wsClient.close();
this.wsClient.removeEventListener('open', this.handleWebsocketOpen);
this.wsClient.removeEventListener('close', this.handleWebsocketClose);
this.wsClient.removeEventListener('message', this.handleWebsocketClose);
}

connect(url: string) {
if (this.wsClient) {
this.disconnect();
}

this.wsClient = new WebSocket(url);
this.wsClient.addEventListener('open', () => this.handleWebsocketOpen());
this.wsClient.addEventListener('close', () => this.handleWebsocketClose());
this.wsClient.addEventListener('message', (message) => this.handleWebsocketMessage(message));
}

static getInstance(): WebSocketClient {
if (!WebSocketClient.instance) {
WebSocketClient.instance = new WebSocketClient();
}
return WebSocketClient.instance;
}
}

export default WebSocketClient.getInstance();
11 changes: 10 additions & 1 deletion src/popup/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import {
import { useStore } from 'vuex';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import type { WalletRouteMeta } from '../types';
import type { WalletRouteMeta, INetwork } from '../types';
import {
NOTIFICATION_DEFAULT_SETTINGS,
APP_LINK_FIREFOX,
Expand All @@ -83,6 +83,8 @@ import {
useUi,
useViewport,
} from '../composables';
import { useGetter } from '../composables/vuex';
import WebSocketClient from '../lib/WebSocketClient';
import Header from './components/Header.vue';
import NodeConnectionStatus from './components/NodeConnectionStatus.vue';
Expand All @@ -99,6 +101,7 @@ export default defineComponent({
const store = useStore();
const route = useRoute();
const { t } = useI18n();
const activeNetwork = useGetter<INetwork>('activeNetwork');
const { watchConnectionStatus } = useConnection();
const { initVisibilityListeners } = useUi();
Expand Down Expand Up @@ -179,6 +182,12 @@ export default defineComponent({
}
});
watch(activeNetwork, (network, prevNetwork) => {
if (network?.websocketUrl !== prevNetwork?.websocketUrl) {
WebSocketClient.connect(network.websocketUrl);
}
}, { immediate: true });
initVisibilityListeners();
onMounted(async () => {
Expand Down
20 changes: 20 additions & 0 deletions src/popup/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export const NETWORK_MAINNET: INetwork = {
middlewareUrl: 'https://mainnet.aeternity.io/mdw',
explorerUrl: 'https://aescan.io',
compilerUrl: 'https://compiler.aepps.com',
websocketUrl: 'wss://mainnet.aeternity.io/mdw/v2/websocket',
backendUrl: 'https://raendom-backend.z52da5wt.xyz',
tipContractV1: 'ct_2AfnEfCSZCTEkxL5Yoi4Yfq6fF7YapHRaFKDJK3THMXMBspp5z' as Encoded.ContractAddress,
multisigBackendUrl: 'https://ga-multisig-backend-mainnet.prd.aepps.com',
Expand All @@ -137,6 +138,7 @@ export const NETWORK_TESTNET: INetwork = {
middlewareUrl: 'https://testnet.aeternity.io/mdw',
explorerUrl: 'https://testnet.aescan.io',
compilerUrl: 'https://latest.compiler.aepps.com',
websocketUrl: 'wss://testnet.aeternity.io/mdw/v2/websocket',
backendUrl: 'https://testnet.superhero.aeternity.art',
tipContractV1: 'ct_2Cvbf3NYZ5DLoaNYAU71t67DdXLHeSXhodkSNifhgd7Xsw28Xd' as Encoded.ContractAddress,
tipContractV2: 'ct_2ZEoCKcqXkbz2uahRrsWeaPooZs9SdCv6pmC4kc55rD4MhqYSu' as Encoded.ContractAddress,
Expand Down Expand Up @@ -596,3 +598,21 @@ export const ALLOWED_ICON_STATUSES = [
'success',
'warning',
] as const;

export const WEB_SOCKET_CHANNELS = {
Transactions: 'Transactions',
MicroBlocks: 'MicroBlocks',
KeyBlocks: 'KeyBlocks',
Object: 'Object',
};

export const WEB_SOCKET_SOURCE = {
mdw: 'mdw',
node: 'node',
};

export const WEB_SOCKET_SUBSCRIBE = 'Subscribe';
export const WEB_SOCKET_UNSUBSCRIBE = 'Unsubscribe';
export const WEB_SOCKET_RECONNECT_TIMEOUT = 1000;

export const PUSH_NOTIFICATION_AUTO_CLOSE_TIMEOUT = 10000;
15 changes: 15 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
FUNCTION_TYPE_MULTISIG,
ALLOWED_ICON_STATUSES,
AETERNITY_COIN_ID,
WEB_SOCKET_CHANNELS,
WEB_SOCKET_SOURCE,
} from '../popup/utils';
import { RejectedByUserError } from '../lib/errors';

Expand Down Expand Up @@ -225,6 +227,8 @@ export interface INetworkBase {
*/
name: string;
middlewareUrl: string;
websocketUrl: string;

/**
* TODO: Replace with different way of differentiating the networks
*/
Expand Down Expand Up @@ -721,3 +725,14 @@ export interface TippingV2ContractApi extends TippingV1ContractApi {
amount: number
) => Encoded.TxHash;
}

export type WebSocketChannelName = ObjectValues<typeof WEB_SOCKET_CHANNELS>;
export type WebSocketSourceName = ObjectValues<typeof WEB_SOCKET_SOURCE>;

// https://github.com/aeternity/ae_mdw#websocket-interface
export interface IMiddlewareWebSocketSubscriptionMessage {
op: 'Subscribe' | 'Unsubscribe';
payload: WebSocketChannelName;
target?: string;
source?: WebSocketSourceName;
}

0 comments on commit 1b6c50e

Please sign in to comment.