feat: integrate Socket.IO for real-time communication; add socket connection management and token refresh handling
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Routes, Route, Navigate } from 'react-router'
|
||||
import { ThemeProvider } from './components/ThemeProvider'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { SocketProvider } from './contexts/SocketContext'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { RegisterPage } from './pages/RegisterPage'
|
||||
import { AuthCallbackPage } from './pages/AuthCallbackPage'
|
||||
@@ -42,8 +43,10 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
<Toaster />
|
||||
<SocketProvider>
|
||||
<AppRoutes />
|
||||
<Toaster />
|
||||
</SocketProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
50
src/components/SocketStatus.tsx
Normal file
50
src/components/SocketStatus.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useSocket } from '../contexts/SocketContext'
|
||||
import { Badge } from './ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card'
|
||||
|
||||
export function SocketStatus() {
|
||||
const { isConnected, connectionError, isReconnecting } = useSocket()
|
||||
|
||||
const getStatusBadge = () => {
|
||||
if (isReconnecting) {
|
||||
return <Badge variant="secondary">Reconnecting...</Badge>
|
||||
}
|
||||
return (
|
||||
<Badge variant={isConnected ? 'default' : 'destructive'}>
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusMessage = () => {
|
||||
if (isReconnecting) {
|
||||
return <div className="text-muted-foreground text-sm">Reconnecting with refreshed token...</div>
|
||||
}
|
||||
if (connectionError) {
|
||||
return <div className="text-destructive text-sm">{connectionError}</div>
|
||||
}
|
||||
if (isConnected) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-sm">Ready for real-time communication</div>
|
||||
<div className="text-xs text-muted-foreground">🔄 Proactive token refresh active</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
WebSocket Status
|
||||
{getStatusBadge()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{getStatusMessage()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
import type { AuthContextType, User, LoginRequest, RegisterRequest } from '@/types/auth'
|
||||
import { authEvents, AUTH_EVENTS } from '@/lib/events'
|
||||
import { tokenRefreshManager } from '@/lib/token-refresh-manager'
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
@@ -26,8 +28,10 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
// Try to get user info using cookies
|
||||
const user = await api.auth.getMe()
|
||||
setUser(user)
|
||||
// Token refresh manager will be started by the user effect below
|
||||
} catch {
|
||||
// User is not authenticated - this is normal for logged out users
|
||||
// Token refresh manager will be stopped by the user effect below
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -35,19 +39,31 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
initAuth()
|
||||
}, [])
|
||||
|
||||
// Start/stop token refresh manager based on user authentication
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
tokenRefreshManager.start()
|
||||
} else {
|
||||
tokenRefreshManager.stop()
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const login = async (credentials: LoginRequest) => {
|
||||
const user = await api.auth.login(credentials)
|
||||
setUser(user)
|
||||
authEvents.emit(AUTH_EVENTS.LOGIN_SUCCESS)
|
||||
}
|
||||
|
||||
const register = async (data: RegisterRequest) => {
|
||||
const user = await api.auth.register(data)
|
||||
setUser(user)
|
||||
authEvents.emit(AUTH_EVENTS.LOGIN_SUCCESS)
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
await api.auth.logout()
|
||||
setUser(null)
|
||||
authEvents.emit(AUTH_EVENTS.LOGOUT)
|
||||
}
|
||||
|
||||
const value: AuthContextType = {
|
||||
|
||||
126
src/contexts/SocketContext.tsx
Normal file
126
src/contexts/SocketContext.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import { useAuth } from './AuthContext'
|
||||
import { authEvents, AUTH_EVENTS } from '../lib/events'
|
||||
|
||||
interface SocketContextType {
|
||||
socket: Socket | null
|
||||
isConnected: boolean
|
||||
connectionError: string | null
|
||||
isReconnecting: boolean
|
||||
}
|
||||
|
||||
const SocketContext = createContext<SocketContextType | undefined>(undefined)
|
||||
|
||||
interface SocketProviderProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function SocketProvider({ children }: SocketProviderProps) {
|
||||
const { user, loading } = useAuth()
|
||||
const [socket, setSocket] = useState<Socket | null>(null)
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null)
|
||||
const [isReconnecting, setIsReconnecting] = useState(false)
|
||||
|
||||
const createSocket = useCallback(() => {
|
||||
if (!user) return null
|
||||
|
||||
const newSocket = io('http://localhost:8000', {
|
||||
withCredentials: true,
|
||||
transports: ['polling', 'websocket'],
|
||||
timeout: 20000,
|
||||
forceNew: true,
|
||||
autoConnect: true,
|
||||
})
|
||||
|
||||
newSocket.on('connect', () => {
|
||||
setIsConnected(true)
|
||||
setConnectionError(null)
|
||||
setIsReconnecting(false)
|
||||
})
|
||||
|
||||
newSocket.on('disconnect', () => {
|
||||
setIsConnected(false)
|
||||
})
|
||||
|
||||
newSocket.on('connect_error', (error) => {
|
||||
setConnectionError(`Connection failed: ${error.message}`)
|
||||
setIsConnected(false)
|
||||
setIsReconnecting(false)
|
||||
})
|
||||
|
||||
return newSocket
|
||||
}, [user])
|
||||
|
||||
// Handle token refresh - reconnect socket with new token
|
||||
const handleTokenRefresh = useCallback(() => {
|
||||
if (!user || !socket) return
|
||||
|
||||
setIsReconnecting(true)
|
||||
|
||||
// Disconnect current socket
|
||||
socket.disconnect()
|
||||
|
||||
// Create new socket with fresh token
|
||||
const newSocket = createSocket()
|
||||
if (newSocket) {
|
||||
setSocket(newSocket)
|
||||
}
|
||||
}, [user, socket, createSocket])
|
||||
|
||||
// Listen for token refresh events
|
||||
useEffect(() => {
|
||||
authEvents.on(AUTH_EVENTS.TOKEN_REFRESHED, handleTokenRefresh)
|
||||
|
||||
return () => {
|
||||
authEvents.off(AUTH_EVENTS.TOKEN_REFRESHED, handleTokenRefresh)
|
||||
}
|
||||
}, [handleTokenRefresh])
|
||||
|
||||
// Initial socket connection
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
|
||||
if (!user) {
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
setSocket(null)
|
||||
setIsConnected(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const newSocket = createSocket()
|
||||
if (newSocket) {
|
||||
setSocket(newSocket)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (newSocket) {
|
||||
newSocket.disconnect()
|
||||
}
|
||||
}
|
||||
}, [loading, user, createSocket])
|
||||
|
||||
const value: SocketContextType = {
|
||||
socket,
|
||||
isConnected,
|
||||
connectionError,
|
||||
isReconnecting,
|
||||
}
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={value}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSocket() {
|
||||
const context = useContext(SocketContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useSocket must be used within a SocketProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { API_CONFIG } from './config'
|
||||
import { createApiError, NetworkError, TimeoutError } from './errors'
|
||||
import type { ApiClient, ApiRequestConfig, HttpMethod } from './types'
|
||||
import { authEvents, AUTH_EVENTS } from '../events'
|
||||
|
||||
export class BaseApiClient implements ApiClient {
|
||||
private refreshPromise: Promise<void> | null = null
|
||||
@@ -147,6 +148,9 @@ export class BaseApiClient implements ApiClient {
|
||||
if (!response.ok) {
|
||||
throw createApiError(response, await this.safeParseJSON(response))
|
||||
}
|
||||
|
||||
// Emit token refresh event for socket reconnection (reactive refresh)
|
||||
authEvents.emit(AUTH_EVENTS.TOKEN_REFRESHED)
|
||||
}
|
||||
|
||||
private handleAuthenticationFailure(): void {
|
||||
|
||||
43
src/lib/events.ts
Normal file
43
src/lib/events.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Simple event emitter for cross-component communication
|
||||
*/
|
||||
|
||||
type EventHandler = (...args: any[]) => void
|
||||
|
||||
class EventEmitter {
|
||||
private events: Map<string, EventHandler[]> = new Map()
|
||||
|
||||
on(event: string, handler: EventHandler): void {
|
||||
if (!this.events.has(event)) {
|
||||
this.events.set(event, [])
|
||||
}
|
||||
this.events.get(event)!.push(handler)
|
||||
}
|
||||
|
||||
off(event: string, handler: EventHandler): void {
|
||||
const handlers = this.events.get(event)
|
||||
if (handlers) {
|
||||
const index = handlers.indexOf(handler)
|
||||
if (index > -1) {
|
||||
handlers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit(event: string, ...args: any[]): void {
|
||||
const handlers = this.events.get(event)
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => handler(...args))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const authEvents = new EventEmitter()
|
||||
|
||||
// Auth event types
|
||||
export const AUTH_EVENTS = {
|
||||
TOKEN_REFRESHED: 'token_refreshed',
|
||||
TOKEN_EXPIRED: 'token_expired',
|
||||
LOGIN_SUCCESS: 'login_success',
|
||||
LOGOUT: 'logout',
|
||||
} as const
|
||||
149
src/lib/token-refresh-manager.ts
Normal file
149
src/lib/token-refresh-manager.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Token refresh manager for proactive token refresh
|
||||
*/
|
||||
|
||||
import { authEvents, AUTH_EVENTS } from './events'
|
||||
import { api } from './api'
|
||||
|
||||
export class TokenRefreshManager {
|
||||
private refreshTimer: NodeJS.Timeout | null = null
|
||||
private refreshInterval = 10 * 60 * 1000 // Refresh every 10 minutes (tokens expire in 15 min)
|
||||
private isRefreshing = false
|
||||
private isEnabled = false
|
||||
|
||||
/**
|
||||
* Start proactive token refresh monitoring
|
||||
*/
|
||||
start(): void {
|
||||
// Prevent multiple starts
|
||||
if (this.isEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isEnabled = true
|
||||
this.scheduleNextRefresh()
|
||||
|
||||
// Listen for visibility changes to handle tab switching
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
|
||||
// Listen for successful auth events to reschedule
|
||||
authEvents.on(AUTH_EVENTS.LOGIN_SUCCESS, this.handleAuthSuccess)
|
||||
authEvents.on(AUTH_EVENTS.TOKEN_REFRESHED, this.handleTokenRefreshed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop proactive token refresh monitoring
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.isEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isEnabled = false
|
||||
this.clearRefreshTimer()
|
||||
|
||||
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
authEvents.off(AUTH_EVENTS.LOGIN_SUCCESS, this.handleAuthSuccess)
|
||||
authEvents.off(AUTH_EVENTS.TOKEN_REFRESHED, this.handleTokenRefreshed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the next token refresh using fixed interval
|
||||
*/
|
||||
private scheduleNextRefresh = (): void => {
|
||||
if (!this.isEnabled) return
|
||||
|
||||
this.clearRefreshTimer()
|
||||
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.performProactiveRefresh()
|
||||
}, this.refreshInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform proactive token refresh
|
||||
*/
|
||||
private performProactiveRefresh = async (): Promise<void> => {
|
||||
if (this.isRefreshing || !this.isEnabled) return
|
||||
|
||||
this.isRefreshing = true
|
||||
|
||||
try {
|
||||
// Use the API client's refresh method directly
|
||||
await api.auth.refreshToken()
|
||||
|
||||
authEvents.emit(AUTH_EVENTS.TOKEN_REFRESHED)
|
||||
|
||||
// Schedule next refresh immediately since we just completed one
|
||||
this.scheduleNextRefresh()
|
||||
|
||||
} catch {
|
||||
// If refresh fails, try again in 1 minute
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.performProactiveRefresh()
|
||||
}, 60 * 1000)
|
||||
} finally {
|
||||
this.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle tab visibility changes
|
||||
*/
|
||||
private handleVisibilityChange = (): void => {
|
||||
if (!document.hidden) {
|
||||
// Tab became active - check if we need to refresh immediately
|
||||
this.checkTokenFreshnessOnActivation()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check token freshness when tab becomes active
|
||||
*/
|
||||
private checkTokenFreshnessOnActivation = async (): Promise<void> => {
|
||||
try {
|
||||
// Try to make an API call to see if token is still valid
|
||||
await api.auth.getMe()
|
||||
|
||||
// Token is still valid, reschedule based on remaining time
|
||||
this.scheduleNextRefresh()
|
||||
} catch (error: unknown) {
|
||||
const errorObj = error as { status?: number; message?: string }
|
||||
if (errorObj?.status === 401 || errorObj?.message?.includes('401')) {
|
||||
// Token is expired, trigger refresh immediately
|
||||
this.performProactiveRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle successful auth events
|
||||
*/
|
||||
private handleAuthSuccess = (): void => {
|
||||
this.scheduleNextRefresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle token refresh events
|
||||
*/
|
||||
private handleTokenRefreshed = (): void => {
|
||||
// Only reschedule if this wasn't our own refresh to avoid double scheduling
|
||||
if (!this.isRefreshing) {
|
||||
this.scheduleNextRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current refresh timer
|
||||
*/
|
||||
private clearRefreshTimer(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer)
|
||||
this.refreshTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global token refresh manager instance
|
||||
export const tokenRefreshManager = new TokenRefreshManager()
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { ModeToggle } from '../components/ModeToggle'
|
||||
import { SocketStatus } from '../components/SocketStatus'
|
||||
|
||||
export function DashboardPage() {
|
||||
const { user, logout } = useAuth()
|
||||
@@ -28,8 +29,11 @@ export function DashboardPage() {
|
||||
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<div className="border-4 border-dashed border-gray-200 dark:border-gray-700 rounded-lg h-96 flex items-center justify-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">Dashboard content coming soon...</p>
|
||||
<div className="space-y-6">
|
||||
<SocketStatus />
|
||||
<div className="border-4 border-dashed border-gray-200 dark:border-gray-700 rounded-lg h-96 flex items-center justify-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">Dashboard content coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user