import { createAlova } from 'alova'; import fetchAdapter from 'alova/fetch'; import { createApis, withConfigType, mountApis } from './createApis'; import { recordApiResult } from './health'; import { newTraceparent } from './trace'; const rawAdapter = fetchAdapter(); let refreshInFlight: Promise | null = null; function refreshSession(): Promise { if (!refreshInFlight) { refreshInFlight = fetch('/api/v1/auth/refresh', { method: 'POST', credentials: 'same-origin' }) .then(res => res.ok) .catch(() => false) .finally(() => { refreshInFlight = null; }); } return refreshInFlight; } function redirectToLogin() { const { pathname, search } = window.location; if (pathname.startsWith('/auth')) return; // 带着当前位置重登,回来后还停在原来的页面;入口页不重复带参数。 const target = pathname === '/' || pathname === '/app' ? '' : `&redirect=${encodeURIComponent(pathname + search)}`; window.location.assign(`/api/v1/auth/login?silent=1${target}`); } const retryAdapter: typeof rawAdapter = (elements, method) => { const original = rawAdapter(elements, method); return { abort: original.abort, headers: original.headers, onDownload: original.onDownload, onUpload: original.onUpload, response: async () => { const response = await original.response(); if (response.status !== 401) return response; const body = await response.clone().text(); if (!body.includes('token_expired')) return response; if (!(await refreshSession())) { redirectToLogin(); return response; } return rawAdapter(elements, method).response(); } }; }; export const alovaInstance = createAlova({ baseURL: '/api/v1', requestAdapter: retryAdapter, beforeRequest: method => { method.config.headers = { ...method.config.headers, traceparent: newTraceparent() }; }, responded: { // Every settled response is reported to the health window before its body // is parsed, so an unreachable backend is recognised even when the caller // swallows the resulting error. onSuccess: async (res, method) => { recordApiResult({ status: res.status, url: method.url }); const contentType = res.headers.get('content-type') || ''; let body: unknown; if (contentType.includes('application/json')) { try { body = await res.json(); } catch (err) { // A gateway error page is handled by the envelope fallback below; a // broken success payload must still reject the method unchanged. if (res.status < 400) throw err; body = null; } } else { body = await res.text(); } // A failing hop (dev-server proxy, reverse proxy, gateway) answers with // plain text, never with the documented envelope. Synthesise one so // `unwrapApi` still throws an ApiFailure that carries the real status // instead of `code: undefined`. if (res.status >= 400 && !isEnvelope(body)) { return { code: res.status, message: res.status >= 500 ? '服务未响应(网关错误)' : '服务返回了非接口数据', data: null, error: null, error_code: null }; } return body; }, // Alova routes only real transport failures here (404/500 land on // onSuccess). The error must be rethrown: a global onError that returns // normally makes alova resolve the method instead, so callers would read // `.code` off `undefined` and lose their ApiFailure/network-error branch. onError: (err, method) => { if ((err as Error | null)?.name !== 'AbortError') { recordApiResult({ status: 0, url: method.url, networkError: true }); } throw err; } } }); function isEnvelope(value: unknown): value is { code: number } { return typeof value === 'object' && value !== null && typeof (value as { code?: unknown }).code === 'number'; } export const $$userConfigMap = withConfigType({}); const Apis = createApis(alovaInstance, $$userConfigMap); mountApis(Apis); export default Apis; // Health gate primitives live next to the transport they observe; exported // through the segment's public API so `app` and `entities` never deep-import. export { API_ERROR_BURST_FAILURES, API_ERROR_MIN_SAMPLES, API_ERROR_RATE_THRESHOLD, API_PROBE_INTERVAL_MS, getApiHealth, recordApiResult, retryApiHealth, subscribeApiHealth, trackFetch, useApiHealth, useApiUnavailable, type ApiHealth, type ApiResult } from './health'; export { newTraceparent } from './trace'; // Re-export the generated DTO types through the segment's public API so // consumers never deep-import globals.d (regenerated file, see alova.config). export type * from './globals.d'