import type { ApiError } from './types' let accessToken: string | null = null let refreshInFlight: Promise | null = null let onUnauthorized: (() => void) | null = null export function setAccessToken(token: string | null) { accessToken = token } export function getAccessToken() { return accessToken } /** Вызывается, когда refresh-токен недействителен — обычно очищает стор авторизации и шлёт на /login. */ export function setUnauthorizedHandler(handler: (() => void) | null) { onUnauthorized = handler } type RequestOptions = { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' body?: unknown /** Не пытаться освежить токен на 401 (используется самим refresh-запросом, чтобы не зациклиться). */ skipRefresh?: boolean } async function refreshAccessToken(): Promise { if (!refreshInFlight) { refreshInFlight = (async () => { try { const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' }) if (!response.ok) return false const data = (await response.json()) as { accessToken: string } setAccessToken(data.accessToken) return true } catch { return false } finally { refreshInFlight = null } })() } return refreshInFlight } export class HttpError extends Error implements ApiError { title: string detail: string status: number constructor(problem: Partial, status: number) { super(problem.detail ?? problem.title ?? `HTTP ${status}`) this.title = problem.title ?? 'Error' this.detail = problem.detail ?? this.message this.status = status } } async function parseError(response: Response): Promise { try { const problem = (await response.json()) as Partial return new HttpError(problem, response.status) } catch { return new HttpError({ title: response.statusText }, response.status) } } export async function apiRequest(path: string, options: RequestOptions = {}): Promise { const headers: Record = {} if (accessToken) headers.Authorization = `Bearer ${accessToken}` if (options.body !== undefined) headers['Content-Type'] = 'application/json' const response = await fetch(`/api${path}`, { method: options.method ?? 'GET', headers, credentials: 'include', body: options.body !== undefined ? JSON.stringify(options.body) : undefined, }) if (response.status === 401 && !options.skipRefresh) { const refreshed = await refreshAccessToken() if (refreshed) return apiRequest(path, { ...options, skipRefresh: true }) onUnauthorized?.() throw await parseError(response) } if (!response.ok) throw await parseError(response) if (response.status === 204) return undefined as T const text = await response.text() return (text ? JSON.parse(text) : undefined) as T } type UploadOptions = { method?: 'POST' | 'PUT' skipRefresh?: boolean } /** Как apiRequest, но для multipart/form-data (вложения к тикетам) — без JSON.stringify и * без Content-Type (браузер сам проставляет boundary). */ export async function apiUpload(path: string, formData: FormData, options: UploadOptions = {}): Promise { const headers: Record = {} if (accessToken) headers.Authorization = `Bearer ${accessToken}` const response = await fetch(`/api${path}`, { method: options.method ?? 'POST', headers, credentials: 'include', body: formData, }) if (response.status === 401 && !options.skipRefresh) { const refreshed = await refreshAccessToken() if (refreshed) return apiUpload(path, formData, { ...options, skipRefresh: true }) onUnauthorized?.() throw await parseError(response) } if (!response.ok) throw await parseError(response) if (response.status === 204) return undefined as T const text = await response.text() return (text ? JSON.parse(text) : undefined) as T }