import { store } from "@store/state.svelte"; import { fetchAuthenticated, AuthRequiredError, refreshUiAccessToken } from "../core/auth"; import { boot } from "@store/boot.svelte"; import { getBlobUrl } from "@core/cache/imageCache"; const DEFAULT_URL = "http://127.0.0.1:4567"; type ReauthResolver = () => void; let _reauthQueue: ReauthResolver[] = []; export function notifyReauthSuccess() { const queue = _reauthQueue; _reauthQueue = []; queue.forEach(resolve => resolve()); } function waitForReauth(): Promise { return new Promise(resolve => { _reauthQueue.push(resolve); }); } export function getServerUrl(): string { const url = store.settings.serverUrl; return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL; } export function plainThumbUrl(path: string): string { if (!path) return ""; if (path.startsWith("http")) return path; return `${getServerUrl()}${path}`; } export async function resolveImageUrl(path: string): Promise { if (!path) return ""; const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`; const mode = store.settings.serverAuthMode ?? "NONE"; if (mode === "NONE") return url; return getBlobUrl(url); } export const thumbUrl = plainThumbUrl; interface GQLResponse { data: T; errors?: { message: string }[]; } function abortableSleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; } const timer = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("Aborted", "AbortError")); }, { once: true }); }); } async function fetchWithRetry( url: string, init: RequestInit, signal?: AbortSignal, retries = 3, delayMs = 300, ): Promise { if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); for (let i = 0; i < retries; i++) { if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); try { const res = await fetchAuthenticated(url, init, signal, boot.skipped); if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); return res; } catch (e: any) { if (e?.authRequired) throw e; if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (e instanceof AuthRequiredError) throw e; if (i === retries - 1) throw e; await abortableSleep(delayMs * Math.pow(1.5, i), signal); } } throw new Error("unreachable"); } export async function fetchImage( path: string, signal?: AbortSignal, ): Promise<{ src: string; revoke: () => void }> { if (!path) return { src: "", revoke: () => {} }; const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`; const mode = store.settings.serverAuthMode ?? "NONE"; if (mode === "NONE") return { src: url, revoke: () => {} }; const res = await fetchWithRetry(url, { method: "GET" }, signal); if (!res.ok) throw new Error(`Image fetch failed: ${res.status}`); const blob = await res.blob(); const src = URL.createObjectURL(blob); return { src, revoke: () => URL.revokeObjectURL(src) }; } export async function gql( query: string, variables?: Record, signal?: AbortSignal, ): Promise { const tryRefreshAndRetry = async (): Promise => { const mode = store.settings.serverAuthMode ?? "NONE"; if (mode !== "UI_LOGIN" || boot.skipped) return null; const refreshed = await refreshUiAccessToken(true); if (!refreshed) return null; if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); return attempt(); }; const attempt = async (): Promise => { const res = await fetchWithRetry( `${getServerUrl()}/api/graphql`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) }, signal, ); if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (!res.ok) { if (res.status === 401 || res.status === 403) { const retried = await tryRefreshAndRetry(); if (retried) return retried; } throw new Error(`Suwayomi HTTP ${res.status}`); } const json: GQLResponse = await res.json(); if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (json.errors?.length) { const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message)); if (isAuthError && !boot.skipped) { const retried = await tryRefreshAndRetry(); if (retried) return retried; boot.sessionExpired = true; boot.loginRequired = true; boot.loginUser = store.settings.serverAuthUser ?? ""; await waitForReauth(); if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); return attempt(); } throw new Error(json.errors[0].message); } return json.data; }; return attempt(); }