client.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  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. // A 401 from the auth endpoints themselves is an answer, not an expired
  46. // session. Logging in with the wrong password returns 401, and treating
  47. // that as "your token lapsed" sent it round the refresh path, found no
  48. // refresh token to use, logged out and reloaded the page - which wiped the
  49. // error the form had just rendered. The red text was there; the reload
  50. // took it away before it could be read. Let these fall through so the
  51. // caller can show what the server actually said.
  52. const url = originalRequest?.url || ''
  53. const isAuthAttempt = url.includes('/auth/login') || url.includes('/auth/refresh')
  54. if (error.response?.status === 401 && !originalRequest._retry && !isAuthAttempt) {
  55. originalRequest._retry = true
  56. try {
  57. await useAuthStore.getState().refreshTokens()
  58. const token = useAuthStore.getState().accessToken
  59. originalRequest.headers.Authorization = `Bearer ${token}`
  60. return api(originalRequest)
  61. } catch (refreshError) {
  62. useAuthStore.getState().logout()
  63. window.location.href = '/login'
  64. return Promise.reject(refreshError)
  65. }
  66. }
  67. return Promise.reject(error)
  68. }
  69. )
  70. // WebSocket connection
  71. export class WebSocketClient {
  72. private ws: WebSocket | null = null
  73. private reconnectTimeout: number | null = null
  74. private messageHandlers: Map<string, Set<(data: any) => void>> = new Map()
  75. // What this client wants to be subscribed to, as opposed to what it has
  76. // managed to say so far. send() drops anything queued before the socket is
  77. // open, so a page that subscribes while connecting - which is every page,
  78. // on a cold load - was silently never subscribed to anything.
  79. private desiredChannels = new Set<string>()
  80. // This connection's own id, told to us when the server accepts the token.
  81. //
  82. // Kept here rather than in whichever component happened to be listening:
  83. // auth_success arrives once, and a page navigated to later has missed it.
  84. // Something that needs to recognise its own messages - a node lock, say -
  85. // would then never know which ones were its own.
  86. clientId: string | null = null
  87. private reconnectAttempts: number = 0
  88. private maxReconnectAttempts: number = 5
  89. private baseReconnectDelay: number = 1000
  90. private maxReconnectDelay: number = 30000
  91. private reconnectCallbacks: Set<() => void> = new Set()
  92. private wasConnected: boolean = false
  93. connect() {
  94. // Don't try to connect if not logged in
  95. if (!useAuthStore.getState().accessToken) {
  96. return
  97. }
  98. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
  99. // In development (Vite), use /ws path which is proxied to the backend
  100. // In production, WebSocket server runs on HTTP port + 1
  101. let wsUrl: string
  102. if (import.meta.env.DEV) {
  103. // Use Vite proxy - connects to same host/port with /ws path
  104. wsUrl = `${protocol}//${window.location.host}/ws`
  105. } else {
  106. // Production: WebSocket server on HTTP port + 1
  107. const httpPort = parseInt(window.location.port) || (window.location.protocol === 'https:' ? 443 : 80)
  108. const wsPort = httpPort + 1
  109. wsUrl = `${protocol}//${window.location.hostname}:${wsPort}`
  110. }
  111. try {
  112. this.ws = new WebSocket(wsUrl)
  113. } catch (e) {
  114. console.error('Failed to create WebSocket:', e)
  115. this.handleReconnect()
  116. return
  117. }
  118. this.ws.onopen = () => {
  119. console.log('WebSocket connected')
  120. const isReconnect = this.wasConnected
  121. this.reconnectAttempts = 0 // Reset on successful connection
  122. this.wasConnected = true
  123. // Authenticate
  124. const token = useAuthStore.getState().accessToken
  125. if (token) {
  126. this.send({ type: 'auth', token: `Bearer ${token}` })
  127. }
  128. // Notify listeners about reconnection so they can reset state
  129. if (isReconnect) {
  130. console.log('WebSocket reconnected - notifying listeners to reset state')
  131. this.reconnectCallbacks.forEach(callback => {
  132. try {
  133. callback()
  134. } catch (e) {
  135. console.error('Error in reconnect callback:', e)
  136. }
  137. })
  138. }
  139. }
  140. this.ws.onmessage = (event) => {
  141. try {
  142. const message = JSON.parse(event.data)
  143. // Subscriptions are replayed here rather than in onopen because the
  144. // server refuses a subscribe until it has accepted the token.
  145. if (message.type === 'auth_success') {
  146. this.clientId = message.clientId || null
  147. if (this.desiredChannels.size > 0) {
  148. this.send({ type: 'subscribe', channels: [...this.desiredChannels] })
  149. }
  150. }
  151. const channel = message.channel || message.type
  152. const data = message.data || message
  153. // Include channel in data for wildcard handlers
  154. const dataWithChannel = { ...data, _channel: channel }
  155. const handlers = this.messageHandlers.get(channel)
  156. if (handlers) {
  157. handlers.forEach((handler) => handler(data))
  158. }
  159. // Wildcard handlers - pass channel info so handlers know event type
  160. this.messageHandlers.forEach((handlers, pattern) => {
  161. if (pattern.endsWith('.*') && channel.startsWith(pattern.slice(0, -1))) {
  162. handlers.forEach((handler) => handler(dataWithChannel))
  163. }
  164. })
  165. } catch (e) {
  166. console.error('WebSocket message parse error:', e)
  167. }
  168. }
  169. this.ws.onclose = () => {
  170. console.log('WebSocket disconnected')
  171. this.handleReconnect()
  172. }
  173. this.ws.onerror = (error) => {
  174. console.error('WebSocket error:', error)
  175. }
  176. }
  177. private handleReconnect() {
  178. // Don't reconnect if not logged in
  179. if (!useAuthStore.getState().accessToken) {
  180. return
  181. }
  182. this.reconnectAttempts++
  183. if (this.reconnectAttempts > this.maxReconnectAttempts) {
  184. console.warn('Max WebSocket reconnect attempts reached, server appears down')
  185. useAuthStore.getState().logout()
  186. window.location.href = '/login?reason=server_unavailable'
  187. return
  188. }
  189. // Exponential backoff with jitter
  190. const delay = Math.min(
  191. this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts - 1) + Math.random() * 1000,
  192. this.maxReconnectDelay
  193. )
  194. console.log(`WebSocket reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
  195. this.reconnectTimeout = window.setTimeout(() => this.connect(), delay)
  196. }
  197. disconnect() {
  198. if (this.reconnectTimeout) {
  199. clearTimeout(this.reconnectTimeout)
  200. }
  201. if (this.ws) {
  202. this.ws.close()
  203. this.ws = null
  204. }
  205. }
  206. send(message: object) {
  207. if (this.ws?.readyState === WebSocket.OPEN) {
  208. this.ws.send(JSON.stringify(message))
  209. }
  210. }
  211. subscribe(channels: string[]) {
  212. channels.forEach((c) => this.desiredChannels.add(c))
  213. this.send({ type: 'subscribe', channels })
  214. }
  215. unsubscribe(channels: string[]) {
  216. channels.forEach((c) => this.desiredChannels.delete(c))
  217. this.send({ type: 'unsubscribe', channels })
  218. }
  219. on(channel: string, handler: (data: any) => void) {
  220. if (!this.messageHandlers.has(channel)) {
  221. this.messageHandlers.set(channel, new Set())
  222. }
  223. this.messageHandlers.get(channel)!.add(handler)
  224. }
  225. off(channel: string, handler: (data: any) => void) {
  226. this.messageHandlers.get(channel)?.delete(handler)
  227. }
  228. // Register a callback to be called when WebSocket reconnects
  229. // Useful for components to reset their execution state
  230. onReconnect(callback: () => void) {
  231. this.reconnectCallbacks.add(callback)
  232. }
  233. offReconnect(callback: () => void) {
  234. this.reconnectCallbacks.delete(callback)
  235. }
  236. }
  237. export const wsClient = new WebSocketClient()