import { AUTH_EVENTS, authEvents } from '../events' import { API_CONFIG } from './config' import { NetworkError, TimeoutError, createApiError } from './errors' import type { ApiClient, ApiRequestConfig, HttpMethod } from './types' export class BaseApiClient implements ApiClient { private refreshPromise: Promise | null = null private baseURL: string constructor(baseURL: string = API_CONFIG.BASE_URL) { this.baseURL = baseURL } private buildURL( endpoint: string, params?: Record, ): string { let url: URL if (this.baseURL) { // Full base URL provided url = new URL(endpoint, this.baseURL) } else { // Use relative URL (for reverse proxy) url = new URL(endpoint, window.location.origin) } if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined) { url.searchParams.append(key, String(value)) } }) } return this.baseURL ? url.toString() : url.pathname + url.search } private async request( method: HttpMethod, endpoint: string, data?: unknown, config: ApiRequestConfig = {}, ): Promise { const { params, skipAuth = false, timeout = API_CONFIG.TIMEOUT, headers: customHeaders, ...fetchConfig } = config const url = this.buildURL(endpoint, params) const headers: Record = { 'Content-Type': 'application/json', ...(customHeaders as Record), } const requestConfig: RequestInit = { method, headers, credentials: skipAuth ? 'omit' : 'include', ...fetchConfig, } if (data && ['POST', 'PUT', 'PATCH'].includes(method)) { if (data instanceof FormData) { // Remove Content-Type header for FormData to let browser set it with boundary delete headers['Content-Type'] requestConfig.body = data } else { requestConfig.body = JSON.stringify(data) } } const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeout) requestConfig.signal = controller.signal try { const response = await fetch(url, requestConfig) clearTimeout(timeoutId) if (!response.ok) { if (response.status === 401 && !skipAuth) { try { await this.handleTokenRefresh() // Retry the original request const retryResponse = await fetch(url, requestConfig) if (!retryResponse.ok) { const errorData = await this.safeParseJSON(retryResponse) throw createApiError(retryResponse, errorData) } return (await this.safeParseJSON(retryResponse)) as T } catch (refreshError) { this.handleAuthenticationFailure() throw refreshError } } const errorData = await this.safeParseJSON(response) throw createApiError(response, errorData) } // Handle empty responses (204 No Content, etc.) if ( response.status === 204 || response.headers.get('content-length') === '0' ) { return {} as T } return (await this.safeParseJSON(response)) as T } catch (error) { clearTimeout(timeoutId) if ((error as Error).name === 'AbortError') { throw new TimeoutError() } if (error instanceof TypeError && error.message.includes('fetch')) { throw new NetworkError() } throw error } } private async safeParseJSON(response: Response): Promise { try { const text = await response.text() return text ? JSON.parse(text) : {} } catch { return {} } } private async handleTokenRefresh(): Promise { if (this.refreshPromise) { await this.refreshPromise return } this.refreshPromise = this.performTokenRefresh() try { await this.refreshPromise } finally { this.refreshPromise = null } } private async performTokenRefresh(): Promise { const response = await fetch( `${this.baseURL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', }, ) 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 { // Only redirect if we're not already on auth pages to prevent infinite loops const currentPath = window.location.pathname const authPaths = ['/login', '/register', '/auth/callback'] if (!authPaths.includes(currentPath)) { window.location.href = '/login' } } // Public API methods async get(endpoint: string, config?: ApiRequestConfig): Promise { return this.request('GET', endpoint, undefined, config) } async post( endpoint: string, data?: unknown, config?: ApiRequestConfig, ): Promise { return this.request('POST', endpoint, data, config) } async put( endpoint: string, data?: unknown, config?: ApiRequestConfig, ): Promise { return this.request('PUT', endpoint, data, config) } async patch( endpoint: string, data?: unknown, config?: ApiRequestConfig, ): Promise { return this.request('PATCH', endpoint, data, config) } async delete(endpoint: string, config?: ApiRequestConfig): Promise { return this.request('DELETE', endpoint, undefined, config) } // Utility methods setBaseURL(baseURL: string): void { this.baseURL = baseURL } getBaseURL(): string { return this.baseURL } } // Default API client instance export const apiClient = new BaseApiClient()