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(url: string, init: ApiRequestInit = {}): Promise { 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 = (url: string, init?: ApiRequestInit) => api(url, { ...init, method: "GET" }); export const apiPost = (url: string, body?: unknown, init: ApiRequestInit = {}) => api(url, { ...init, method: "POST", body: body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify(body), }); export const apiPut = (url: string, body?: unknown, init: ApiRequestInit = {}) => api(url, { ...init, method: "PUT", body: body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify(body), }); export const apiPatch = (url: string, body?: unknown, init: ApiRequestInit = {}) => api(url, { ...init, method: "PATCH", body: body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify(body), }); export const apiDelete = (url: string, body?: unknown, init: ApiRequestInit = {}) => api(url, { ...init, method: "DELETE", body: body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify(body), }); export const fetchApi = api; export const request = api;