145 lines
5.3 KiB
TypeScript
145 lines
5.3 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 >= 500 ? "服务器暂时繁忙,请稍后重试" : "操作未能完成,请稍后重试"),
|
|
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;
|