Files
omniai-web/src/api/serverConnection.ts
T
2026-06-02 12:38:01 +08:00

381 lines
12 KiB
TypeScript

import type { WebUserSession } from "../types";
export const DEFAULT_SERVER_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
export const SERVER_SESSION_STORAGE_KEY = "omniai-web-session";
export const SERVER_SESSION_REPLACED_EVENT = "omniai:session-replaced";
export const SERVER_SESSION_EXPIRED_EVENT = "omniai:session-expired";
export type ServerConnectionState = "checking" | "connected" | "degraded";
export interface ServerConnectionHealth {
state: ServerConnectionState;
baseUrl: string;
checkedAt: string;
errorMessage?: string;
}
export interface ServerRequestOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
body?: unknown;
token?: string;
headers?: Record<string, string>;
raw?: boolean;
signal?: AbortSignal;
/** Per-request timeout in ms. Defaults to DEFAULT_REQUEST_TIMEOUT_MS. Pass 0 to disable. */
timeoutMs?: number;
}
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
export interface ServerSessionReplacedDetail {
status?: number;
code?: string;
message: string;
}
export class ServerRequestError extends Error {
status?: number;
code?: string;
payload?: unknown;
constructor(message: string, status?: number, payload?: unknown) {
super(message);
this.name = "ServerRequestError";
this.status = status;
this.payload = payload;
if (isRecord(payload) && typeof payload.code === "string") {
this.code = payload.code;
}
}
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function compactMessage(value: string): string {
return value.replace(/\s+/g, " ").trim().slice(0, 240);
}
export function getServerBaseUrl(): string {
const envBaseUrl = String(
import.meta.env.VITE_KEY_SERVER_URL ||
import.meta.env.VITE_SERVER_BASE_URL ||
import.meta.env.VITE_API_BASE_URL ||
"",
).trim();
const shouldUseSameOriginApi =
typeof window !== "undefined" &&
(window.location.protocol === "https:" ||
window.location.hostname === "omniai.net.cn" ||
window.location.hostname === "www.omniai.net.cn");
const rawBaseUrl = envBaseUrl || (shouldUseSameOriginApi ? "" : DEFAULT_SERVER_BASE_URL);
if (!rawBaseUrl || rawBaseUrl.replace(/\/+$/, "").toLowerCase() === "/api") {
return "";
}
return rawBaseUrl.replace(/\/+$/, "").replace(/\/api$/i, "");
}
export function buildApiUrl(path: string): string {
const cleanPath = path.replace(/^\/+/, "");
const baseUrl = getServerBaseUrl();
if (!baseUrl) return `/api/${cleanPath}`;
try {
return new URL(`api/${cleanPath}`, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
} catch {
return `${baseUrl}/api/${cleanPath}`;
}
}
export function canUseSessionStorage(): boolean {
return typeof window !== "undefined" && typeof window.sessionStorage !== "undefined";
}
function canUseLocalStorage(): boolean {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
}
function parseStoredSession(raw: string | null): WebUserSession | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
return isRecord(parsed) && typeof parsed.token === "string" && isRecord(parsed.user)
? (parsed as unknown as WebUserSession)
: null;
} catch {
return null;
}
}
export function readStoredSession(): WebUserSession | null {
let fallbackSession: WebUserSession | null = null;
try {
if (canUseLocalStorage()) {
const localSession = parseStoredSession(window.localStorage.getItem(SERVER_SESSION_STORAGE_KEY));
if (localSession) return localSession;
}
} catch {
// Fall through to the legacy session-scoped copy.
}
try {
if (canUseSessionStorage()) {
fallbackSession = parseStoredSession(window.sessionStorage.getItem(SERVER_SESSION_STORAGE_KEY));
}
} catch {
fallbackSession = null;
}
if (fallbackSession && canUseLocalStorage()) {
try {
window.localStorage.setItem(SERVER_SESSION_STORAGE_KEY, JSON.stringify(fallbackSession));
} catch {
// Migrating the legacy session is best-effort.
}
}
return fallbackSession;
}
export function writeStoredSession(session: WebUserSession | null): void {
try {
if (canUseLocalStorage()) {
if (session) {
window.localStorage.setItem(SERVER_SESSION_STORAGE_KEY, JSON.stringify(session));
} else {
window.localStorage.removeItem(SERVER_SESSION_STORAGE_KEY);
}
}
} catch {
// Browser persistence is a convenience layer, not a hard dependency.
}
try {
if (canUseSessionStorage()) {
if (session) {
window.sessionStorage.setItem(SERVER_SESSION_STORAGE_KEY, JSON.stringify(session));
} else {
window.sessionStorage.removeItem(SERVER_SESSION_STORAGE_KEY);
}
}
} catch {
// Keep the local copy as the primary persistence layer.
}
}
export function getStoredToken(): string | null {
return readStoredSession()?.token ?? null;
}
export function buildAuthHeaders(tokenOverride?: string, extraHeaders?: Record<string, string>): Record<string, string> {
const token = (tokenOverride ?? getStoredToken() ?? "").trim();
return {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(extraHeaders || {}),
};
}
export function parseResponseBody(text: string): unknown {
const trimmed = text.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
export function unwrapApiPayload(payload: unknown): unknown {
if (!isRecord(payload)) return payload;
const nested = payload.data ?? payload.result ?? payload.payload;
return nested === undefined ? payload : nested;
}
export function getPayloadMessage(payload: unknown): string | null {
if (typeof payload === "string") {
const message = compactMessage(payload);
if (/^<!doctype html|<html[\s>]|<head[\s>]|<body[\s>]|^<\?xml|<Error[\s>]/i.test(message)) {
return null;
}
return message || null;
}
if (!isRecord(payload)) return null;
const message = payload.error ?? payload.message ?? payload.errorMessage;
if (typeof message !== "string") return null;
const compacted = compactMessage(message);
if (/^<!doctype html|<html[\s>]|<head[\s>]|<body[\s>]|^<\?xml|<Error[\s>]/i.test(compacted)) {
return null;
}
return compacted || null;
}
function getPayloadCode(payload: unknown): string | undefined {
return isRecord(payload) && typeof payload.code === "string" ? payload.code : undefined;
}
let lastSessionReplacedEventAt = 0;
let lastSessionExpiredEventAt = 0;
function notifySessionExpired(status: number, response: Response, payload: unknown): void {
if (status !== 401 && status !== 403) return;
if (typeof window === "undefined") return;
// Auth endpoints (login/register/me) surface their own errors — a wrong
// password must not be mistaken for an expired session.
if (/\/auth\//i.test(response.url)) return;
// SESSION_REPLACED has its own dedicated handling/modal.
if (getPayloadCode(payload) === "SESSION_REPLACED") return;
const now = Date.now();
if (now - lastSessionExpiredEventAt < 1500) return;
lastSessionExpiredEventAt = now;
window.dispatchEvent(
new CustomEvent<ServerSessionReplacedDetail>(SERVER_SESSION_EXPIRED_EVENT, {
detail: { status, code: getPayloadCode(payload), message: "登录状态已失效,请重新登录。" },
}),
);
}
function notifySessionReplaced(status: number, payload: unknown, fallbackMessage: string): void {
const code = getPayloadCode(payload);
const message = getPayloadMessage(payload) || fallbackMessage || "您已在别处登录";
const isSessionReplaced = code === "SESSION_REPLACED" || message.includes("您已在别处登录");
if (!isSessionReplaced || typeof window === "undefined") return;
const now = Date.now();
if (now - lastSessionReplacedEventAt < 1500) return;
lastSessionReplacedEventAt = now;
window.dispatchEvent(
new CustomEvent<ServerSessionReplacedDetail>(SERVER_SESSION_REPLACED_EVENT, {
detail: { status, code, message },
}),
);
}
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error || "Unknown API error");
}
export function isServerRequestError(error: unknown): error is ServerRequestError {
return error instanceof ServerRequestError;
}
export async function readJsonResponse<T>(response: Response, fallbackMessage: string): Promise<T> {
const payload = parseResponseBody(await response.text().catch(() => ""));
if (!response.ok) {
const message =
getPayloadMessage(payload) ||
compactMessage(response.statusText) ||
`${fallbackMessage} (${response.status})`;
notifySessionReplaced(response.status, payload, message);
notifySessionExpired(response.status, response, payload);
throw new ServerRequestError(message, response.status, payload);
}
return unwrapApiPayload(payload) as T;
}
export async function throwResponseError(response: Response, fallbackMessage: string): Promise<never> {
const payload = parseResponseBody(await response.text().catch(() => ""));
const message =
getPayloadMessage(payload) ||
compactMessage(response.statusText) ||
`${fallbackMessage} (${response.status})`;
notifySessionReplaced(response.status, payload, message);
notifySessionExpired(response.status, response, payload);
throw new ServerRequestError(message, response.status, payload);
}
function isRetryable(error: unknown): boolean {
if (error instanceof ServerRequestError) {
const s = error.status;
return s === 429 || (s !== undefined && s >= 500);
}
return error instanceof TypeError || (error instanceof DOMException && error.name !== "AbortError");
}
function getRetryDelay(attempt: number, error: unknown): number {
if (error instanceof ServerRequestError && error.status === 429) {
return Math.min(5000, 2000 * attempt);
}
return Math.min(4000, 300 * 2 ** attempt);
}
const MAX_RETRIES = 2;
export async function serverRequest<T>(path: string, options?: ServerRequestOptions): Promise<T> {
let lastError: unknown;
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const controller = timeoutMs > 0 ? new AbortController() : null;
const timeoutId =
controller && typeof window !== "undefined"
? window.setTimeout(() => controller.abort(new DOMException("Request timed out", "TimeoutError")), timeoutMs)
: null;
const onCallerAbort = () => controller?.abort((options?.signal as AbortSignal)?.reason);
if (controller && options?.signal) {
if (options.signal.aborted) controller.abort(options.signal.reason);
else options.signal.addEventListener("abort", onCallerAbort, { once: true });
}
try {
const headers = buildAuthHeaders(options?.token, options?.headers);
const response = await fetch(buildApiUrl(path), {
method: options?.method || "GET",
headers,
body: options?.body === undefined ? undefined : JSON.stringify(options.body),
signal: controller ? controller.signal : options?.signal,
});
const payload = await readJsonResponse<unknown>(response, "Request failed");
return (options?.raw ? payload : unwrapApiPayload(payload)) as T;
} catch (error) {
lastError = error;
if (attempt < MAX_RETRIES && isRetryable(error) && !options?.signal?.aborted) {
await new Promise((r) => setTimeout(r, getRetryDelay(attempt, error)));
continue;
}
throw error;
} finally {
if (timeoutId !== null) window.clearTimeout(timeoutId);
options?.signal?.removeEventListener("abort", onCallerAbort);
}
}
throw lastError;
}
export async function checkServerHealth(timeoutMs = 4500): Promise<ServerConnectionHealth> {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
try {
await serverRequest<unknown>("/health", { signal: controller.signal });
return {
state: "connected",
baseUrl: getServerBaseUrl(),
checkedAt: new Date().toISOString(),
};
} catch (error) {
return {
state: "degraded",
baseUrl: getServerBaseUrl(),
checkedAt: new Date().toISOString(),
errorMessage: getErrorMessage(error),
};
} finally {
window.clearTimeout(timeoutId);
}
}