WebSocketContext.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. // WebSocket context for real-time updates (US-036)
  2. import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
  3. import { useAuth } from './AuthContext'
  4. const ACCESS_TOKEN_KEY = 'smartbotic_access_token'
  5. interface WebSocketMessage {
  6. type: string
  7. [key: string]: unknown
  8. }
  9. interface Notification {
  10. id: string
  11. message: string
  12. level: 'info' | 'warn' | 'error'
  13. timestamp: string
  14. read: boolean
  15. }
  16. interface DocumentEvent {
  17. workspace_id: string
  18. collection: string
  19. action: 'create' | 'update' | 'delete'
  20. document_id: string
  21. data?: unknown
  22. }
  23. interface WebSocketContextType {
  24. isConnected: boolean
  25. notifications: Notification[]
  26. unreadCount: number
  27. subscribe: (workspaceId: string, collection: string) => void
  28. unsubscribe: (workspaceId: string, collection: string) => void
  29. markAsRead: (notificationId: string) => void
  30. markAllAsRead: () => void
  31. clearNotifications: () => void
  32. onDocumentEvent: (callback: (event: DocumentEvent) => void) => () => void
  33. }
  34. const WebSocketContext = createContext<WebSocketContextType | undefined>(undefined)
  35. const WS_URL = `ws://${window.location.hostname}:18081/ws`
  36. const RECONNECT_DELAY = 3000
  37. const MAX_NOTIFICATIONS = 50
  38. export function WebSocketProvider({ children }: { children: React.ReactNode }) {
  39. const { isAuthenticated, isLoading: isAuthLoading } = useAuth()
  40. const wsRef = useRef<WebSocket | null>(null)
  41. const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
  42. const documentEventCallbacksRef = useRef<Set<(event: DocumentEvent) => void>>(new Set())
  43. const [isConnected, setIsConnected] = useState(false)
  44. const [notifications, setNotifications] = useState<Notification[]>([])
  45. const [subscriptions, setSubscriptions] = useState<Set<string>>(new Set())
  46. const unreadCount = notifications.filter((n) => !n.read).length
  47. // Get access token from localStorage
  48. const getAccessToken = useCallback(() => {
  49. return localStorage.getItem(ACCESS_TOKEN_KEY)
  50. }, [])
  51. // Connect to WebSocket
  52. const connect = useCallback(() => {
  53. // Don't connect while auth is still loading
  54. if (isAuthLoading) {
  55. return
  56. }
  57. const accessToken = getAccessToken()
  58. if (!isAuthenticated || !accessToken || wsRef.current?.readyState === WebSocket.OPEN) {
  59. return
  60. }
  61. try {
  62. const ws = new WebSocket(WS_URL)
  63. wsRef.current = ws
  64. ws.onopen = () => {
  65. console.log('WebSocket connected')
  66. // Send authentication
  67. ws.send(JSON.stringify({ type: 'auth', token: accessToken }))
  68. }
  69. ws.onmessage = (event) => {
  70. try {
  71. const message: WebSocketMessage = JSON.parse(event.data)
  72. handleMessage(message)
  73. } catch (err) {
  74. console.error('Failed to parse WebSocket message:', err)
  75. }
  76. }
  77. ws.onclose = () => {
  78. console.log('WebSocket disconnected')
  79. setIsConnected(false)
  80. wsRef.current = null
  81. // Attempt reconnection
  82. if (isAuthenticated) {
  83. reconnectTimeoutRef.current = setTimeout(connect, RECONNECT_DELAY)
  84. }
  85. }
  86. ws.onerror = (error) => {
  87. console.error('WebSocket error:', error)
  88. }
  89. } catch (err) {
  90. console.error('Failed to create WebSocket connection:', err)
  91. }
  92. }, [isAuthenticated, isAuthLoading, getAccessToken])
  93. // Handle incoming messages
  94. const handleMessage = useCallback((message: WebSocketMessage) => {
  95. switch (message.type) {
  96. case 'auth_success':
  97. setIsConnected(true)
  98. // Re-subscribe to previous subscriptions
  99. subscriptions.forEach((key) => {
  100. const [workspaceId, collection] = key.split(':')
  101. wsRef.current?.send(
  102. JSON.stringify({ type: 'subscribe', workspace_id: workspaceId, collection })
  103. )
  104. })
  105. break
  106. case 'auth_error':
  107. console.error('WebSocket auth error:', message.error)
  108. wsRef.current?.close()
  109. break
  110. case 'notification':
  111. addNotification({
  112. id: crypto.randomUUID(),
  113. message: String(message.message || 'New notification'),
  114. level: (message.level as 'info' | 'warn' | 'error') || 'info',
  115. timestamp: new Date().toISOString(),
  116. read: false,
  117. })
  118. break
  119. case 'document': {
  120. // Notify document event listeners
  121. const docEvent: DocumentEvent = {
  122. workspace_id: String(message.workspace_id || ''),
  123. collection: String(message.collection || ''),
  124. action: message.action as 'create' | 'update' | 'delete',
  125. document_id: String(message.document_id || ''),
  126. data: message.data,
  127. }
  128. documentEventCallbacksRef.current.forEach((cb) => cb(docEvent))
  129. break
  130. }
  131. case 'subscribed':
  132. console.log(`Subscribed to ${message.workspace_id}:${message.collection}`)
  133. break
  134. case 'unsubscribed':
  135. console.log(`Unsubscribed from ${message.workspace_id}:${message.collection}`)
  136. break
  137. case 'error':
  138. console.error('WebSocket error message:', message.error)
  139. break
  140. default:
  141. console.log('Unknown WebSocket message type:', message.type)
  142. }
  143. }, [subscriptions])
  144. // Add notification
  145. const addNotification = useCallback((notification: Notification) => {
  146. setNotifications((prev) => {
  147. const updated = [notification, ...prev]
  148. // Keep only the last MAX_NOTIFICATIONS
  149. return updated.slice(0, MAX_NOTIFICATIONS)
  150. })
  151. }, [])
  152. // Subscribe to collection changes
  153. const subscribe = useCallback((workspaceId: string, collection: string) => {
  154. const key = `${workspaceId}:${collection}`
  155. setSubscriptions((prev) => new Set(prev).add(key))
  156. if (wsRef.current?.readyState === WebSocket.OPEN) {
  157. wsRef.current.send(JSON.stringify({ type: 'subscribe', workspace_id: workspaceId, collection }))
  158. }
  159. }, [])
  160. // Unsubscribe from collection changes
  161. const unsubscribe = useCallback((workspaceId: string, collection: string) => {
  162. const key = `${workspaceId}:${collection}`
  163. setSubscriptions((prev) => {
  164. const updated = new Set(prev)
  165. updated.delete(key)
  166. return updated
  167. })
  168. if (wsRef.current?.readyState === WebSocket.OPEN) {
  169. wsRef.current.send(
  170. JSON.stringify({ type: 'unsubscribe', workspace_id: workspaceId, collection })
  171. )
  172. }
  173. }, [])
  174. // Mark notification as read
  175. const markAsRead = useCallback((notificationId: string) => {
  176. setNotifications((prev) =>
  177. prev.map((n) => (n.id === notificationId ? { ...n, read: true } : n))
  178. )
  179. }, [])
  180. // Mark all notifications as read
  181. const markAllAsRead = useCallback(() => {
  182. setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))
  183. }, [])
  184. // Clear all notifications
  185. const clearNotifications = useCallback(() => {
  186. setNotifications([])
  187. }, [])
  188. // Register document event callback
  189. const onDocumentEvent = useCallback((callback: (event: DocumentEvent) => void) => {
  190. documentEventCallbacksRef.current.add(callback)
  191. return () => {
  192. documentEventCallbacksRef.current.delete(callback)
  193. }
  194. }, [])
  195. // Connect on auth change
  196. useEffect(() => {
  197. // Wait for auth to finish loading
  198. if (isAuthLoading) {
  199. return
  200. }
  201. if (isAuthenticated) {
  202. connect()
  203. } else {
  204. // Disconnect on logout
  205. if (wsRef.current) {
  206. wsRef.current.close()
  207. wsRef.current = null
  208. }
  209. setIsConnected(false)
  210. setSubscriptions(new Set())
  211. }
  212. return () => {
  213. if (reconnectTimeoutRef.current) {
  214. clearTimeout(reconnectTimeoutRef.current)
  215. }
  216. }
  217. }, [isAuthenticated, isAuthLoading, connect])
  218. // Cleanup on unmount
  219. useEffect(() => {
  220. return () => {
  221. if (wsRef.current) {
  222. wsRef.current.close()
  223. }
  224. if (reconnectTimeoutRef.current) {
  225. clearTimeout(reconnectTimeoutRef.current)
  226. }
  227. }
  228. }, [])
  229. const value: WebSocketContextType = {
  230. isConnected,
  231. notifications,
  232. unreadCount,
  233. subscribe,
  234. unsubscribe,
  235. markAsRead,
  236. markAllAsRead,
  237. clearNotifications,
  238. onDocumentEvent,
  239. }
  240. return <WebSocketContext.Provider value={value}>{children}</WebSocketContext.Provider>
  241. }
  242. export function useWebSocket() {
  243. const context = useContext(WebSocketContext)
  244. if (context === undefined) {
  245. throw new Error('useWebSocket must be used within a WebSocketProvider')
  246. }
  247. return context
  248. }