diff --git a/todoblue/src/app/(api)/useWs.ts b/todoblue/src/app/(api)/useWs.ts index 5f2132c..d86193f 100644 --- a/todoblue/src/app/(api)/useWs.ts +++ b/todoblue/src/app/(api)/useWs.ts @@ -1,10 +1,9 @@ 'use client'; -import {useState, useCallback, useEffect} from "react" +import {useState, useCallback, useEffect, useReducer, Reducer} from "react" export interface WebSocketHandlerParams { event: E, - openWebSocketAfterBackoff: () => void, closeWebSocket: () => void, } @@ -15,11 +14,45 @@ export interface WebSocketHandlers { onopen?: (params: WebSocketHandlerParams) => void, } +interface WebSocketState { + webSocket: WebSocket | undefined, + webSocketState: number | undefined, + webSocketBackoffMs: number | undefined, + nextBackOffMs: number +} + +type WebSocketAction = { event: "connect", webSocket: WebSocket } | { event: "disconnect" } | { event: "backoffExpire" } | { event: "stateChange" }; + +function wsReducer(prevState: WebSocketState, action: WebSocketAction) { + switch(action.event) { + case "connect": + return { + webSocket: action.webSocket, + webSocketState: action.webSocket.readyState, + webSocketBackoffMs: prevState.nextBackOffMs, + nextBackOffMs: prevState.nextBackOffMs * 2, + } + case "disconnect": + return { + ...prevState, + webSocket: undefined, + webSocketState: prevState.webSocket?.readyState, + } + case "backoffExpire": + return { + ...prevState, + webSocketBackoffMs: undefined, + } + case "stateChange": + return { + ...prevState, + webSocketState: prevState.webSocket?.readyState + } + } +} + export function useWs(url: string | undefined, {onclose, onerror, onmessage, onopen}: WebSocketHandlers) { - const [webSocket, setWebSocket] = useState(undefined) - const [webSocketState, setWebSocketState] = useState(undefined); - const [isBackingOff, setBackingOff] = useState(false); - const [webSocketBackoffMs, setWebSocketBackoffMs] = useState(1); + const [{webSocket, webSocketState, webSocketBackoffMs, nextBackOffMs}, dispatch] = useReducer>(wsReducer, {webSocket: undefined, webSocketState: undefined, webSocketBackoffMs: undefined, nextBackOffMs: 1000}) const closeWebSocket = useCallback(() => { console.debug("[useWebSocket] Closing WebSocket:", webSocket); @@ -33,61 +66,52 @@ export function useWs(url: string | undefined, {onclose, onerror, onmessage, ono catch(closeErr) { console.debug("[useWebSocket] Failed to close the websocket (it might be already closed):", closeErr) } - setWebSocket(undefined); - setWebSocketState(WebSocket.CLOSED); + dispatch({event: "disconnect"}) }, [webSocket]) - const backOff = useCallback((func: () => void) => async () => { - // This WILL cause no-ops, but they're going to be pretty infrequent, so idc - console.debug("[useWebSocket] Backing off for:", webSocketBackoffMs, "ms") - setBackingOff(true) - await new Promise(resolve => setTimeout(resolve, webSocketBackoffMs)) - setBackingOff(false) - func() - }, [webSocketBackoffMs]) - 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; } - setWebSocketBackoffMs(prev => prev * 2); // Workaround for connections that get closed immediately by the server - setWebSocketState(WebSocket.CONNECTING); // Workaround for constructor blocking and giving no feedback to the user const sock = new WebSocket(url); - const openWebSocketAfterBackoff = backOff(openWebSocket); + dispatch({event: "connect", webSocket: sock}) sock.onopen = (event) => { console.debug("[useWebSocket] Opened connection:", event) - setWebSocket(sock) - setWebSocketState(sock.readyState) - onopen?.({event, openWebSocketAfterBackoff, closeWebSocket}); + dispatch({event: "stateChange"}) + onopen?.({event, closeWebSocket}); } sock.onclose = (event) => { console.debug("[useWebSocket] Closed connection:", event) - setWebSocket(undefined) - setWebSocketState(sock.readyState); - onclose?.({event, openWebSocketAfterBackoff, closeWebSocket}); + dispatch({event: "stateChange"}) + onclose?.({event, closeWebSocket}); } sock.onerror = (event) => { console.error("[useWebSocket] Error in connection:", event) - setWebSocketState(sock.readyState) - onerror?.({event, openWebSocketAfterBackoff, closeWebSocket}); + dispatch({event: "stateChange"}) + onerror?.({event, closeWebSocket}); } sock.onmessage = (event) => { console.debug("[useWebSocket] Received message:", event) - onmessage?.({event, openWebSocketAfterBackoff, closeWebSocket}); + onmessage?.({event, closeWebSocket}); } - }, [url, onopen, onclose, onerror, onmessage, backOff, closeWebSocket]) + }, [url, onopen, onclose, onerror, onmessage, closeWebSocket]) + + useEffect(() => { + if(webSocketBackoffMs === undefined) return; + console.debug("[useWebSocket] Backing off for:", webSocketBackoffMs, "ms") + new Promise(resolve => setTimeout(resolve, webSocketBackoffMs)).then(() => { + dispatch({event: "backoffExpire"}) + }) + }, [webSocketBackoffMs]) useEffect(() => { - if(!url) return; if(webSocket !== undefined) return; - if(isBackingOff) return; - console.debug("[useWebSocket] Hook mounted, opening connection as soon as possible...") - const openWebSocketAfterBackoff = backOff(openWebSocket); - // noinspection JSIgnoredPromiseFromCall - openWebSocketAfterBackoff() - }, [url, webSocket, isBackingOff, webSocketState]) + if(webSocketBackoffMs !== undefined) return; + console.debug("[useWebSocket] Back off expired, opening websocket...") + openWebSocket() + }, [webSocket, webSocketBackoffMs, openWebSocket]) return {webSocket, webSocketState, webSocketBackoffMs} }