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; 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 { 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): Record { 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 (/^]|]|]|^<\?xml|]/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 (/^]|]|]|^<\?xml|]/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(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(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(response: Response, fallbackMessage: string): Promise { 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 { 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(path: string, options?: ServerRequestOptions): Promise { 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(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 { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); try { await serverRequest("/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); } }