import { deepNormalize } from '../utils/normalize'; const API_BASE = '/api'; export class HttpClient { private token: string | null = null; private isRefreshing = false; private refreshSubscribers: Array<(token: string) => void> = []; setToken(token: string | null) { this.token = token; } private subscribeTokenRefresh(cb: (token: string) => void) { this.refreshSubscribers.push(cb); } private onRefreshed(token: string) { this.refreshSubscribers.forEach(cb => cb(token)); this.refreshSubscribers = []; } private async handle401(): Promise { if (this.isRefreshing) { return new Promise(resolve => { this.subscribeTokenRefresh(token => { resolve(token); }); }); } const refreshToken = localStorage.getItem('knot_refresh_token'); if (!refreshToken) { return null; } this.isRefreshing = true; try { const response = await fetch(`${API_BASE}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }), }); if (!response.ok) { localStorage.removeItem('knot_token'); localStorage.removeItem('knot_refresh_token'); this.token = null; return null; } const data = await response.json(); const newToken = data.accessToken; const newRefreshToken = data.refreshToken; localStorage.setItem('knot_token', newToken); if (newRefreshToken) { localStorage.setItem('knot_refresh_token', newRefreshToken); } this.token = newToken; this.onRefreshed(newToken); return newToken; } catch (err) { localStorage.removeItem('knot_token'); localStorage.removeItem('knot_refresh_token'); this.token = null; return null; } finally { this.isRefreshing = false; } } async request(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise { const { timeout = 30_000, ...fetchOptions } = options; const controller = new AbortController(); const timer = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : undefined; const isFormData = fetchOptions.body instanceof FormData; const computedHeaders: Record = { ...(this.token ? { Authorization: this.token.startsWith('Basic ') ? this.token : `Bearer ${this.token}` } : {}), ...(fetchOptions.headers as Record), }; if (!isFormData && !computedHeaders['Content-Type']) { computedHeaders['Content-Type'] = 'application/json'; } let response: Response; try { response = await fetch(`${API_BASE}${endpoint}`, { ...fetchOptions, headers: computedHeaders, signal: controller.signal, }); } catch (err) { clearTimeout(timer); if (err instanceof DOMException && err.name === 'AbortError') { throw new Error('Время ожидания запроса истекло'); } throw err; } clearTimeout(timer); // Handle 401 Unauthorized if (response.status === 401 && endpoint !== '/auth/refresh') { const newToken = await this.handle401(); if (newToken) { // Retry the original request with new token const retryHeaders: Record = { ...computedHeaders, Authorization: `Bearer ${newToken}`, }; const retryController = new AbortController(); const retryTimer = timeout > 0 ? setTimeout(() => retryController.abort(), timeout) : undefined; try { response = await fetch(`${API_BASE}${endpoint}`, { ...fetchOptions, headers: retryHeaders, signal: retryController.signal, }); } catch (err) { clearTimeout(retryTimer); if (err instanceof DOMException && err.name === 'AbortError') { throw new Error('Время ожидания запроса истекло'); } throw err; } clearTimeout(retryTimer); } } if (!response.ok) { const errorData = await response.json().catch(() => ({})); const errorMessage = errorData.error || errorData.message || `Request failed with status ${response.status}`; const err = new Error(errorMessage); (err as any).status = response.status; throw err; } // Handle empty responses (e.g., 200 OK with no body) const text = await response.text(); if (!text) { return undefined as T; } const jsonData = JSON.parse(text); return deepNormalize(jsonData); } } export const httpClient = new HttpClient();