Files
forkmessager/client-web/src/core/infrastructure/httpClient.ts
2026-04-19 22:13:20 +03:00

157 lines
4.6 KiB
TypeScript

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<string | null> {
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<T>(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise<T> {
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<string, string> = {
...(this.token
? { Authorization: this.token.startsWith('Basic ') ? this.token : `Bearer ${this.token}` }
: {}),
...(fetchOptions.headers as Record<string, string>),
};
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<string, string> = {
...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();