Files
omniai-web/src/api/serverConnection.ts
T

426 lines
14 KiB
TypeScript

import type { WebUserSession } from "../types";
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;
/** Defaults to 2. Use 0 for non-idempotent task submission endpoints. */
maxRetries?: number;
fallbackMessage?: string;
}
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 {
return "";
}
export function buildApiUrl(path: string): string {
const cleanPath = path.replace(/^\/+/, "");
return `/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 clearAllUserStorage(): void {
writeStoredSession(null);
try {
if (typeof window === "undefined") return;
const legacyKeys = ["omniai:token", "omniai:session"];
for (const key of legacyKeys) {
window.localStorage.removeItem(key);
window.sessionStorage.removeItem(key);
}
const prefixKeys = [
"omniai-web-profile-ui",
"omniai:more-recent-tools",
"omniai:generation-queue",
"omniai-canvas-saved-assets",
];
for (let i = window.localStorage.length - 1; i >= 0; i--) {
const key = window.localStorage.key(i);
if (key && prefixKeys.some((p) => key.startsWith(p))) {
window.localStorage.removeItem(key);
}
}
for (let i = window.sessionStorage.length - 1; i >= 0; i--) {
const key = window.sessionStorage.key(i);
if (key && prefixKeys.some((p) => key.startsWith(p))) {
window.sessionStorage.removeItem(key);
}
}
} catch {
// best-effort cleanup
}
}
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 isNonAuthErrorCode(code: string | undefined): boolean {
if (!code) return false;
return [
"ENTERPRISE_VIDEO_MODEL_NOT_ALLOWED",
"INSUFFICIENT_BALANCE",
"INSUFFICIENT_ENTERPRISE_BALANCE",
].includes(code);
}
function isAuthFailureResponse(status: number, payload: unknown): boolean {
if (status === 401) return true;
if (status !== 403) return false;
const code = getPayloadCode(payload);
if (code === "SESSION_REPLACED" || code === "TOKEN_EXPIRED" || code === "ACCOUNT_DISABLED") return true;
const message = getPayloadMessage(payload) || "";
return /账号已禁用|登录已过期|登录状态|session|token|企业信息不存在/i.test(message);
}
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;
// If the user never had a session, a 401 is expected — not a session expiry.
if (!readStoredSession()) return;
// Deliberate early-exit for unauthenticated users — not a real auth failure.
if (getPayloadCode(payload) === "NOT_LOGGED_IN") return;
// Non-auth 403 errors (enterprise model access, insufficient balance) must
// not trigger session expiry.
if (status === 403 && isNonAuthErrorCode(getPayloadCode(payload))) return;
if (!isAuthFailureResponse(status, payload)) 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;
if (!readStoredSession()) 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;
const maxRetries = options?.maxRetries ?? MAX_RETRIES;
const fallbackMessage = options?.fallbackMessage || "Request failed";
for (let attempt = 0; attempt <= maxRetries; 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,
credentials: "include",
});
const payload = await readJsonResponse<unknown>(response, fallbackMessage);
return (options?.raw ? payload : unwrapApiPayload(payload)) as T;
} catch (error) {
lastError = error;
if (attempt < maxRetries && 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);
}
}