157 lines
4.6 KiB
TypeScript
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();
|