From b0eac94b1870a00d519c80c14741e6b8e5a310a5 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Sun, 6 Aug 2023 00:23:49 +0200 Subject: [PATCH] Implement reconnection mechanism and other good things --- .idea/runConfigurations/Run_client.xml | 4 +- .../src/app/board/[board]/BoardColumn.tsx | 2 +- .../src/app/board/[board]/BoardHeader.tsx | 24 +++-- todoblue/src/app/board/[board]/BoardMain.tsx | 14 +-- .../app/board/[board]/BoardMainTaskGroups.tsx | 2 +- .../src/app/board/[board]/BoardTaskEditor.tsx | 4 +- .../src/app/board/[board]/TaskDisplay.tsx | 41 +++++---- todoblue/src/app/board/[board]/useBoard.tsx | 12 +-- .../app/board/[board]/useBoardWebSocket.ts | 36 -------- .../app/board/[board]/useBoardWebSocketURL.ts | 17 ---- todoblue/src/app/board/[board]/useBoardWs.ts | 46 ++++++++++ todoblue/src/app/useHttpBaseURL.ts | 20 ++++ todoblue/src/app/useWebSocket.ts | 43 --------- todoblue/src/app/useWs.ts | 91 +++++++++++++++++++ todoblue/src/app/useWsBaseURL.ts | 9 ++ 15 files changed, 223 insertions(+), 142 deletions(-) delete mode 100644 todoblue/src/app/board/[board]/useBoardWebSocket.ts delete mode 100644 todoblue/src/app/board/[board]/useBoardWebSocketURL.ts create mode 100644 todoblue/src/app/board/[board]/useBoardWs.ts create mode 100644 todoblue/src/app/useHttpBaseURL.ts delete mode 100644 todoblue/src/app/useWebSocket.ts create mode 100644 todoblue/src/app/useWs.ts create mode 100644 todoblue/src/app/useWsBaseURL.ts diff --git a/.idea/runConfigurations/Run_client.xml b/.idea/runConfigurations/Run_client.xml index d062d06..7dc2faf 100644 --- a/.idea/runConfigurations/Run_client.xml +++ b/.idea/runConfigurations/Run_client.xml @@ -8,9 +8,9 @@ - - + + diff --git a/todoblue/src/app/board/[board]/BoardColumn.tsx b/todoblue/src/app/board/[board]/BoardColumn.tsx index 9d75ce3..924f6ed 100644 --- a/todoblue/src/app/board/[board]/BoardColumn.tsx +++ b/todoblue/src/app/board/[board]/BoardColumn.tsx @@ -10,7 +10,7 @@ export function BoardColumn({taskGroup}: {taskGroup: TaskGroup}) { {taskGroup.name}
- {taskGroup.tasks.map(task => )} + {taskGroup.tasks.map(task => )}
) diff --git a/todoblue/src/app/board/[board]/BoardHeader.tsx b/todoblue/src/app/board/[board]/BoardHeader.tsx index b4d8a31..22045bb 100644 --- a/todoblue/src/app/board/[board]/BoardHeader.tsx +++ b/todoblue/src/app/board/[board]/BoardHeader.tsx @@ -36,7 +36,11 @@ function TitleArea({children}: {children: ReactNode}) { } function TitleInput() { - const {editTitle, setEditTitle, stopEditingTitle} = useManagedBoard() + const {editTitle, setEditTitle, stopEditingTitle, webSocketState} = useManagedBoard() + + if(webSocketState !== WebSocket.OPEN) { + return null + } return (
@@ -52,7 +56,11 @@ function TitleInput() { } function TitleDisplay() { - const {title} = useManagedBoard() + const {title, webSocketState} = useManagedBoard() + + if(webSocketState !== WebSocket.OPEN) { + return null + } return (
} text={"Caricamento..."} className={className}/> + return } text={"Caricamento..."} className={className}/> case WebSocket.CONNECTING: - return } text={"Connessione..."} className={className}/> + return } text={"Connessione..."} className={className}/> case WebSocket.OPEN: return case WebSocket.CLOSING: + return } text={"Disconnessione..."} className={className}/> case WebSocket.CLOSED: - return } text={"Errore"} className={cn("red", className)}/> + return } text={"Disconnesso"} className={className}/> } } diff --git a/todoblue/src/app/board/[board]/BoardMainTaskGroups.tsx b/todoblue/src/app/board/[board]/BoardMainTaskGroups.tsx index c40061a..974b43c 100644 --- a/todoblue/src/app/board/[board]/BoardMainTaskGroups.tsx +++ b/todoblue/src/app/board/[board]/BoardMainTaskGroups.tsx @@ -9,7 +9,7 @@ export function BoardMainTaskGroups({className}: {className?: string}) { return (
- {taskGroups.map((tg) => )} + {taskGroups.map((tg) => )}
) } diff --git a/todoblue/src/app/board/[board]/BoardTaskEditor.tsx b/todoblue/src/app/board/[board]/BoardTaskEditor.tsx index 840c426..7124320 100644 --- a/todoblue/src/app/board/[board]/BoardTaskEditor.tsx +++ b/todoblue/src/app/board/[board]/BoardTaskEditor.tsx @@ -7,7 +7,7 @@ import {faAdd} from "@fortawesome/free-solid-svg-icons" export function BoardTaskEditor({className}: {className?: string}) { - const {editedTask, send, setEditedTaskText, websocketState} = useManagedBoard() + const {editedTask, send, setEditedTaskText, webSocketState} = useManagedBoard() const submitTask = useCallback((e: FormEvent) => { e.preventDefault(); @@ -15,7 +15,7 @@ export function BoardTaskEditor({className}: {className?: string}) { setEditedTaskText("") }, [send, editedTask]) - if(websocketState != WebSocket.OPEN) { + if(webSocketState != WebSocket.OPEN) { return null } diff --git a/todoblue/src/app/board/[board]/TaskDisplay.tsx b/todoblue/src/app/board/[board]/TaskDisplay.tsx index 5cdd582..86443d0 100644 --- a/todoblue/src/app/board/[board]/TaskDisplay.tsx +++ b/todoblue/src/app/board/[board]/TaskDisplay.tsx @@ -55,25 +55,28 @@ export function TaskDisplay({task}: {task: TaskWithId}) { } return ( -
setDisplayingActions(!isDisplayingActions)}> +
setDisplayingActions(!isDisplayingActions)} + > {contents}
) diff --git a/todoblue/src/app/board/[board]/useBoard.tsx b/todoblue/src/app/board/[board]/useBoard.tsx index 2516c24..9d2bfa5 100644 --- a/todoblue/src/app/board/[board]/useBoard.tsx +++ b/todoblue/src/app/board/[board]/useBoard.tsx @@ -4,7 +4,7 @@ import {TASK_GROUPERS} from "@/app/board/[board]/doTaskGrouping" import {TASK_SORTERS} from "@/app/board/[board]/doTaskSorting" import {BoardAction, Task} from "@/app/board/[board]/Types" import {useBoardTaskEditor} from "@/app/board/[board]/useBoardTaskEditor" -import {useBoardWebSocket} from "@/app/board/[board]/useBoardWebSocket" +import {useBoardWs} from "@/app/board/[board]/useBoardWs" import {TaskGroup, useBoardTaskArranger} from "@/app/board/[board]/useBoardTaskArranger" import {useBoardTitleEditor} from "@/app/board/[board]/useBoardTitleEditor" import {useCycleState} from "@/app/useCycleState" @@ -14,7 +14,7 @@ export interface UseBoardReturns { title: string, tasksById: {[id: string]: Task}, taskGroups: TaskGroup[], - websocketState: number, + webSocketState: number | undefined, isEditingTitle: boolean, stopEditingTitle: () => void, startEditingTitle: () => void, @@ -35,13 +35,13 @@ export interface UseBoardReturns { } export function useBoard(name: string): UseBoardReturns { - const {state: {title, tasksById}, send, websocketState} = useBoardWebSocket(name); + const {state: {title, tasksById}, sendAction, webSocketState} = useBoardWs(name); const {value: [taskGrouper, groupSorter, groupNamer], move: moveGrouper, next: nextGrouper, previous: previousGrouper} = useCycleState(TASK_GROUPERS); const {value: taskSorter, move: moveSorter, next: nextSorter, previous: previousSorter} = useCycleState(TASK_SORTERS); const {taskGroups} = useBoardTaskArranger(tasksById, taskGrouper, groupSorter, groupNamer, taskSorter); - const {isEditingTitle, stopEditingTitle, startEditingTitle, toggleEditingTitle, editTitle, setEditTitle} = useBoardTitleEditor(title, send); + const {isEditingTitle, stopEditingTitle, startEditingTitle, toggleEditingTitle, editTitle, setEditTitle} = useBoardTitleEditor(title, sendAction); const {editedTaskText, setEditedTaskText, editedTask, setEditedTask} = useBoardTaskEditor() @@ -49,7 +49,7 @@ export function useBoard(name: string): UseBoardReturns { title, tasksById, taskGroups, - websocketState, + webSocketState, isEditingTitle, stopEditingTitle, startEditingTitle, @@ -62,7 +62,7 @@ export function useBoard(name: string): UseBoardReturns { previousSorter, editTitle, setEditTitle, - send, + send: sendAction, editedTaskText, setEditedTaskText, editedTask, diff --git a/todoblue/src/app/board/[board]/useBoardWebSocket.ts b/todoblue/src/app/board/[board]/useBoardWebSocket.ts deleted file mode 100644 index 39d3bee..0000000 --- a/todoblue/src/app/board/[board]/useBoardWebSocket.ts +++ /dev/null @@ -1,36 +0,0 @@ -'use client'; - -import {BoardAction} from "@/app/board/[board]/Types" -import {useBoardState} from "@/app/board/[board]/useBoardState" -import {useBoardWebSocketURL} from "@/app/board/[board]/useBoardWebSocketURL" -import {useCallback} from "react" -import {useWebSocket} from "@/app/useWebSocket" - - -export function useBoardWebSocket(name: string) { - const {webSocketURL} = useBoardWebSocketURL(name) - const {state, act} = useBoardState(); - - const {websocket, websocketState} = useWebSocket(webSocketURL, { - onopen: useCallback((_sock: WebSocket, _event: Event) => { - console.debug("[useBoard] Connected to board:", name); - act(null); - }, []), - onmessage: useCallback((_sock: WebSocket, event: MessageEvent) => { - const action: BoardAction = JSON.parse(event.data); - console.debug("[useBoard] Received:", action); - act(action) - }, []), - }); - - const send = useCallback((data: BoardAction) => { - if(!websocket || websocketState !== WebSocket.OPEN) { - console.warn("[useBoardWebSocket] Webbsocket is not yet ready, cannot send:", data); - return; - } - console.debug("[useBoardWebSocket] Sending:", data); - websocket.send(JSON.stringify(data)); - }, [websocket, websocketState]) - - return {state, send, websocketState} -} diff --git a/todoblue/src/app/board/[board]/useBoardWebSocketURL.ts b/todoblue/src/app/board/[board]/useBoardWebSocketURL.ts deleted file mode 100644 index 785cb0a..0000000 --- a/todoblue/src/app/board/[board]/useBoardWebSocketURL.ts +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; -import {useMemo} from "react" - - -const HTTP_TO_WS = { - "http:": "ws:", - "https:": "wss:", -} - -export function useBoardWebSocketURL(name: string) { - // @ts-ignore - const protocol = HTTP_TO_WS[window.location.protocol] - const host = window.location.host - - const webSocketURL = useMemo(() => `${protocol}//${host}/api/board/${name}/ws`, [name]); - return {webSocketURL} -} diff --git a/todoblue/src/app/board/[board]/useBoardWs.ts b/todoblue/src/app/board/[board]/useBoardWs.ts new file mode 100644 index 0000000..fec591c --- /dev/null +++ b/todoblue/src/app/board/[board]/useBoardWs.ts @@ -0,0 +1,46 @@ +'use client'; + +import {BoardAction} from "@/app/board/[board]/Types" +import {useBoardState} from "@/app/board/[board]/useBoardState" +import {useWsBaseURL} from "@/app/useWsBaseURL" +import {useCallback, useMemo} from "react" +import {useWs, WebSocketHandlerParams} from "@/app/useWs" + + +export function useBoardWs(name: string) { + const wsBaseURL = useWsBaseURL() + const wsFullURL = useMemo(() => wsBaseURL ? `${wsBaseURL}/board/${name}/ws` : undefined, [wsBaseURL, name]) + + const {state, act} = useBoardState(); + + const {webSocket, webSocketState} = useWs(wsFullURL, { + onopen: useCallback(({}) => { + console.debug("[useBoardWs] Connected to board:", name); + act(null); + }, [act]), + onmessage: useCallback(({event}: WebSocketHandlerParams) => { + const action: BoardAction = JSON.parse(event.data); + console.debug("[useBoardWs] Parsed payload as:", action); + act(action) + }, [act]), + onerror: useCallback(({event, closeWebSocket}: WebSocketHandlerParams) => { + console.error("[useBoardWs] Encountered a WebSocket error, closing current connection:", event); + closeWebSocket() + }, []), + onclose: useCallback(({event, openWebSocket}: WebSocketHandlerParams) => { + console.debug("[useBoardWs] WebSocket was closed, trying to reconnect:", event); + openWebSocket() + }, []) + }); + + const sendAction = useCallback((data: BoardAction) => { + if(!webSocket || webSocketState !== WebSocket.OPEN) { + console.warn("[useBoardWs] WebSocket is not yet ready, cannot send:", data); + return; + } + console.debug("[useBoardWs] Sending:", data); + webSocket.send(JSON.stringify(data)); + }, [webSocket, webSocketState]) + + return {state, sendAction, webSocketState} +} diff --git a/todoblue/src/app/useHttpBaseURL.ts b/todoblue/src/app/useHttpBaseURL.ts new file mode 100644 index 0000000..54ff204 --- /dev/null +++ b/todoblue/src/app/useHttpBaseURL.ts @@ -0,0 +1,20 @@ +import {useEffect, useState} from "react" + + +/** + * **Hook** returning the base URL to use in API calls. + * + * Obtains the base URL from `window.location`, unless the `NEXT_PUBLIC_TODOBLUE_OVERRIDE_BASE_URL` environment variable is set, which takes precedence in that case. + */ +export function useHttpBaseURL(): string | undefined { + const [baseURL, setBaseURL] = useState(undefined); + + useEffect(() => { + let url = process.env.NEXT_PUBLIC_TODOBLUE_OVERRIDE_BASE_URL; + if(!url) url = `${window.location.protocol}//${window.location.host}`; + console.debug("[useBaseURL] Using base URL:", url); + setBaseURL(url); + }, []); + + return baseURL; +} diff --git a/todoblue/src/app/useWebSocket.ts b/todoblue/src/app/useWebSocket.ts deleted file mode 100644 index c08800f..0000000 --- a/todoblue/src/app/useWebSocket.ts +++ /dev/null @@ -1,43 +0,0 @@ -'use client'; - -import {useState, useEffect, useCallback} from "react" - -export interface WebSocketHandlers { - onclose?: (sock: WebSocket, event: CloseEvent) => void, - onerror?: (sock: WebSocket, event: Event) => void, - onmessage?: (sock: WebSocket, event: MessageEvent) => void, - onopen?: (sock: WebSocket, event: Event) => void, -} - -export function useWebSocket(url: string, {onclose, onerror, onmessage, onopen}: WebSocketHandlers) { - const [websocket, setWebsocket] = useState(null) - const [websocketState, setWebsocketState] = useState(0); - - useEffect(() => { - console.debug("[useWebSocket] Creating websocket..."); - const sock = new WebSocket(url); - setWebsocket(sock); - sock.onopen = (ev) => { - setWebsocketState(sock.readyState); - onopen?.(sock, ev); - } - sock.onclose = (ev) => { - setWebsocketState(sock.readyState); - onclose?.(sock, ev); - } - sock.onerror = (ev) => { - setWebsocketState(sock.readyState); - onerror?.(sock, ev); - } - sock.onmessage = (ev) => { - onmessage?.(sock, ev); - } - return () => { - console.debug("[useWebSocket] Closing websocket..."); - sock.close(); - setWebsocket(null); - } - }, [url, onclose, onerror, onmessage, onopen]) - - return {websocket, websocketState} -} diff --git a/todoblue/src/app/useWs.ts b/todoblue/src/app/useWs.ts new file mode 100644 index 0000000..8942f7b --- /dev/null +++ b/todoblue/src/app/useWs.ts @@ -0,0 +1,91 @@ +'use client'; + +import {useState, useCallback, useEffect} from "react" + +export interface WebSocketHandlerParams { + event: E, + openWebSocket: () => void, + closeWebSocket: () => void, +} + +export interface WebSocketHandlers { + onclose?: (params: WebSocketHandlerParams) => void, + onerror?: (params: WebSocketHandlerParams) => void, + onmessage?: (params: WebSocketHandlerParams) => void, + onopen?: (params: WebSocketHandlerParams) => void, +} + +export function useWs(url: string | undefined, {onclose, onerror, onmessage, onopen}: WebSocketHandlers) { + const [webSocket, setWebSocket] = useState(undefined) + const [webSocketState, setWebSocketState] = useState(undefined); + const [webSocketBackoffMs, setWebSocketBackoffMs] = useState(1); + + const closeWebSocket = useCallback(() => { + console.debug("[useWebSocket] Closing WebSocket:", webSocket); + if(webSocket === undefined) { + console.warn("[useWebSocket] Trying to close WebSocket, but no WebSocket is open; ignoring request...") + return; + } + try { + webSocket.close(); + } + catch(closeErr) { + console.debug("[useWebSocket] Failed to close the websocket (it might be already closed):", closeErr) + } + setWebSocket(undefined); + setWebSocketState(WebSocket.CLOSED); + }, [webSocket]) + + const openWebSocket = useCallback(() => { + console.debug("[useWebSocket] Opening WebSocket:", url); + if(url === undefined) { + console.warn("[useWebSocket] Trying to open WebSocket, but no URL has been given; ignoring request...") + return; + } + setWebSocketState(WebSocket.CONNECTING); // Workaround for constructor blocking and giving no feedback to the user + const sock = new WebSocket(url); + sock.onopen = (event) => { + console.debug("[useWebSocket] Opened connection:", event) + setWebSocket(sock) + setWebSocketState(sock.readyState) + setWebSocketBackoffMs(1) + onopen?.({event, openWebSocket, closeWebSocket}); + } + sock.onclose = (event) => { + console.debug("[useWebSocket] Closed connection:", event) + setWebSocket(undefined) + setWebSocketState(sock.readyState); + onclose?.({event, openWebSocket, closeWebSocket}); + } + sock.onerror = (event) => { + console.error("[useWebSocket] Error in connection:", event) + setWebSocketState(sock.readyState) + setWebSocketBackoffMs(prev => prev * 2) + onerror?.({event, openWebSocket, closeWebSocket}); + } + sock.onmessage = (event) => { + console.debug("[useWebSocket] Received message:", event) + onmessage?.({event, openWebSocket, closeWebSocket}); + } + }, [url, onopen, onclose, onerror, onmessage]) + + // @ts-ignore + const openWebSocketAfterBackoff = useCallback(() => { + console.debug("[useWebSocket] Backing off for:", webSocketBackoffMs, "ms") + return setTimeout(openWebSocket, webSocketBackoffMs) + }, [openWebSocket, webSocketBackoffMs]) + + useEffect(() => { + if(!url) { + return; + } + console.debug("[useWebSocket] Hook mounted, opening connection as soon as possible...") + // @ts-ignore + const handle = openWebSocketAfterBackoff() + return () => { + clearTimeout(handle) + } + }, [url, openWebSocketAfterBackoff]) + + return {webSocket, webSocketState, openWebSocket, closeWebSocket} +} diff --git a/todoblue/src/app/useWsBaseURL.ts b/todoblue/src/app/useWsBaseURL.ts new file mode 100644 index 0000000..5b5a271 --- /dev/null +++ b/todoblue/src/app/useWsBaseURL.ts @@ -0,0 +1,9 @@ +import {useHttpBaseURL} from "@/app/useHttpBaseURL" + + +/** + * **Hook** similar to {@link useHttpBaseURL}, but returning the websocket URL instead. + */ +export function useWsBaseURL() { + return useHttpBaseURL()?.replace(/^http/, "ws") +}