Files
my_site/front/src/lib/api.ts
2026-03-26 14:29:30 +08:00

538 lines
13 KiB
TypeScript

import type { AuthResponse } from './types';
import { clearStoredSession, createSession, readStoredSession, saveStoredSession } from './session';
interface ApiEnvelope<T> {
code: number;
msg: string;
data: T;
}
interface ApiRequestInit extends Omit<RequestInit, 'body'> {
body?: unknown;
}
interface ApiUploadRequestInit {
body: FormData;
headers?: HeadersInit;
method?: 'POST' | 'PUT' | 'PATCH';
onProgress?: (progress: {loaded: number; total: number}) => void;
signal?: AbortSignal;
}
interface ApiBinaryUploadRequestInit {
body: Blob;
headers?: HeadersInit;
method?: 'PUT' | 'POST';
onProgress?: (progress: {loaded: number; total: number}) => void;
signal?: AbortSignal;
}
const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, '');
const AUTH_REFRESH_PATH = '/auth/refresh';
let refreshRequestPromise: Promise<boolean> | null = null;
export class ApiError extends Error {
code?: number;
status: number;
isNetworkError: boolean;
constructor(message: string, status = 500, code?: number) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.isNetworkError = status === 0;
}
}
function isNetworkFailure(error: unknown) {
return error instanceof TypeError || error instanceof DOMException;
}
function sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function getRetryDelayMs(attempt: number) {
const schedule = [500, 1200, 2200];
return schedule[Math.min(attempt, schedule.length - 1)];
}
function getMaxRetryAttempts(path: string, init: ApiRequestInit = {}) {
const method = (init.method || 'GET').toUpperCase();
if (method === 'POST' && path === '/auth/login') {
return 1;
}
if (method === 'PATCH' && /^\/files\/\d+\/rename$/.test(path)) {
return 0;
}
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
return 2;
}
return -1;
}
function getRetryDelayForRequest(path: string, init: ApiRequestInit = {}, attempt: number) {
const method = (init.method || 'GET').toUpperCase();
if (method === 'POST' && path === '/auth/login') {
const loginSchedule = [350, 800];
return loginSchedule[Math.min(attempt, loginSchedule.length - 1)];
}
return getRetryDelayMs(attempt);
}
function resolveUrl(path: string) {
if (/^https?:\/\//.test(path)) {
return path;
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${API_BASE_URL}${normalizedPath}`;
}
function normalizePath(path: string) {
return path.startsWith('/') ? path : `/${path}`;
}
function shouldAttemptTokenRefresh(path: string) {
const normalizedPath = normalizePath(path);
return ![
'/auth/login',
'/auth/register',
'/auth/dev-login',
AUTH_REFRESH_PATH,
].includes(normalizedPath);
}
function buildRequestBody(body: ApiRequestInit['body']) {
if (body == null) {
return undefined;
}
if (
body instanceof FormData ||
body instanceof Blob ||
body instanceof URLSearchParams ||
typeof body === 'string' ||
body instanceof ArrayBuffer
) {
return body;
}
return JSON.stringify(body);
}
async function refreshAccessToken() {
const currentSession = readStoredSession();
if (!currentSession?.refreshToken) {
clearStoredSession();
return false;
}
if (refreshRequestPromise) {
return refreshRequestPromise;
}
refreshRequestPromise = (async () => {
try {
const response = await fetch(resolveUrl(AUTH_REFRESH_PATH), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
refreshToken: currentSession.refreshToken,
}),
});
const contentType = response.headers.get('content-type') || '';
if (!response.ok || !contentType.includes('application/json')) {
clearStoredSession();
return false;
}
const payload = (await response.json()) as ApiEnvelope<AuthResponse>;
if (payload.code !== 0 || !payload.data) {
clearStoredSession();
return false;
}
saveStoredSession({
...currentSession,
...createSession(payload.data),
user: payload.data.user ?? currentSession.user,
});
return true;
} catch {
clearStoredSession();
return false;
} finally {
refreshRequestPromise = null;
}
})();
return refreshRequestPromise;
}
async function parseApiError(response: Response) {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
return new ApiError(`请求失败 (${response.status})`, response.status);
}
const payload = (await response.json()) as ApiEnvelope<null>;
return new ApiError(payload.msg || `请求失败 (${response.status})`, response.status, payload.code);
}
export function toNetworkApiError(error: unknown) {
const fallbackMessage = '网络连接异常,请稍后重试';
const message = error instanceof Error && error.message ? error.message : fallbackMessage;
return new ApiError(message === 'Failed to fetch' ? fallbackMessage : message, 0);
}
function toUploadAbortApiError() {
return new ApiError('上传已取消', 0);
}
export function shouldRetryRequest(
path: string,
init: ApiRequestInit = {},
error: unknown,
attempt: number,
) {
if (!isNetworkFailure(error)) {
return false;
}
return attempt <= getMaxRetryAttempts(path, init);
}
async function performRequest(path: string, init: ApiRequestInit = {}, allowRefresh = true): Promise<Response> {
const session = readStoredSession();
const headers = new Headers(init.headers);
const requestBody = buildRequestBody(init.body);
if (session?.token) {
headers.set('Authorization', `Bearer ${session.token}`);
}
if (requestBody && !(requestBody instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json');
}
let response: Response;
let lastError: unknown;
for (let attempt = 0; attempt <= 3; attempt += 1) {
try {
response = await fetch(resolveUrl(path), {
...init,
headers,
body: requestBody,
});
break;
} catch (error) {
lastError = error;
if (!shouldRetryRequest(path, init, error, attempt)) {
throw toNetworkApiError(error);
}
await sleep(getRetryDelayForRequest(path, init, attempt));
}
}
if (!response!) {
throw toNetworkApiError(lastError);
}
if (response.status === 401 && allowRefresh && shouldAttemptTokenRefresh(path)) {
const refreshed = await refreshAccessToken();
if (refreshed) {
return performRequest(path, init, false);
}
}
if (response.status === 401) {
clearStoredSession();
}
return response;
}
export async function apiRequest<T>(path: string, init?: ApiRequestInit) {
const response = await performRequest(path, init);
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
if (!response.ok) {
throw new ApiError(`请求失败 (${response.status})`, response.status);
}
return undefined as T;
}
const payload = (await response.json()) as ApiEnvelope<T>;
if (!response.ok || payload.code !== 0) {
throw new ApiError(payload.msg || `请求失败 (${response.status})`, response.status, payload.code);
}
return payload.data;
}
function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, allowRefresh: boolean): Promise<T> {
const session = readStoredSession();
const headers = new Headers(init.headers);
if (session?.token) {
headers.set('Authorization', `Bearer ${session.token}`);
}
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json');
}
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest();
let settled = false;
const detachAbortSignal = () => {
init.signal?.removeEventListener('abort', handleAbortSignal);
};
const resolveOnce = (value: T | PromiseLike<T>) => {
if (settled) {
return;
}
settled = true;
detachAbortSignal();
resolve(value);
};
const rejectOnce = (error: unknown) => {
if (settled) {
return;
}
settled = true;
detachAbortSignal();
reject(error);
};
const handleAbortSignal = () => {
if (settled) {
return;
}
xhr.abort();
rejectOnce(toUploadAbortApiError());
};
if (init.signal?.aborted) {
rejectOnce(toUploadAbortApiError());
return;
}
xhr.open(init.method || 'POST', resolveUrl(path));
headers.forEach((value, key) => {
xhr.setRequestHeader(key, value);
});
if (init.onProgress) {
xhr.upload.addEventListener('progress', (event) => {
if (!event.lengthComputable) {
return;
}
init.onProgress?.({
loaded: event.loaded,
total: event.total,
});
});
}
xhr.onerror = () => {
if (init.signal?.aborted) {
rejectOnce(toUploadAbortApiError());
return;
}
rejectOnce(toNetworkApiError(new TypeError('Failed to fetch')));
};
xhr.onabort = () => {
rejectOnce(toUploadAbortApiError());
};
xhr.onload = () => {
const contentType = xhr.getResponseHeader('content-type') || '';
if (xhr.status === 401 && allowRefresh && shouldAttemptTokenRefresh(path)) {
refreshAccessToken()
.then((refreshed) => {
if (refreshed) {
resolveOnce(apiUploadRequestInternal<T>(path, init, false));
return;
}
clearStoredSession();
rejectOnce(new ApiError('登录状态已失效,请重新登录', 401));
})
.catch((error) => {
clearStoredSession();
rejectOnce(error instanceof ApiError ? error : toNetworkApiError(error));
});
return;
}
if (!contentType.includes('application/json')) {
if (xhr.status >= 200 && xhr.status < 300) {
resolveOnce(undefined as T);
return;
}
rejectOnce(new ApiError(`请求失败 (${xhr.status})`, xhr.status));
return;
}
const payload = JSON.parse(xhr.responseText) as ApiEnvelope<T>;
if (xhr.status < 200 || xhr.status >= 300 || payload.code !== 0) {
if (xhr.status === 401) {
clearStoredSession();
}
rejectOnce(new ApiError(payload.msg || `请求失败 (${xhr.status})`, xhr.status, payload.code));
return;
}
resolveOnce(payload.data);
};
if (init.signal) {
init.signal.addEventListener('abort', handleAbortSignal, {once: true});
}
xhr.send(init.body);
});
}
export function apiUploadRequest<T>(path: string, init: ApiUploadRequestInit): Promise<T> {
return apiUploadRequestInternal<T>(path, init, true);
}
export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadRequestInit) {
const headers = new Headers(init.headers);
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
let settled = false;
const detachAbortSignal = () => {
init.signal?.removeEventListener('abort', handleAbortSignal);
};
const resolveOnce = () => {
if (settled) {
return;
}
settled = true;
detachAbortSignal();
resolve();
};
const rejectOnce = (error: unknown) => {
if (settled) {
return;
}
settled = true;
detachAbortSignal();
reject(error);
};
const handleAbortSignal = () => {
if (settled) {
return;
}
xhr.abort();
rejectOnce(toUploadAbortApiError());
};
if (init.signal?.aborted) {
rejectOnce(toUploadAbortApiError());
return;
}
xhr.open(init.method || 'PUT', resolveUrl(path));
headers.forEach((value, key) => {
xhr.setRequestHeader(key, value);
});
if (init.onProgress) {
xhr.upload.addEventListener('progress', (event) => {
if (!event.lengthComputable) {
return;
}
init.onProgress?.({
loaded: event.loaded,
total: event.total,
});
});
}
xhr.onerror = () => {
if (init.signal?.aborted) {
rejectOnce(toUploadAbortApiError());
return;
}
rejectOnce(toNetworkApiError(new TypeError('Failed to fetch')));
};
xhr.onabort = () => {
rejectOnce(toUploadAbortApiError());
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolveOnce();
return;
}
rejectOnce(new ApiError(`请求失败 (${xhr.status})`, xhr.status));
};
if (init.signal) {
init.signal.addEventListener('abort', handleAbortSignal, {once: true});
}
xhr.send(init.body);
});
}
export async function apiDownload(path: string) {
const response = await performRequest(path, {
headers: {
Accept: '*/*',
},
});
if (!response.ok) {
throw await parseApiError(response);
}
return response;
}