From db99b4cb54838f5a2ca1e36b51038dfb80167dd9 Mon Sep 17 00:00:00 2001
From: GyDi <zzzgydi@gmail.com>
Date: Thu, 16 Mar 2023 17:03:00 +0800
Subject: [PATCH] fix: infinite retry when websocket error

---
 src/components/layout/layout-traffic.tsx | 53 +++++++-----------------
 src/components/layout/use-log-setup.ts   | 33 +++++++--------
 src/hooks/use-websocket.ts               | 49 ++++++++++++++++++++++
 src/pages/connections.tsx                | 25 ++++++-----
 4 files changed, 95 insertions(+), 65 deletions(-)
 create mode 100644 src/hooks/use-websocket.ts

diff --git a/src/components/layout/layout-traffic.tsx b/src/components/layout/layout-traffic.tsx
index c4282cf..e920e7c 100644
--- a/src/components/layout/layout-traffic.tsx
+++ b/src/components/layout/layout-traffic.tsx
@@ -5,6 +5,7 @@ import { useClashInfo } from "@/hooks/use-clash";
 import { useVerge } from "@/hooks/use-verge";
 import { TrafficGraph, type TrafficRef } from "./traffic-graph";
 import { useLogSetup } from "./use-log-setup";
+import { useWebsocket } from "@/hooks/use-websocket";
 import parseTraffic from "@/utils/parse-traffic";
 
 // setup the traffic
@@ -18,58 +19,36 @@ const LayoutTraffic = () => {
   const trafficRef = useRef<TrafficRef>(null);
   const [traffic, setTraffic] = useState({ up: 0, down: 0 });
 
-  const wsRef = useRef<WebSocket | null>(null);
-  const [refresh, setRefresh] = useState({});
-
   // setup log ws during layout
   useLogSetup();
 
+  const { connect, disconnect } = useWebsocket((event) => {
+    const data = JSON.parse(event.data) as ITrafficItem;
+    trafficRef.current?.appendData(data);
+    setTraffic(data);
+  });
+
   useEffect(() => {
     if (!clashInfo) return;
 
     const { server = "", secret = "" } = clashInfo;
-    const ws = new WebSocket(`ws://${server}/traffic?token=${secret}`);
-
-    ws.addEventListener("message", (event) => {
-      const data = JSON.parse(event.data) as ITrafficItem;
-      trafficRef.current?.appendData(data);
-      setTraffic(data);
-    });
-
-    ws.addEventListener("error", () => {
-      setTimeout(() => {
-        if (document.visibilityState === "visible") {
-          setRefresh({});
-        }
-      }, 1000);
-    });
-
-    ws.addEventListener("close", () => {
-      setTimeout(() => {
-        if (document.visibilityState === "visible") {
-          setRefresh({});
-        }
-      }, 1000);
-    });
-
-    wsRef.current = ws;
+    connect(`ws://${server}/traffic?token=${secret}`);
 
     return () => {
-      ws?.close();
-      wsRef.current = null;
+      disconnect();
     };
-  }, [clashInfo, refresh]);
+  }, [clashInfo]);
 
   useEffect(() => {
+    // 页面隐藏时去掉请求
     const handleVisibleChange = () => {
+      if (!clashInfo) return;
       if (document.visibilityState === "visible") {
         // reconnect websocket
-        if (
-          wsRef.current &&
-          wsRef.current.readyState !== WebSocket.CONNECTING
-        ) {
-          setRefresh({});
-        }
+        const { server = "", secret = "" } = clashInfo;
+        connect(`ws://${server}/traffic?token=${secret}`);
+      } else {
+        disconnect();
       }
     };
 
diff --git a/src/components/layout/use-log-setup.ts b/src/components/layout/use-log-setup.ts
index ebe0d38..8dde19d 100644
--- a/src/components/layout/use-log-setup.ts
+++ b/src/components/layout/use-log-setup.ts
@@ -1,9 +1,10 @@
 import dayjs from "dayjs";
-import { useEffect, useState } from "react";
+import { useEffect } from "react";
 import { useRecoilValue, useSetRecoilState } from "recoil";
 import { getClashLogs } from "@/services/cmds";
 import { useClashInfo } from "@/hooks/use-clash";
 import { atomEnableLog, atomLogData } from "@/services/states";
+import { useWebsocket } from "@/hooks/use-websocket";
 
 const MAX_LOG_NUM = 1000;
 
@@ -14,7 +15,14 @@ export const useLogSetup = () => {
   const enableLog = useRecoilValue(atomEnableLog);
   const setLogData = useSetRecoilState(atomLogData);
 
-  const [refresh, setRefresh] = useState({});
+  const { connect, disconnect } = useWebsocket((event) => {
+    const data = JSON.parse(event.data) as ILogItem;
+    const time = dayjs().format("MM-DD HH:mm:ss");
+    setLogData((l) => {
+      if (l.length >= MAX_LOG_NUM) l.shift();
+      return [...l, { ...data, time }];
+    });
+  });
 
   useEffect(() => {
     if (!enableLog || !clashInfo) return;
@@ -22,21 +30,10 @@ export const useLogSetup = () => {
     getClashLogs().then(setLogData);
 
     const { server = "", secret = "" } = clashInfo;
-    const ws = new WebSocket(`ws://${server}/logs?token=${secret}`);
-
-    ws.addEventListener("message", (event) => {
-      const data = JSON.parse(event.data) as ILogItem;
-      const time = dayjs().format("MM-DD HH:mm:ss");
-      setLogData((l) => {
-        if (l.length >= MAX_LOG_NUM) l.shift();
-        return [...l, { ...data, time }];
-      });
-    });
-
-    ws.addEventListener("error", () => {
-      setTimeout(() => setRefresh({}), 1000);
-    });
+    connect(`ws://${server}/logs?token=${secret}`);
 
-    return () => ws?.close();
-  }, [clashInfo, enableLog, refresh]);
+    return () => {
+      disconnect();
+    };
+  }, [clashInfo, enableLog]);
 };
diff --git a/src/hooks/use-websocket.ts b/src/hooks/use-websocket.ts
new file mode 100644
index 0000000..1c92e7b
--- /dev/null
+++ b/src/hooks/use-websocket.ts
@@ -0,0 +1,49 @@
+import { useRef } from "react";
+
+export type WsMsgFn = (event: MessageEvent<any>) => void;
+
+interface Options {
+  errorCount?: number; // default is 5
+  retryInterval?: number; // default is 2500
+}
+
+export const useWebsocket = (onMessage: WsMsgFn, options?: Options) => {
+  const wsRef = useRef<WebSocket | null>(null);
+  const timerRef = useRef<any>(null);
+
+  const disconnect = () => {
+    if (wsRef.current) {
+      wsRef.current.close();
+      wsRef.current = null;
+    }
+    if (timerRef.current) {
+      clearTimeout(timerRef.current);
+    }
+  };
+
+  const connect = (url: string) => {
+    let errorCount = options?.errorCount ?? 5;
+
+    if (!url) return;
+
+    const connectHelper = () => {
+      disconnect();
+
+      const ws = new WebSocket(url);
+      wsRef.current = ws;
+
+      ws.addEventListener("message", onMessage);
+      ws.addEventListener("error", () => {
+        errorCount -= 1;
+
+        if (errorCount >= 0) {
+          timerRef.current = setTimeout(connectHelper, 2500);
+        }
+      });
+    };
+
+    connectHelper();
+  };
+
+  return { connect, disconnect };
+};
diff --git a/src/pages/connections.tsx b/src/pages/connections.tsx
index 7ea1444..85f97e4 100644
--- a/src/pages/connections.tsx
+++ b/src/pages/connections.tsx
@@ -17,6 +17,7 @@ import { closeAllConnections } from "@/services/api";
 import { atomConnectionSetting } from "@/services/states";
 import { useClashInfo } from "@/hooks/use-clash";
 import { BaseEmpty, BasePage } from "@/components/base";
+import { useWebsocket } from "@/hooks/use-websocket";
 import ConnectionItem from "@/components/connection/connection-item";
 import ConnectionTable from "@/components/connection/connection-table";
 
@@ -53,15 +54,9 @@ const ConnectionsPage = () => {
     return connections;
   }, [connData, filterText, curOrderOpt]);
 
-  useEffect(() => {
-    if (!clashInfo) return;
-
-    const { server = "", secret = "" } = clashInfo;
-    const ws = new WebSocket(`ws://${server}/connections?token=${secret}`);
-
-    ws.addEventListener("message", (event) => {
+  const { connect, disconnect } = useWebsocket(
+    (event) => {
       const data = JSON.parse(event.data) as IConnections;
-
       // 尽量与前一次connections的展示顺序保持一致
       setConnData((old) => {
         const oldConn = old.connections;
@@ -93,9 +88,19 @@ const ConnectionsPage = () => {
 
         return { ...data, connections };
       });
-    });
+    },
+    { errorCount: 3, retryInterval: 1000 }
+  );
+
+  useEffect(() => {
+    if (!clashInfo) return;
+
+    const { server = "", secret = "" } = clashInfo;
+    connect(`ws://${server}/connections?token=${secret}`);
 
-    return () => ws?.close();
+    return () => {
+      disconnect();
+    };
   }, [clashInfo]);
 
   const onCloseAll = useLockFn(closeAllConnections);
-- 
GitLab