import createClient from "openapi-fetch"; import createQueryClient from "openapi-react-query"; import type { paths } from "./generated/schema"; import { apiErrorCode } from "./problem-details"; const HTTP_UNAUTHORIZED = 401; const configuredBaseUrl: unknown = import.meta.env.VITE_API_BASE_URL; const apiBaseUrl = typeof configuredBaseUrl === "string" ? configuredBaseUrl : "http://localhost:3000"; type AuthHandlers = { accessToken: () => null | string | undefined; clear: () => void; generation?: () => number; refresh: () => Promise; }; const inFlight = new Set<{ invalidated: boolean; pathname: string }>(); const latestReads = new Map(); function invalidateApiRequests(isMatches: (pathname: string) => boolean) { for (const request of inFlight) if (isMatches(request.pathname)) request.invalidated = true; } const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = { hasSession: false }; const authSessionListeners = new Set<() => void>(); async function authenticatedFetch(request: Request): Promise { const url = new URL(request.url); url.searchParams.delete("sync"); url.searchParams.sort(); const key = `${request.method} ${url}`; const ticket = { invalidated: false, pathname: url.pathname }; const read = request.method === "GET" ? (latestReads.get(key) ?? { pending: 0, sequence: 0 }) : undefined; if (read) { read.sequence += 1; read.pending += 1; latestReads.set(key, read); } const sequence = read?.sequence; inFlight.add(ticket); try { return await fetchCurrent( request, () => !ticket.invalidated && (!read || sequence === read.sequence), ); } finally { inFlight.delete(ticket); if (read && --read.pending === 0) latestReads.delete(key); } } function configureAuthClient(handlers?: AuthHandlers) { if (!handlers) invalidateApiRequests(() => true); sessionState.handlers = handlers; setAuthenticatedSession(Boolean(sessionState.handlers?.accessToken())); } async function fetchCurrent( request: Request, isLatest: () => boolean, ): Promise { const isAuthRequest = new URL(request.url).pathname.startsWith( "/api/v1/auth/", ); const session = sessionState.handlers; const generation = session?.generation?.(); const isCurrent = () => (session?.generation ? generation === session.generation() && generation === sessionState.handlers?.generation?.() : session === sessionState.handlers) && isLatest(); const token = session?.accessToken(); const withToken = (source: Request, value: string) => { const headers = new Headers(source.headers); headers.set("Authorization", `Bearer ${value}`); return new Request(source, { headers }); }; const authorized = token && !isAuthRequest ? withToken(request, token) : request; const retryRequest = authorized.clone(); const response = await fetch(authorized); await response.clone().arrayBuffer(); if (!isAuthRequest && !isCurrent()) throw new DOMException("Session changed", "AbortError"); if (isAuthRequest || !sessionState.handlers || response.status !== HTTP_UNAUTHORIZED) return response; let problem: unknown; try { problem = await response.clone().json(); } catch { return response; } if (apiErrorCode(problem) !== "UNAUTHORIZED") return response; if (!isCurrent()) throw new DOMException("Session changed", "AbortError"); if (!(await sessionState.handlers.refresh())) { if (isCurrent()) sessionState.handlers.clear(); return response; } if (!isCurrent()) throw new DOMException("Session changed", "AbortError"); const refreshedToken = sessionState.handlers.accessToken(); if (!refreshedToken) return response; const retried = await fetch(withToken(retryRequest, refreshedToken)); await retried.clone().arrayBuffer(); if (!isCurrent()) throw new DOMException("Session changed", "AbortError"); return retried; } function hasAuthenticatedSession() { return sessionState.hasSession; } function setAuthenticatedSession(hasSession: boolean) { if (hasSession === sessionState.hasSession) return; sessionState.hasSession = hasSession; for (const listener of authSessionListeners) listener(); } function subscribeAuthenticatedSession(listener: () => void) { authSessionListeners.add(listener); return () => authSessionListeners.delete(listener); } const apiClient = createClient({ baseUrl: apiBaseUrl, credentials: "include", fetch: authenticatedFetch, }); const apiQueryClient = createQueryClient(apiClient); export { apiClient, apiQueryClient, configureAuthClient, hasAuthenticatedSession as getAuthenticatedSessionSnapshot, invalidateApiRequests, subscribeAuthenticatedSession, };