Files
TallyNote/web-next/src/services/api.ts
T
2026-09-04 14:01:58 +08:00

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;