Commit 1d3a8a0c authored by tom's avatar tom

rewrite using phoenix lib

parent c7315ba0
import React from 'react';
import type { SocketSubscriber } from 'lib/socket/types';
import Socket from 'lib/socket/Socket';
type Params = SocketSubscriber & {
isDisabled: boolean;
}
export default function useSocketRoom({ isDisabled, channelId, eventId, onMessage, onClose, onError, hash }: Params) {
React.useEffect(() => {
if (isDisabled) {
return;
}
const room = {
channelId,
eventId,
onMessage,
onClose,
onError,
hash,
} as SocketSubscriber;
const socket = (new Socket).init();
socket.joinRoom(room);
return () => {
socket.leaveRoom(room);
socket.close();
};
}, [ channelId, eventId, hash, isDisabled, onClose, onError, onMessage ]);
}
import type { SocketData, SocketSubscriber } from 'lib/socket/types';
import appConfig from 'configs/app/config';
import { SECOND } from 'lib/consts';
const OPEN_STATE = 1;
class Socket {
private socket: WebSocket | undefined;
private heartBeatIntervalId: number | undefined;
private lastHeartBeatTs: number | undefined;
private onReadyEvents: Array<SocketData> = [];
private channels: Record<string, Array<SocketSubscriber>> = {};
private HEART_BEAT_INTERVAL = 30 * SECOND;
private handleOpen = () => {
this.startHeartBeat();
this.onReadyEvents.forEach((data) => this.socket?.send(JSON.stringify(data)));
this.onReadyEvents = [];
};
private handleMessage = (event: MessageEvent) => {
const data: SocketData = JSON.parse(event.data);
const channelId = data[2];
const eventId = data[3];
const payload = data[4];
const subscribers = this.channels[channelId];
subscribers
?.filter((subscriber) => subscriber.eventId ? subscriber.eventId === eventId : true)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
?.forEach((subscriber) => subscriber.onMessage(payload as any));
if (channelId === 'phoenix' && eventId === 'phx_reply') {
const isOk = (payload as { status?: string } | undefined)?.status === 'ok';
isOk && (this.lastHeartBeatTs = Date.now());
}
};
private handleClose = () => {
this.beforeClose();
this.afterClose();
};
private handleError = () => {
Object.values(this.channels).forEach((channel) => channel.forEach((subscriber) => subscriber.onError?.()));
};
init() {
if (this.socket) {
return this;
}
this.socket = new WebSocket(`${ appConfig.api.socket }${ appConfig.api.basePath }/socket/v2/websocket?vsn=2.0.0`);
this.socket.addEventListener('open', this.handleOpen);
this.socket.addEventListener('message', this.handleMessage);
this.socket.addEventListener('error', this.handleError);
this.socket.addEventListener('close', this.handleClose);
return this;
}
close() {
this.beforeClose();
this.socket?.close();
this.afterClose();
}
beforeClose() {
window.clearInterval(this.heartBeatIntervalId);
this.socket?.removeEventListener('open', this.handleOpen);
this.socket?.removeEventListener('message', this.handleMessage);
this.socket?.removeEventListener('error', this.handleError);
this.socket?.removeEventListener('close', this.handleClose);
if (this.socket?.readyState === OPEN_STATE) {
Object.values(this.channels).forEach((channel) => channel.forEach((subscriber) => subscriber.onClose?.()));
}
}
afterClose() {
this.socket = undefined;
this.onReadyEvents = [];
this.channels = {};
this.lastHeartBeatTs = undefined;
}
joinRoom(subscriber: SocketSubscriber) {
const channelId = this.getChannelId(subscriber.channelId, subscriber.hash);
const data: SocketData = [ null, null, channelId, 'phx_join', {} ];
if (this.socket?.readyState === OPEN_STATE) {
this.socket?.send(JSON.stringify(data));
} else {
this.onReadyEvents.push(data);
}
if (this.channels[channelId]) {
this.channels[channelId].push(subscriber);
} else {
this.channels[channelId] = [ subscriber ];
}
}
leaveRoom(subscriber: SocketSubscriber) {
const channelId = this.getChannelId(subscriber.channelId, subscriber.hash);
const data: SocketData = [ null, null, channelId, 'phx_leave', {} ];
if (this.socket?.readyState === OPEN_STATE) {
this.socket?.send(JSON.stringify(data));
} else {
this.onReadyEvents.push(data);
}
this.channels[channelId]?.filter(({ onMessage }) => onMessage !== subscriber.onMessage);
}
private startHeartBeat() {
this.heartBeatIntervalId = window.setInterval(() => {
if (this.socket?.readyState !== OPEN_STATE) {
return;
}
if (this.lastHeartBeatTs && Date.now() - this.lastHeartBeatTs > this.HEART_BEAT_INTERVAL) {
// if we didn't receive response to the last heartbeat
this.close();
return;
}
const data: SocketData = [ null, null, 'phoenix', 'heartbeat', {} ];
this.socket?.send(JSON.stringify(data));
}, this.HEART_BEAT_INTERVAL);
}
private getChannelId(pattern: string, hash?: string) {
if (!hash) {
return pattern;
}
return pattern.replace('[hash]', hash.toLowerCase());
}
}
export default Socket;
import type { SocketConnectOption } from 'phoenix';
import { Socket } from 'phoenix';
import React, { useEffect, useState } from 'react';
export const SocketContext = React.createContext<Socket | null>(null);
interface SocketProviderProps {
children: React.ReactNode;
url: string;
options?: Partial<SocketConnectOption>;
}
export function SocketProvider({ children, options, url }: SocketProviderProps) {
const [ socket, setSocket ] = useState<Socket | null>(null);
useEffect(() => {
const s = new Socket(url, options);
s.connect();
setSocket(s);
return () => {
s.disconnect();
setSocket(null);
};
}, [ options, url ]);
return (
<SocketContext.Provider value={ socket }>
{ children }
</SocketContext.Provider>
);
}
export function useSocket() {
const context = React.useContext(SocketContext);
if (context === undefined) {
throw new Error('useCount must be used within a SocketProvider');
}
return context;
}
import type { NewBlockSocketResponse } from 'types/api/block';
import type { Channel } from 'phoenix';
export type SocketData = [ null, null, string, string, unknown ];
import type { NewBlockSocketResponse } from 'types/api/block';
export type SocketSubscriber = SocketSubscribers.BlocksNewBlock |
SocketSubscribers.BlockNewBlock |
SocketSubscribers.BlockNewBlock |
SocketSubscribers.TxStatusUpdate;
export type SocketMessageParams = SocketMessage.NewBlock |
SocketMessage.BlocksIndexStatus |
SocketMessage.TxStatusUpdate;
interface SocketSubscriberGeneric<Channel extends string, Event extends string, Payload> {
channelId: Channel;
eventId: Event;
onMessage: (payload: Payload) => void;
onClose?: () => void;
onError?: () => void;
hash?: string;
interface SocketMessageParamsGeneric<Event extends string, Payload extends object> {
channel: Channel | undefined;
event: Event;
handler: (payload: Payload) => void;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace SocketSubscribers {
export type BlocksNewBlock = SocketSubscriberGeneric<'blocks:new_block', 'new_block', NewBlockSocketResponse>;
export type BlocksIndexStatus = SocketSubscriberGeneric<'blocks:indexing', 'index_status', {finished: boolean; ratio: string}>;
export type BlockNewBlock = SocketSubscriberGeneric<'blocks:[hash]', 'new_block', NewBlockSocketResponse>;
export type TxStatusUpdate = SocketSubscriberGeneric<'transactions:[hash]', 'collated', NewBlockSocketResponse>;
export namespace SocketMessage {
export type NewBlock = SocketMessageParamsGeneric<'new_block', NewBlockSocketResponse>;
export type BlocksIndexStatus = SocketMessageParamsGeneric<'index_status', {finished: boolean; ratio: string}>;
export type TxStatusUpdate = SocketMessageParamsGeneric<'collated', NewBlockSocketResponse>;
}
import type { Channel } from 'phoenix';
import { useContext, useEffect, useRef, useState } from 'react';
import notEmpty from 'lib/notEmpty';
import { SocketContext } from './context';
interface Params {
topic: string;
params?: object;
isDisabled: boolean;
onJoin?: (channel: Channel, message: unknown) => void;
onSocketClose?: () => void;
onSocketError?: () => void;
}
export default function useSocketChannel({ topic, params, isDisabled, onJoin, onSocketClose, onSocketError }: Params) {
const socket = useContext(SocketContext);
const [ channel, setChannel ] = useState<Channel>();
const onJoinFun = useRef(onJoin);
onJoinFun.current = onJoin;
useEffect(() => {
const onCloseRef = onSocketClose && socket?.onClose(onSocketClose);
const onErrorRef = onSocketError && socket?.onClose(onSocketError);
return () => {
const refs = [ onCloseRef, onErrorRef ].filter(notEmpty);
refs.length > 0 && socket?.off(refs);
};
}, [ onSocketClose, onSocketError, socket ]);
useEffect(() => {
if (isDisabled && channel) {
channel.leave();
setChannel(undefined);
}
}, [ channel, isDisabled ]);
useEffect(() => {
if (socket === null || isDisabled) {
return;
}
const ch = socket.channel(topic, params);
ch.join().receive('ok', (message) => onJoinFun.current?.(ch, message));
setChannel(ch);
return () => {
ch.leave();
setChannel(undefined);
};
}, [ socket, topic, params, isDisabled ]);
return channel;
}
import { useEffect, useRef } from 'react';
import type { SocketMessageParams } from 'lib/socket/types';
export default function useSocketMessage({ channel, event, handler }: SocketMessageParams) {
const handlerFun = useRef(handler);
handlerFun.current = handler;
useEffect(() => {
if (channel === undefined) {
return;
}
const ref = channel.on(event, (message) => {
handlerFun.current?.(message);
});
return () => {
channel.off(event, ref);
};
}, [ channel, event ]);
}
......@@ -2,12 +2,13 @@ import { Box, Text, Show, Alert, Skeleton } from '@chakra-ui/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { SocketSubscribers } from 'lib/socket/types';
import type { SocketMessage } from 'lib/socket/types';
import type { BlockType, BlocksResponse } from 'types/api/block';
import { QueryKeys } from 'types/client/accountQueries';
import useFetch from 'lib/hooks/useFetch';
import useSocketRoom from 'lib/hooks/useSocketRoom';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import BlocksList from 'ui/blocks/BlocksList';
import BlocksSkeletonMobile from 'ui/blocks/BlocksSkeletonMobile';
import BlocksTable from 'ui/blocks/BlocksTable';
......@@ -29,7 +30,7 @@ const BlocksContent = ({ type }: Props) => {
async() => await fetch(`/node-api/blocks${ type ? `?type=${ type }` : '' }`),
);
const handleNewBlockMessage: SocketSubscribers.BlocksNewBlock['onMessage'] = React.useCallback((payload) => {
const handleNewBlockMessage: SocketMessage.NewBlock['handler'] = React.useCallback((payload) => {
queryClient.setQueryData([ QueryKeys.blocks, type ], (prevData: BlocksResponse | undefined) => {
if (!prevData) {
return {
......@@ -49,14 +50,17 @@ const BlocksContent = ({ type }: Props) => {
setSocketAlert('An error has occurred while fetching new blocks. Please click here to refresh the page.');
}, []);
useSocketRoom({
channelId: 'blocks:new_block',
eventId: 'new_block',
onMessage: handleNewBlockMessage,
onClose: handleSocketClose,
onError: handleSocketError,
const channel = useSocketChannel({
topic: 'blocks:new_block',
onSocketClose: handleSocketClose,
onSocketError: handleSocketError,
isDisabled: isLoading || isError,
});
useSocketMessage({
channel,
event: 'new_block',
handler: handleNewBlockMessage,
});
if (isLoading) {
return (
......
......@@ -3,12 +3,9 @@ import * as Sentry from '@sentry/react';
import type { ChangeEvent } from 'react';
import React from 'react';
import type { SocketSubscribers } from 'lib/socket/types';
import appConfig from 'configs/app/config';
import * as cookies from 'lib/cookies';
import useToast from 'lib/hooks/useToast';
import Socket from 'lib/socket/Socket';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -18,27 +15,6 @@ const Home = () => {
const [ isFormVisible, setFormVisibility ] = React.useState(false);
const [ token, setToken ] = React.useState('');
React.useEffect(() => {
const socket = (new Socket).init();
const onMessage: SocketSubscribers.BlocksNewBlock['onMessage'] = () => {};
socket.joinRoom({
channelId: 'blocks:new_block',
eventId: 'new_block',
onMessage,
hash: '0xdc4765d9dabf6c6c4908fe97e649ef1f05cb6252',
});
return () => {
socket.leaveRoom({
channelId: 'blocks:[hash]',
eventId: 'new_block',
hash: '0xdc4765d9dabf6c6c4908fe97e649ef1f05cb6252',
onMessage,
});
socket.close();
};
}, []);
React.useEffect(() => {
const token = cookies.get(cookies.NAMES.API_TOKEN);
setFormVisibility(Boolean(!token && appConfig.isAccountSupported));
......
......@@ -4,9 +4,11 @@ import React from 'react';
import { QueryKeys } from 'types/client/queries';
import appConfig from 'configs/app/config';
import * as cookies from 'lib/cookies';
import useFetch from 'lib/hooks/useFetch';
import useScrollDirection from 'lib/hooks/useScrollDirection';
import { SocketProvider } from 'lib/socket/context';
import ScrollDirectionContext from 'ui/ScrollDirectionContext';
import PageContent from 'ui/shared/Page/PageContent';
import Header from 'ui/snippets/header/Header';
......@@ -32,15 +34,17 @@ const Page = ({ children, wrapChildren = true, hideMobileHeaderOnScrollDown }: P
) : children;
return (
<ScrollDirectionContext.Provider value={ directionContext }>
<Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/>
<Flex flexDir="column" width="100%">
<Header hideOnScrollDown={ hideMobileHeaderOnScrollDown }/>
{ renderedChildren }
<SocketProvider url={ `${ appConfig.api.socket }${ appConfig.api.basePath }/socket/v2/websocket?vsn=2.0.0` }>
<ScrollDirectionContext.Provider value={ directionContext }>
<Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/>
<Flex flexDir="column" width="100%">
<Header hideOnScrollDown={ hideMobileHeaderOnScrollDown }/>
{ renderedChildren }
</Flex>
</Flex>
</Flex>
</ScrollDirectionContext.Provider>
</ScrollDirectionContext.Provider>
</SocketProvider>
);
};
......
......@@ -5,7 +5,7 @@ import { useRouter } from 'next/router';
import React from 'react';
import { scroller, Element } from 'react-scroll';
import type { SocketSubscribers } from 'lib/socket/types';
import type { SocketMessage } from 'lib/socket/types';
import type { Transaction } from 'types/api/transaction';
import { QueryKeys } from 'types/client/queries';
......@@ -17,7 +17,8 @@ import successIcon from 'icons/status/success.svg';
import { WEI, WEI_IN_GWEI } from 'lib/consts';
import dayjs from 'lib/date/dayjs';
import useFetch from 'lib/hooks/useFetch';
import useSocketRoom from 'lib/hooks/useSocketRoom';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import getConfirmationDuration from 'lib/tx/getConfirmationDuration';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
......@@ -59,7 +60,7 @@ const TxDetails = () => {
},
);
const handleStatusUpdateMessage: SocketSubscribers.BlocksNewBlock['onMessage'] = React.useCallback(() => {
const handleStatusUpdateMessage: SocketMessage.TxStatusUpdate['handler'] = React.useCallback(() => {
queryClient.invalidateQueries({ queryKey: [ QueryKeys.tx, router.query.id ] });
}, [ queryClient, router.query.id ]);
......@@ -71,14 +72,16 @@ const TxDetails = () => {
setSocketAlert('An error has occurred while fetching new blocks. Please click here to update transaction info.');
}, []);
useSocketRoom({
channelId: 'transactions:[hash]',
eventId: 'collated',
hash: data?.hash,
onMessage: handleStatusUpdateMessage,
onClose: handleSocketClose,
onError: handleSocketError,
isDisabled: isLoading || isError,
const channel = useSocketChannel({
topic: `transactions:${ router.query.id }`,
onSocketClose: handleSocketClose,
onSocketError: handleSocketError,
isDisabled: isLoading || isError || data.status !== null,
});
useSocketMessage({
channel,
event: 'collated',
handler: handleStatusUpdateMessage,
});
const [ isExpanded, setIsExpanded ] = React.useState(false);
......
......@@ -1916,6 +1916,11 @@
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
"@types/phoenix@^1.5.4":
version "1.5.4"
resolved "https://registry.yarnpkg.com/@types/phoenix/-/phoenix-1.5.4.tgz#c08a1da6d7b4e365f6a1fe1ff9aada55f5356d24"
integrity sha512-L5eZmzw89eXBKkiqVBcJfU1QGx9y+wurRIEgt0cuLH0hwNtVUxtx+6cu0R2STwWj468sjXyBYPYDtGclUd1kjQ==
"@types/prop-types@*":
version "15.7.5"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
......@@ -4471,6 +4476,11 @@ path-type@^4.0.0:
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
phoenix@^1.6.15:
version "1.6.15"
resolved "https://registry.yarnpkg.com/phoenix/-/phoenix-1.6.15.tgz#efb2088a310cde333b3762002831b79dedf76002"
integrity sha512-O6AG5jTkZOOkdd/GOSCsM4v3bzBoyRnC5bEi57KhX/Daba6FvnBRzt0nhEeRRiVQGLSxDlyb0dUe9CkYWMZd8g==
picocolors@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment