import { ApiError, SessionExpiredError, type ApiErrorBody, type ApiSuccess } from "./types"; type AccessTokenGetter = () => string | null; type RefreshAccessToken = () => Promise; type ClearSession = () => void; let getAccessToken: AccessTokenGetter = () => null; let refreshAccessToken: RefreshAccessToken | null = null; let clearSession: ClearSession | null = null; let refreshPromise: Promise | null = null; export type ConfigureHttpAuthOptions = { getAccessToken: AccessTokenGetter; refreshAccessToken: RefreshAccessToken; clearSession?: ClearSession; }; export type ApiRequestOptions = Omit & { headers?: HeadersInit; auth?: boolean; retryOn401?: boolean; accessTokenOverride?: string | null; }; export function configureHttpAuth(options: ConfigureHttpAuthOptions) { getAccessToken = options.getAccessToken; refreshAccessToken = options.refreshAccessToken; clearSession = options.clearSession ?? null; } async function safeParseJson(response: Response): Promise { const contentType = response.headers.get("content-type") ?? ""; if (!contentType.includes("application/json")) { return null; } try { return await response.json(); } catch { return null; } } function buildApiError(response: Response, payload: unknown): ApiError { const fallbackRequestId = response.headers.get("x-request-id") ?? ""; if (payload && typeof payload === "object" && "error" in payload) { const body = payload as ApiErrorBody; return new ApiError({ status: response.status, code: body.error?.code ?? "SYS_001", message: body.error?.message ?? "请求失败", details: body.error?.details, requestId: body.requestId || fallbackRequestId, }); } return new ApiError({ status: response.status, code: "SYS_001", message: "请求失败", requestId: fallbackRequestId, }); } async function getRefreshedToken(): Promise { if (!refreshAccessToken) { return null; } if (!refreshPromise) { refreshPromise = refreshAccessToken() .catch(() => null) .finally(() => { refreshPromise = null; }); } return refreshPromise; } function throwSessionExpired(): never { if (clearSession) { clearSession(); } throw new SessionExpiredError(); } async function sendRequest( path: string, options: ApiRequestOptions, ): Promise> { const { auth = true, retryOn401 = true, accessTokenOverride, headers, ...rest } = options; const requestHeaders = new Headers(headers ?? {}); const token = accessTokenOverride ?? (auth ? getAccessToken() : null); if (auth && token) { requestHeaders.set("authorization", `Bearer ${token}`); } if (rest.body && !(rest.body instanceof FormData) && !requestHeaders.has("content-type")) { requestHeaders.set("content-type", "application/json"); } const response = await fetch(path, { ...rest, credentials: "include", headers: requestHeaders, }); if (response.status === 401 && auth && retryOn401 && refreshAccessToken) { const nextToken = await getRefreshedToken(); if (nextToken) { return sendRequest(path, { ...options, retryOn401: false, accessTokenOverride: nextToken, }); } throwSessionExpired(); } const payload = await safeParseJson(response); if (!response.ok) { throw buildApiError(response, payload); } if (payload && typeof payload === "object" && "data" in payload && "requestId" in payload) { return payload as ApiSuccess; } return { requestId: response.headers.get("x-request-id") ?? "", data: undefined as T, }; } export async function apiRequest( path: string, options: ApiRequestOptions = {}, ): Promise> { return sendRequest(path, options); } export async function apiRequestData( path: string, options: ApiRequestOptions = {}, ): Promise { const response = await apiRequest(path, options); return response.data; } function parseFilenameFromContentDisposition(headerValue: string | null): string | null { if (!headerValue) { return null; } const parts = headerValue.split(";").map((part) => part.trim()); const filenamePart = parts.find((part) => part.toLowerCase().startsWith("filename=")); if (!filenamePart) { return null; } const raw = filenamePart.slice("filename=".length).trim(); return raw.replace(/^\"|\"$/g, "") || null; } export type ApiBlobResponse = { requestId: string; blob: Blob; filename: string | null; }; export async function apiRequestBlob( path: string, options: ApiRequestOptions = {}, ): Promise { const { auth = true, retryOn401 = true, accessTokenOverride, headers, ...rest } = options; const requestHeaders = new Headers(headers ?? {}); const token = accessTokenOverride ?? (auth ? getAccessToken() : null); if (auth && token) { requestHeaders.set("authorization", `Bearer ${token}`); } const response = await fetch(path, { ...rest, credentials: "include", headers: requestHeaders, }); if (response.status === 401 && auth && retryOn401 && refreshAccessToken) { const nextToken = await getRefreshedToken(); if (nextToken) { return apiRequestBlob(path, { ...options, retryOn401: false, accessTokenOverride: nextToken, }); } throwSessionExpired(); } const payload = await safeParseJson(response); if (!response.ok) { throw buildApiError(response, payload); } const blob = await response.blob(); return { requestId: response.headers.get("x-request-id") ?? "", blob, filename: parseFilenameFromContentDisposition(response.headers.get("content-disposition")), }; }