84 lines
2.6 KiB
TypeScript
84 lines
2.6 KiB
TypeScript
/** 全局认证 fetch 客户端 */
|
||
|
||
let tokenGetter: () => string | null = () => null;
|
||
const inflightGetRequests = new Map<string, Promise<unknown>>();
|
||
|
||
export class ApiRequestError extends Error {
|
||
readonly status: number;
|
||
readonly code: string | null;
|
||
readonly retryable: boolean;
|
||
|
||
constructor(message: string, status: number, code: string | null, retryable: boolean) {
|
||
super(message);
|
||
this.name = 'ApiRequestError';
|
||
this.status = status;
|
||
this.code = code;
|
||
this.retryable = retryable;
|
||
}
|
||
}
|
||
|
||
interface ApiErrorPayload {
|
||
error?: unknown;
|
||
message?: unknown;
|
||
code?: unknown;
|
||
retryable?: unknown;
|
||
}
|
||
|
||
export function setTokenGetter(fn: () => string | null) {
|
||
tokenGetter = fn;
|
||
}
|
||
|
||
async function executeJsonRequest<T>(url: string, options: RequestInit | undefined, token: string | null): Promise<T> {
|
||
const res = await fetch(url, {
|
||
...options,
|
||
headers: {
|
||
...options?.headers,
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
});
|
||
if (res.status === 401) {
|
||
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
||
throw new Error('Unauthorized');
|
||
}
|
||
if (!res.ok) {
|
||
let payload: ApiErrorPayload = {};
|
||
try {
|
||
const parsed: unknown = await res.json();
|
||
if (typeof parsed === 'object' && parsed !== null) payload = parsed as ApiErrorPayload;
|
||
} catch {
|
||
payload = {};
|
||
}
|
||
const message = typeof payload?.error === 'string'
|
||
? payload.error
|
||
: typeof payload?.message === 'string'
|
||
? payload.message
|
||
: `请求失败(${res.status})`;
|
||
const code = typeof payload?.code === 'string' ? payload.code : null;
|
||
const retryable = typeof payload?.retryable === 'boolean' ? payload.retryable : res.status >= 500;
|
||
throw new ApiRequestError(message, res.status, code, retryable);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||
const token = tokenGetter();
|
||
const method = (options?.method || 'GET').toUpperCase();
|
||
const canDedupe = method === 'GET' && options?.body == null && options?.signal == null;
|
||
if (!canDedupe) return executeJsonRequest<T>(url, options, token);
|
||
|
||
const key = `${token || 'anonymous'}:${url}`;
|
||
const existing = inflightGetRequests.get(key) as Promise<T> | undefined;
|
||
if (existing) return existing;
|
||
|
||
const request = executeJsonRequest<T>(url, options, token)
|
||
.finally(() => {
|
||
if (inflightGetRequests.get(key) === request) inflightGetRequests.delete(key);
|
||
});
|
||
inflightGetRequests.set(key, request);
|
||
return request;
|
||
}
|
||
|
||
export function _clearApiClientInflightForTests(): void {
|
||
inflightGetRequests.clear();
|
||
}
|