client.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import axios, { AxiosError } from 'axios'
  2. import { useAuthStore } from '../stores/authStore'
  3. export const api = axios.create({
  4. baseURL: '/api/v1',
  5. headers: {
  6. 'Content-Type': 'application/json',
  7. },
  8. timeout: 30000, // 30 second timeout
  9. })
  10. // Track consecutive network failures
  11. let networkFailureCount = 0
  12. const MAX_NETWORK_FAILURES = 3
  13. // Request interceptor - add auth token
  14. api.interceptors.request.use((config) => {
  15. const token = useAuthStore.getState().accessToken
  16. if (token) {
  17. config.headers.Authorization = `Bearer ${token}`
  18. }
  19. return config
  20. })
  21. // Response interceptor - handle token refresh and network errors
  22. api.interceptors.response.use(
  23. (response) => {
  24. // Reset failure count on success
  25. networkFailureCount = 0
  26. return response
  27. },
  28. async (error: AxiosError) => {
  29. const originalRequest = error.config as any
  30. // Handle network errors (server unreachable, timeout, etc.)
  31. if (!error.response) {
  32. networkFailureCount++
  33. console.error(`Network error (${networkFailureCount}/${MAX_NETWORK_FAILURES}):`, error.message)
  34. // Auto-logout after consecutive failures
  35. if (networkFailureCount >= MAX_NETWORK_FAILURES && useAuthStore.getState().accessToken) {
  36. console.warn('Server appears to be down, logging out...')
  37. useAuthStore.getState().logout()
  38. window.location.href = '/login?reason=server_unavailable'
  39. return Promise.reject(new Error('Server unavailable'))
  40. }
  41. return Promise.reject(error)
  42. }
  43. // Reset failure count when we get a response (even errors)
  44. networkFailureCount = 0
  45. if (error.response?.status === 401 && !originalRequest._retry) {
  46. originalRequest._retry = true
  47. try {
  48. await useAuthStore.getState().refreshTokens()
  49. const token = useAuthStore.getState().accessToken
  50. originalRequest.headers.Authorization = `Bearer ${token}`
  51. return api(originalRequest)
  52. } catch (refreshError) {
  53. useAuthStore.getState().logout()
  54. window.location.href = '/login'
  55. return Promise.reject(refreshError)
  56. }
  57. }
  58. return Promise.reject(error)
  59. }
  60. )
  61. // WebSocket connection
  62. export class WebSocketClient {
  63. private ws: WebSocket | null = null
  64. private reconnectTimeout: number | null = null
  65. private messageHandlers: Map<string, Set<(data: any) => void>> = new Map()
  66. // What this client wants to be subscribed to, as opposed to what it has
  67. // managed to say so far. send() drops anything queued before the socket is
  68. // open, so a page that subscribes while connecting - which is every page,
  69. // on a cold load - was silently never subscribed to anything.
  70. private desiredChannels = new Set<string>()
  71. private reconnectAttempts: number = 0
  72. private maxReconnectAttempts: number = 5
  73. private baseReconnectDelay: number = 1000
  74. private maxReconnectDelay: number = 30000
  75. private reconnectCallbacks: Set<() => void> = new Set()
  76. private wasConnected: boolean = false
  77. connect() {
  78. // Don't try to connect if not logged in
  79. if (!useAuthStore.getState().accessToken) {
  80. return
  81. }
  82. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
  83. // In development (Vite), use /ws path which is proxied to the backend
  84. // In production, WebSocket server runs on HTTP port + 1
  85. let wsUrl: string
  86. if (import.meta.env.DEV) {
  87. // Use Vite proxy - connects to same host/port with /ws path
  88. wsUrl = `${protocol}//${window.location.host}/ws`
  89. } else {
  90. // Production: WebSocket server on HTTP port + 1
  91. const httpPort = parseInt(window.location.port) || (window.location.protocol === 'https:' ? 443 : 80)
  92. const wsPort = httpPort + 1
  93. wsUrl = `${protocol}//${window.location.hostname}:${wsPort}`
  94. }
  95. try {
  96. this.ws = new WebSocket(wsUrl)
  97. } catch (e) {
  98. console.error('Failed to create WebSocket:', e)
  99. this.handleReconnect()
  100. return
  101. }
  102. this.ws.onopen = () => {
  103. console.log('WebSocket connected')
  104. const isReconnect = this.wasConnected
  105. this.reconnectAttempts = 0 // Reset on successful connection
  106. this.wasConnected = true
  107. // Authenticate
  108. const token = useAuthStore.getState().accessToken
  109. if (token) {
  110. this.send({ type: 'auth', token: `Bearer ${token}` })
  111. }
  112. // Notify listeners about reconnection so they can reset state
  113. if (isReconnect) {
  114. console.log('WebSocket reconnected - notifying listeners to reset state')
  115. this.reconnectCallbacks.forEach(callback => {
  116. try {
  117. callback()
  118. } catch (e) {
  119. console.error('Error in reconnect callback:', e)
  120. }
  121. })
  122. }
  123. }
  124. this.ws.onmessage = (event) => {
  125. try {
  126. const message = JSON.parse(event.data)
  127. // Subscriptions are replayed here rather than in onopen because the
  128. // server refuses a subscribe until it has accepted the token.
  129. if (message.type === 'auth_success' && this.desiredChannels.size > 0) {
  130. this.send({ type: 'subscribe', channels: [...this.desiredChannels] })
  131. }
  132. const channel = message.channel || message.type
  133. const data = message.data || message
  134. // Include channel in data for wildcard handlers
  135. const dataWithChannel = { ...data, _channel: channel }
  136. const handlers = this.messageHandlers.get(channel)
  137. if (handlers) {
  138. handlers.forEach((handler) => handler(data))
  139. }
  140. // Wildcard handlers - pass channel info so handlers know event type
  141. this.messageHandlers.forEach((handlers, pattern) => {
  142. if (pattern.endsWith('.*') && channel.startsWith(pattern.slice(0, -1))) {
  143. handlers.forEach((handler) => handler(dataWithChannel))
  144. }
  145. })
  146. } catch (e) {
  147. console.error('WebSocket message parse error:', e)
  148. }
  149. }
  150. this.ws.onclose = () => {
  151. console.log('WebSocket disconnected')
  152. this.handleReconnect()
  153. }
  154. this.ws.onerror = (error) => {
  155. console.error('WebSocket error:', error)
  156. }
  157. }
  158. private handleReconnect() {
  159. // Don't reconnect if not logged in
  160. if (!useAuthStore.getState().accessToken) {
  161. return
  162. }
  163. this.reconnectAttempts++
  164. if (this.reconnectAttempts > this.maxReconnectAttempts) {
  165. console.warn('Max WebSocket reconnect attempts reached, server appears down')
  166. useAuthStore.getState().logout()
  167. window.location.href = '/login?reason=server_unavailable'
  168. return
  169. }
  170. // Exponential backoff with jitter
  171. const delay = Math.min(
  172. this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts - 1) + Math.random() * 1000,
  173. this.maxReconnectDelay
  174. )
  175. console.log(`WebSocket reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
  176. this.reconnectTimeout = window.setTimeout(() => this.connect(), delay)
  177. }
  178. disconnect() {
  179. if (this.reconnectTimeout) {
  180. clearTimeout(this.reconnectTimeout)
  181. }
  182. if (this.ws) {
  183. this.ws.close()
  184. this.ws = null
  185. }
  186. }
  187. send(message: object) {
  188. if (this.ws?.readyState === WebSocket.OPEN) {
  189. this.ws.send(JSON.stringify(message))
  190. }
  191. }
  192. subscribe(channels: string[]) {
  193. channels.forEach((c) => this.desiredChannels.add(c))
  194. this.send({ type: 'subscribe', channels })
  195. }
  196. unsubscribe(channels: string[]) {
  197. channels.forEach((c) => this.desiredChannels.delete(c))
  198. this.send({ type: 'unsubscribe', channels })
  199. }
  200. on(channel: string, handler: (data: any) => void) {
  201. if (!this.messageHandlers.has(channel)) {
  202. this.messageHandlers.set(channel, new Set())
  203. }
  204. this.messageHandlers.get(channel)!.add(handler)
  205. }
  206. off(channel: string, handler: (data: any) => void) {
  207. this.messageHandlers.get(channel)?.delete(handler)
  208. }
  209. // Register a callback to be called when WebSocket reconnects
  210. // Useful for components to reset their execution state
  211. onReconnect(callback: () => void) {
  212. this.reconnectCallbacks.add(callback)
  213. }
  214. offReconnect(callback: () => void) {
  215. this.reconnectCallbacks.delete(callback)
  216. }
  217. }
  218. export const wsClient = new WebSocketClient()