diff --git a/app/page.tsx b/app/page.tsx index 2364663..d3a3025 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -29,9 +29,9 @@ export default function Page() { return (
{/* Sidebar - hidden on screens smaller than lg (1024px) */} -
+ {/*
-
+
*/}
diff --git a/app/ws-context.tsx b/app/ws-context.tsx index 89de075..bf34965 100644 --- a/app/ws-context.tsx +++ b/app/ws-context.tsx @@ -3,21 +3,26 @@ import React, { createContext, useContext, useRef, useState, ReactNode, useEffect } from 'react' import { Map } from 'maplibre-gl'; import { gql, useSubscription } from '@apollo/client'; +import { subscribeToConnectionState, reconnectWebSocket, WSConnectionState } from '@/lib/apollo-client'; +import * as Sentry from '@sentry/nextjs'; -// 定义MapContext的类型 +// 定义WSContext的类型 interface WSContextType { wsStatus: WsStatus - setWsStatus: (status: WsStatus) => void + connectionState: WSConnectionState data: any loading: boolean error: any restart: () => void + forceReconnect: () => void + isOnline: boolean } enum WsStatus { CONNECTING = 'connecting', CONNECTED = 'connected', - DISCONNECTED = 'disconnected' + DISCONNECTED = 'disconnected', + RECONNECTING = 'reconnecting' } // 创建Context @@ -43,27 +48,101 @@ const SUBSCRIPTION_QUERY = gql` // Provider组件 export function WSProvider({ children }: MapProviderProps) { - const [wsStatus, setWsStatus] = useState(WsStatus.DISCONNECTED) - const { data, loading, error, restart } = useSubscription(SUBSCRIPTION_QUERY) - - useEffect(() => { - if (loading) { - setWsStatus(WsStatus.CONNECTING) - } else if (error) { - setWsStatus(WsStatus.DISCONNECTED) - } else { - setWsStatus(WsStatus.CONNECTED) + const [connectionState, setConnectionState] = useState({ + status: 'disconnected', + reconnectAttempts: 0 + }); + const [isOnline, setIsOnline] = useState(typeof window !== 'undefined' ? navigator.onLine : true); + + const { data, loading, error, restart } = useSubscription(SUBSCRIPTION_QUERY, { + errorPolicy: 'all', + onError: (error) => { + Sentry.captureException(error); } - }, [data]) + }); + + // 监听网络状态变化 + useEffect(() => { + if (typeof window === 'undefined') return; + + const handleOnline = () => { + setIsOnline(true); + if (wsStatus === WsStatus.DISCONNECTED) { + restart(); + } + }; + + const handleOffline = () => { + setIsOnline(false); + setWsStatus(WsStatus.DISCONNECTED); + }; + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, [wsStatus, restart]); + + // 监听 WebSocket 连接状态 + useEffect(() => { + const unsubscribe = subscribeToConnectionState((state) => { + setConnectionState(state); + + switch (state.status) { + case 'connecting': + setWsStatus(WsStatus.CONNECTING); + break; + case 'connected': + setWsStatus(WsStatus.CONNECTED); + break; + case 'disconnected': + setWsStatus(WsStatus.DISCONNECTED); + break; + case 'reconnecting': + setWsStatus(WsStatus.RECONNECTING); + break; + } + }); + + return unsubscribe; + }, []); + + // 监听订阅状态变化 + useEffect(() => { + if (loading && wsStatus !== WsStatus.CONNECTING) { + setWsStatus(WsStatus.CONNECTING); + } else if (error && !loading) { + if (isOnline) { + setTimeout(() => { + restart(); + }, 5000); + } + } + }, [loading, error, isOnline, restart, wsStatus]); + + const forceReconnect = () => { + Sentry.startSpan({ + op: 'websocket.manual-reconnect', + name: 'Manual WebSocket Reconnection' + }, () => { + reconnectWebSocket(); + restart(); + }); + }; const value: WSContextType = { wsStatus, - setWsStatus, + connectionState, data, loading, error, - restart + restart, + forceReconnect, + isOnline } return ( diff --git a/lib/apollo-client.ts b/lib/apollo-client.ts index 780657e..8e1b9dc 100644 --- a/lib/apollo-client.ts +++ b/lib/apollo-client.ts @@ -2,21 +2,119 @@ import { ApolloClient, InMemoryCache, createHttpLink, from, split } from '@apoll import { getMainDefinition } from '@apollo/client/utilities'; import { setContext } from '@apollo/client/link/context'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; -import { createClient } from 'graphql-ws'; +import { createClient, Client } from 'graphql-ws'; +import * as Sentry from '@sentry/nextjs'; const TOKEN_KEY = 'auth_token'; +export interface WSConnectionState { + status: 'connecting' | 'connected' | 'disconnected' | 'reconnecting'; + error?: Error; + reconnectAttempts: number; +} + +let wsConnectionState: WSConnectionState = { + status: 'disconnected', + reconnectAttempts: 0 +}; + +const connectionStateListeners: Set<(state: WSConnectionState) => void> = new Set(); + +export const subscribeToConnectionState = (listener: (state: WSConnectionState) => void) => { + connectionStateListeners.add(listener); + listener(wsConnectionState); + + return () => { + connectionStateListeners.delete(listener); + }; +}; + +const updateConnectionState = (update: Partial) => { + wsConnectionState = { ...wsConnectionState, ...update }; + connectionStateListeners.forEach(listener => listener(wsConnectionState)); +}; + const httpLink = createHttpLink({ uri: process.env.NEXT_PUBLIC_GRAPHQL_BACKEND_URL || 'http://localhost:3050/graphql', - // uri: "http://45.152.65.37:3050/graphql" }); -const wsLink = new GraphQLWsLink(createClient({ - // url: "ws://127.0.0.1:3050/ws", - url: process.env.NEXT_PUBLIC_GRAPHQL_BACKEND_URL?.replace('/graphql', '/ws')?.replace('http://', 'ws://') || 'ws://localhost:3050/ws', - // url: "ws://45.152.65.37:3050/ws" -})); +let wsClient: Client | null = null; + +const createWSClient = () => { + const wsUrl = process.env.NEXT_PUBLIC_GRAPHQL_BACKEND_URL?.replace('/graphql', '/ws')?.replace('http://', 'ws://') || 'ws://localhost:3050/ws'; + + return createClient({ + url: wsUrl, + connectionParams: () => { + const token = typeof window !== 'undefined' ? localStorage.getItem(TOKEN_KEY) : null; + return token ? { authorization: `Bearer ${token}` } : {}; + }, + retryAttempts: 5, + retryWait: async function (attempt) { + const delay = Math.min(1000 * Math.pow(2, attempt), 30000); + updateConnectionState({ + status: 'reconnecting', + reconnectAttempts: attempt + 1 + }); + + Sentry.startSpan({ + op: 'websocket.reconnect', + name: 'WebSocket Reconnection Attempt' + }, (span) => { + span.setAttribute('attempt', attempt + 1); + span.setAttribute('delay', delay); + }); + + await new Promise(resolve => setTimeout(resolve, delay)); + }, + on: { + connecting: () => { + updateConnectionState({ status: 'connecting' }); + }, + opened: () => { + updateConnectionState({ + status: 'connected', + error: undefined, + reconnectAttempts: 0 + }); + + Sentry.startSpan({ + op: 'websocket.connect', + name: 'WebSocket Connection Established' + }, () => { }); + }, + closed: (event: any) => { + const error = event.reason ? new Error(event.reason) : undefined; + updateConnectionState({ + status: 'disconnected', + error + }); + + if (error) { + Sentry.captureException(error); + } + }, + error: (error) => { + updateConnectionState({ + status: 'disconnected', + error: error instanceof Error ? error : new Error('WebSocket error') + }); + + Sentry.captureException(error); + } + } + }); +}; + +const getOrCreateWSClient = () => { + if (!wsClient) { + wsClient = createWSClient(); + } + return wsClient; +}; + +const wsLink = new GraphQLWsLink(getOrCreateWSClient()); const authLink = setContext((_, { headers }) => { // 从 localStorage 获取 token @@ -54,4 +152,13 @@ export const createApolloClient = () => { link, cache: new InMemoryCache(), }); +}; + +export const reconnectWebSocket = () => { + if (wsClient) { + wsClient.dispose(); + wsClient = null; + } + wsClient = createWSClient(); + return wsClient; }; \ No newline at end of file