import { useAppStore } from "../store"; import { refreshTokens, forceLogout } from "../features/auth/auth"; import { readInjectedApiBase, readInjectedDeviceType } from "../store/helpers"; import { toAsciiDeviceName } from "../utils/format"; import { t } from "../i18n"; // /api 请求的来源前缀:桌面 Windows 注入 loopback 代理地址(绕开会缓冲 SSE 的 // custom scheme),macOS / 浏览器为空(同源相对 URL)。读一次注入值即定。 const API_BASE = readInjectedApiBase(); // 客户端类型(browser / macos / windows / linux),随每个请求发给后端登记设备。 const DEVICE_TYPE = readInjectedDeviceType(); function withBase(input: RequestInfo): RequestInfo { if (typeof input === "string" && API_BASE && input.startsWith("/")) { return API_BASE + input; } return input; } // Centralised fetch wrapper: // - in dev, attaches Authorization: Bearer + X-Dev-User // - in prod, attaches Authorization: Bearer // - always sets X-Device-Name from the store // - in prod, on a 401 response transparently refreshes the access token via // /api/auth/refresh and retries once // - in prod, ONLY if the server authoritatively confirms the session is gone // (refresh → HTTP 401), forces a local logout so the app returns to login // // Returns the fetch Response untouched so callers can drive streaming bodies // (SSE, relay GET stream) themselves. // // State is cleared ONLY on server-confirmed credential invalidation. Short-term // failures never clear it: a transient refresh response (5xx / same-origin 403) // yields "transient", and a network error during refresh THROWS out of here — // both keep the session, leaving the original 401 for the caller as a retryable // error. So a flaky network or a server blip can't log the user out; only a // genuine revoke/expire (refresh → 401) does. export async function apiFetch(input: RequestInfo, init?: RequestInit): Promise { let r = await doFetch(input, init); if (r.status === 401 && useAppStore.getState().authMode === "prod") { const outcome = await refreshTokens(); if (outcome === "refreshed") { r = await doFetch(input, init); } else if (outcome === "invalid") { // 服务端确证会话已失效(/auth/refresh 返回 401,已被吊销 / 过期)→ // 清空本地登录态,由 __root 守卫把界面送回登录页。返回这次 401 让调用 // 方照常收尾。"transient"(5xx / 网络抖动)不进此分支:保留登录态。 forceLogout(); } } return r; } async function doFetch(input: RequestInfo, init?: RequestInit): Promise { const state = useAppStore.getState(); const headers = new Headers(init?.headers ?? {}); if (state.authMode === "dev") { const devToken = import.meta.env.VITE_CDROP_DEV_TOKEN; if (!devToken) { throw new Error(t("errors.devTokenMissing")); } headers.set("Authorization", `Bearer ${devToken}`); if (state.user?.id) { headers.set("X-Dev-User", state.user.id); } } else { // A QR-paired device sends its broker access token as Bearer. A global-SSO // browser has none — the broker's domain cookie carries identity, so we send // no Authorization header and let the edge inject X-Auth from that cookie. if (state.accessToken) { headers.set("Authorization", `Bearer ${state.accessToken}`); } } if (state.selfDeviceName) { // Device names are ASCII-only; coerce here so a legacy non-ASCII name // can't make Headers.set throw (the server sanitises identically). const safeName = toAsciiDeviceName(state.selfDeviceName); if (safeName) { headers.set("X-Device-Name", safeName); } } headers.set("X-Device-Type", DEVICE_TYPE); return fetch(withBase(input), { ...init, headers }); } export async function apiJSON(input: RequestInfo, init?: RequestInit): Promise { const r = await apiFetch(input, init); if (!r.ok) { const text = await r.text().catch(() => r.statusText); throw new Error(`${r.status}: ${text}`); } if (r.status === 204) { return undefined as T; } return ( await r.json() ) as T; }