Files
TallyNote/web-next/src/services/api.ts
T
Qiufeng 12495fb6a4
TallyNote release / linux-x64 (push) Successful in 6m24s
release: 1.1.0
2026-08-31 20:11:34 +08:00

145 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;