From ce8496ae059b58fed0172ab7b3666698db77b058 Mon Sep 17 00:00:00 2001 From: capyq <60133108+capyq@users.noreply.github.com> Date: Tue, 17 Dec 2024 16:34:02 +0100 Subject: [PATCH] Websocket watcher (#517) * add reconnecting websocket * add common Websocket listener. We need 3items ( context / component / custom hook ) * feat(websocket)!:addlisterner on open when WS connexion open Signed-off-by: capyq Co-authored-by: Sylvain Bouzols --- jest.config.ts | 4 +- package-lock.json | 6 + package.json | 1 + src/components/index.ts | 1 + .../notifications/NotificationsProvider.tsx | 70 +++++++++ .../contexts/NotificationsContext.ts | 35 +++++ .../notifications/hooks/useListenerManager.ts | 59 ++++++++ .../hooks/useNotificationsListener.ts | 49 ++++++ src/components/notifications/index.ts | 10 ++ .../test/NotificationsProvider.test.tsx | 142 ++++++++++++++++++ 10 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 src/components/notifications/NotificationsProvider.tsx create mode 100644 src/components/notifications/contexts/NotificationsContext.ts create mode 100644 src/components/notifications/hooks/useListenerManager.ts create mode 100644 src/components/notifications/hooks/useNotificationsListener.ts create mode 100644 src/components/notifications/index.ts create mode 100644 src/components/notifications/test/NotificationsProvider.test.tsx diff --git a/jest.config.ts b/jest.config.ts index 5d3129f3..96b176b3 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -14,7 +14,9 @@ const config: Config = { '^.+\\.(css|less|scss)$': 'identity-obj-proxy', }, // see https://github.com/react-dnd/react-dnd/issues/3443 - transformIgnorePatterns: ['node_modules/(?!react-dnd|dnd-core|@react-dnd)'], // transform from ESM + transformIgnorePatterns: [ + 'node_modules/(?!react-dnd|dnd-core|@react-dnd)', + ], // transform from ESM globals: { IS_REACT_ACT_ENVIRONMENT: true, }, diff --git a/package-lock.json b/package-lock.json index 513d1fe0..1fdeafac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-querybuilder": "^7.2.0", "react-virtualized": "^9.22.5", + "reconnecting-websocket": "^4.4.0", "uuid": "^9.0.1" }, "devDependencies": { @@ -14363,6 +14364,11 @@ "once": "^1.3.0" } }, + "node_modules/reconnecting-websocket": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz", + "integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==" + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", diff --git a/package.json b/package.json index 9e8a07eb..67cd6111 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-querybuilder": "^7.2.0", "react-virtualized": "^9.22.5", + "reconnecting-websocket": "^4.4.0", "uuid": "^9.0.1" }, "peerDependencies": { diff --git a/src/components/index.ts b/src/components/index.ts index 8526615e..ab247581 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -20,3 +20,4 @@ export * from './overflowableText'; export * from './snackbarProvider'; export * from './topBar'; export * from './treeViewFinder'; +export * from './notifications'; diff --git a/src/components/notifications/NotificationsProvider.tsx b/src/components/notifications/NotificationsProvider.tsx new file mode 100644 index 00000000..7d20d8f7 --- /dev/null +++ b/src/components/notifications/NotificationsProvider.tsx @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +// @author Quentin CAPY + +import { PropsWithChildren, useEffect, useMemo } from 'react'; +import ReconnectingWebSocket from 'reconnecting-websocket'; +import { ListenerEventWS, ListenerOnReopen, NotificationsContext } from './contexts/NotificationsContext'; +import { useListenerManager } from './hooks/useListenerManager'; + +// the delay before we consider the WS truly connected +const DELAY_BEFORE_WEBSOCKET_CONNECTED = 12000; + +export type NotificationsProviderProps = { urls: Record }; +export function NotificationsProvider({ urls, children }: PropsWithChildren) { + const { + broadcast: broadcastMessage, + addListener: addListenerMessage, + removeListener: removeListenerMessage, + } = useListenerManager(urls); + const { + broadcast: broadcastOnReopen, + addListener: addListenerOnReopen, + removeListener: removeListenerOnReopen, + } = useListenerManager(urls); + + useEffect(() => { + const connections = Object.keys(urls) + .filter((u) => urls[u] != null) + .map((urlKey) => { + const rws = new ReconnectingWebSocket(() => urls[urlKey], [], { + // this option set the minimum duration being connected before reset the retry count to 0 + minUptime: DELAY_BEFORE_WEBSOCKET_CONNECTED, + }); + + rws.onmessage = broadcastMessage(urlKey); + + rws.onclose = (event) => { + console.error(`Unexpected ${urlKey} Notification WebSocket closed`, event); + }; + rws.onerror = (event) => { + console.error(`Unexpected ${urlKey} Notification WebSocket error`, event); + }; + + rws.onopen = () => { + console.info(`${urlKey} Notification Websocket connected`); + broadcastOnReopen(urlKey); + }; + return rws; + }); + + return () => { + connections.forEach((c) => c.close()); + }; + }, [broadcastMessage, broadcastOnReopen, urls]); + + const contextValue = useMemo( + () => ({ + addListenerEvent: addListenerMessage, + removeListenerEvent: removeListenerMessage, + addListenerOnReopen, + removeListenerOnReopen, + }), + [addListenerMessage, removeListenerMessage, addListenerOnReopen, removeListenerOnReopen] + ); + return {children}; +} diff --git a/src/components/notifications/contexts/NotificationsContext.ts b/src/components/notifications/contexts/NotificationsContext.ts new file mode 100644 index 00000000..db2d5ee8 --- /dev/null +++ b/src/components/notifications/contexts/NotificationsContext.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +// @author Quentin CAPY + +import { createContext } from 'react'; + +export type ListenerEventWS = { + id: string; + callback: (event: MessageEvent) => void; +}; + +export type ListenerOnReopen = { + id: string; + callback: () => void; +}; + +export type NotificationsContextType = { + addListenerEvent: (urlKey: string, l: ListenerEventWS) => void; + removeListenerEvent: (urlKey: string, idListener: string) => void; + addListenerOnReopen: (urlKey: string, l: ListenerOnReopen) => void; + removeListenerOnReopen: (urlKey: string, idListener: string) => void; +}; + +export type NotificationsContextRecordType = Record; + +export const NotificationsContext = createContext({ + addListenerEvent: () => {}, + removeListenerEvent: () => {}, + addListenerOnReopen: () => {}, + removeListenerOnReopen: () => {}, +}); diff --git a/src/components/notifications/hooks/useListenerManager.ts b/src/components/notifications/hooks/useListenerManager.ts new file mode 100644 index 00000000..39e7a9f1 --- /dev/null +++ b/src/components/notifications/hooks/useListenerManager.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +// @author Quentin CAPY +import { useCallback, useEffect, useRef } from 'react'; +import { ListenerEventWS, ListenerOnReopen } from '../contexts/NotificationsContext'; + +export const useListenerManager = ( + urls: Record +) => { + const urlsListenersRef = useRef>({}); + + useEffect(() => { + urlsListenersRef.current = Object.keys(urls).reduce((acc, urlKey) => { + acc[urlKey] = urlsListenersRef.current[urlKey] ?? []; + return acc; + }, {} as Record); + }, [urls]); + + const addListenerEvent = useCallback((urlKey: string, listener: TListener) => { + const urlsListeners = urlsListenersRef.current; + if (urlKey in urlsListeners) { + urlsListeners[urlKey].push(listener); + } else { + urlsListeners[urlKey] = [listener]; + } + urlsListenersRef.current = urlsListeners; + }, []); + const removeListenerEvent = useCallback((urlKey: string, id: string) => { + const listeners = urlsListenersRef.current?.[urlKey]; + if (listeners) { + const newListerners = listeners.filter((l) => l.id !== id); + urlsListenersRef.current = { + ...urlsListenersRef.current, + [urlKey]: newListerners, + }; + } + }, []); + const broadcast = useCallback( + (urlKey: string) => (event: TMessage) => { + const listeners = urlsListenersRef.current?.[urlKey]; + if (listeners) { + listeners.forEach(({ callback }) => { + callback(event); + }); + } + }, + [] + ); + + return { + addListener: addListenerEvent, + removeListener: removeListenerEvent, + broadcast, + }; +}; diff --git a/src/components/notifications/hooks/useNotificationsListener.ts b/src/components/notifications/hooks/useNotificationsListener.ts new file mode 100644 index 00000000..4b80f448 --- /dev/null +++ b/src/components/notifications/hooks/useNotificationsListener.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +// @author Quentin CAPY + +import { useContext, useEffect } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { NotificationsContext } from '../contexts/NotificationsContext'; + +export const useNotificationsListener = ( + listenerKey: string, + { + listenerCallbackMessage, + listenerCallbackOnReopen, + propsId, + }: { + listenerCallbackMessage?: (event: MessageEvent) => void; + listenerCallbackOnReopen?: () => void; + propsId?: string; + } +) => { + const { addListenerEvent, removeListenerEvent, addListenerOnReopen, removeListenerOnReopen } = + useContext(NotificationsContext); + + useEffect(() => { + const id = propsId ?? uuidv4(); + if (listenerCallbackMessage) { + addListenerEvent(listenerKey, { + id, + callback: listenerCallbackMessage, + }); + } + return () => removeListenerEvent(listenerKey, id); + }, [addListenerEvent, removeListenerEvent, listenerKey, listenerCallbackMessage, propsId]); + + useEffect(() => { + const id = propsId ?? uuidv4(); + if (listenerCallbackOnReopen) { + addListenerOnReopen(listenerKey, { + id, + callback: listenerCallbackOnReopen, + }); + } + return () => removeListenerOnReopen(listenerKey, id); + }, [addListenerOnReopen, removeListenerOnReopen, listenerKey, listenerCallbackOnReopen, propsId]); +}; diff --git a/src/components/notifications/index.ts b/src/components/notifications/index.ts new file mode 100644 index 00000000..cccf8a2b --- /dev/null +++ b/src/components/notifications/index.ts @@ -0,0 +1,10 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +export * from './NotificationsProvider'; +export * from './contexts/NotificationsContext'; +export * from './hooks/useNotificationsListener'; +export * from './hooks/useListenerManager'; diff --git a/src/components/notifications/test/NotificationsProvider.test.tsx b/src/components/notifications/test/NotificationsProvider.test.tsx new file mode 100644 index 00000000..f92a61bf --- /dev/null +++ b/src/components/notifications/test/NotificationsProvider.test.tsx @@ -0,0 +1,142 @@ +/** + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +import { createRoot } from 'react-dom/client'; +import { waitFor, act } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; +import ReconnectingWebSocket from 'reconnecting-websocket'; +import { NotificationsProvider } from '../NotificationsProvider'; +import { useNotificationsListener } from '../hooks/useNotificationsListener'; + +jest.mock('reconnecting-websocket'); +let container: Element; + +declare global { + interface Window { + ReconnectingWebSocket: any; + } +} + +const WS_CONSUMER_ID = 'WS_CONSUMER_ID'; +const WS_KEY = 'WS_KEY'; + +describe('NotificationsProvider', () => { + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + container?.remove(); + }); + + test('renders NotificationsProvider component', () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + expect(ReconnectingWebSocket).toBeCalled(); + }); + + test('renders NotificationsProvider children component ', () => { + const root = createRoot(container); + + act(() => { + root.render( + +

Child

+
+ ); + }); + const lastMsg = document.querySelector(`#${WS_CONSUMER_ID}`); + expect(lastMsg?.textContent).toEqual('Child'); + }); + + test('renders NotificationsProvider children component and updated by event ', async () => { + const root = createRoot(container); + + const eventCallback = jest.fn(); + function NotificationsConsumer() { + useNotificationsListener(WS_KEY, { listenerCallbackMessage: eventCallback }); + return

empty

; + } + + const reconnectingWebSocketClass = {}; + // @ts-ignore + ReconnectingWebSocket.mockImplementation(() => reconnectingWebSocketClass); + + act(() => { + root.render( + + + + ); + }); + const event = { test: 'test' }; + act(() => { + // @ts-ignore + reconnectingWebSocketClass.onmessage(event); + }); + + waitFor(() => expect(eventCallback).toBeCalledWith(event)); + }); + + test('renders NotificationsProvider children component not called with other key ', () => { + const root = createRoot(container); + + const eventCallback = jest.fn(); + function NotificationsConsumer() { + useNotificationsListener('Fake_Key', { listenerCallbackMessage: eventCallback }); + return

empty

; + } + + const reconnectingWebSocketClass = {}; + // @ts-ignore + ReconnectingWebSocket.mockImplementation(() => reconnectingWebSocketClass); + + act(() => { + root.render( + + + + ); + }); + act(() => { + // @ts-ignore + reconnectingWebSocketClass.onmessage({ test: 'test' }); + }); + + expect(eventCallback).not.toBeCalled(); + }); + + test('renders NotificationsProvider component and calls onOpen callback', async () => { + const root = createRoot(container); + + const onOpenCallback = jest.fn(); + const reconnectingWebSocketClass = { + onopen: onOpenCallback, + }; + const eventCallback = jest.fn(); + function NotificationsConsumer() { + useNotificationsListener('Fake_Key', { listenerCallbackOnReopen: eventCallback }); + return

empty

; + } + + // @ts-ignore + ReconnectingWebSocket.mockImplementation(() => reconnectingWebSocketClass); + + act(() => { + root.render( + + + + ); + }); + + waitFor(() => expect(onOpenCallback).toBeCalled()); + }); +});