145 lines
5.2 KiB
TypeScript
145 lines
5.2 KiB
TypeScript
import type { ApiErrorBody } from "../types/auth";
|
||
|
||
export const AUTH_EXPIRED_EVENT = "tallynote-auth-expired";
|
||
const UNSAFE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||
const AUTH_EXEMPT_PATHS = new Set([
|
||
"/api/auth/login",
|
||
"/api/auth/session",
|
||
]);
|
||
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
||
const UPLOAD_REQUEST_TIMEOUT_MS = 120_000;
|
||
|
||
export interface ApiRequestInit extends RequestInit {
|
||
timeoutMs?: number;
|
||
}
|
||
|
||
export class ApiError extends Error {
|
||
readonly status: number;
|
||
readonly code?: string;
|
||
readonly details?: unknown;
|
||
readonly requestId?: string;
|
||
readonly retryAfter?: number;
|
||
|
||
constructor(status: number, message: string, code?: string, details?: unknown, requestId?: string, retryAfter?: number) {
|
||
super(message);
|
||
this.name = "ApiError";
|
||
this.status = status;
|
||
this.code = code;
|
||
this.details = details;
|
||
this.requestId = requestId;
|
||
this.retryAfter = retryAfter;
|
||
}
|
||
}
|
||
|
||
function readCookie(name: string): string {
|
||
if (typeof document === "undefined") return "";
|
||
const prefix = `${name}=`;
|
||
const value = document.cookie.split(";").map((part) => part.trim()).find((part) => part.startsWith(prefix));
|
||
if (!value) return "";
|
||
const raw = value.slice(prefix.length);
|
||
try {
|
||
return decodeURIComponent(raw);
|
||
} catch {
|
||
return raw;
|
||
}
|
||
}
|
||
|
||
function requestPath(url: string): string {
|
||
try {
|
||
return new URL(url, typeof window === "undefined" ? "http://localhost" : window.location.origin).pathname;
|
||
} catch {
|
||
return url.split("?", 1)[0] ?? url;
|
||
}
|
||
}
|
||
|
||
function emitAuthExpired(message: string): void {
|
||
if (typeof window !== "undefined") {
|
||
window.dispatchEvent(new CustomEvent(AUTH_EXPIRED_EVENT, { detail: message }));
|
||
}
|
||
}
|
||
|
||
export async function api<T = unknown>(url: string, init: ApiRequestInit = {}): Promise<T> {
|
||
const { timeoutMs: requestedTimeout, signal: externalSignal, ...requestInit } = init;
|
||
const method = (init.method ?? "GET").toUpperCase();
|
||
const headers = new Headers(init.headers);
|
||
if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
|
||
headers.set("Content-Type", "application/json");
|
||
}
|
||
if (UNSAFE_METHODS.has(method)) {
|
||
const csrf = readCookie("tally_csrf");
|
||
if (csrf) headers.set("X-CSRF-Token", csrf);
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
let timedOut = false;
|
||
const timeoutMs = requestedTimeout ?? (init.body instanceof FormData ? UPLOAD_REQUEST_TIMEOUT_MS : DEFAULT_REQUEST_TIMEOUT_MS);
|
||
const abortFromCaller = () => controller.abort(externalSignal?.reason);
|
||
if (externalSignal?.aborted) abortFromCaller();
|
||
else externalSignal?.addEventListener("abort", abortFromCaller, { once: true });
|
||
const timeout = setTimeout(() => {
|
||
timedOut = true;
|
||
controller.abort();
|
||
}, timeoutMs);
|
||
|
||
try {
|
||
const response = await fetch(url, { ...requestInit, credentials: "include", headers, signal: controller.signal });
|
||
if (response.status === 204) return undefined as T;
|
||
const contentType = response.headers.get("content-type") ?? "";
|
||
const data: unknown = contentType.includes("json")
|
||
? await response.json().catch(() => ({}))
|
||
: await response.text().catch(() => "");
|
||
if (!response.ok) {
|
||
const body = (data && typeof data === "object" ? data : {}) as ApiErrorBody;
|
||
const error = body.error;
|
||
if (response.status === 401 && error?.code === "AUTH_REQUIRED" && !AUTH_EXEMPT_PATHS.has(requestPath(url))) {
|
||
emitAuthExpired(error.message || "登录已失效,请重新登录");
|
||
}
|
||
throw new ApiError(
|
||
response.status,
|
||
error?.message || `请求失败(${response.status})`,
|
||
error?.code,
|
||
error?.details,
|
||
error?.requestId,
|
||
Number(response.headers.get("retry-after") || 0) || undefined,
|
||
);
|
||
}
|
||
return data as T;
|
||
} catch (caught) {
|
||
if (caught instanceof ApiError) throw caught;
|
||
if (timedOut) throw new ApiError(408, "请求超时,请稍后重试", "REQUEST_TIMEOUT");
|
||
if (externalSignal?.aborted) throw new ApiError(0, "请求已取消", "REQUEST_CANCELED");
|
||
throw new ApiError(0, "网络连接失败,请确认服务仍在运行");
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
externalSignal?.removeEventListener("abort", abortFromCaller);
|
||
}
|
||
}
|
||
|
||
export const apiGet = <T>(url: string, init?: ApiRequestInit) => api<T>(url, { ...init, method: "GET" });
|
||
export const apiPost = <T>(url: string, body?: unknown, init: ApiRequestInit = {}) => api<T>(url, {
|
||
...init,
|
||
method: "POST",
|
||
body: body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify(body),
|
||
});
|
||
|
||
export const apiPut = <T>(url: string, body?: unknown, init: ApiRequestInit = {}) => api<T>(url, {
|
||
...init,
|
||
method: "PUT",
|
||
body: body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify(body),
|
||
});
|
||
|
||
export const apiPatch = <T>(url: string, body?: unknown, init: ApiRequestInit = {}) => api<T>(url, {
|
||
...init,
|
||
method: "PATCH",
|
||
body: body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify(body),
|
||
});
|
||
|
||
export const apiDelete = <T>(url: string, body?: unknown, init: ApiRequestInit = {}) => api<T>(url, {
|
||
...init,
|
||
method: "DELETE",
|
||
body: body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify(body),
|
||
});
|
||
|
||
export const fetchApi = api;
|
||
export const request = api;
|