538 lines
13 KiB
TypeScript
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;
|
|
}
|