import createClient from "openapi-fetch"; import createQueryClient from "openapi-react-query"; import type { paths } from "./generated/schema"; import { ApiErrorCode, apiErrorCode, isApiErrorCode, isBrokerageAccessLoss, } from "./problem-details"; const HTTP_UNAUTHORIZED = 401; const HTTP_REQUEST_TIMEOUT = 408; const HTTP_TOO_MANY_REQUESTS = 429; const HTTP_INTERNAL_SERVER_ERROR = 500; const HTTP_BAD_GATEWAY = 502; const HTTP_SERVICE_UNAVAILABLE = 503; const HTTP_GATEWAY_TIMEOUT = 504; const GET_DEADLINE_MS = 15_000; const FIRST_RETRY_DELAY_MS = 1000; const SECOND_RETRY_DELAY_MS = 2000; const RETRY_DELAYS_MS = [FIRST_RETRY_DELAY_MS, SECOND_RETRY_DELAY_MS] as const; const SECONDS_TO_MS = 1000; 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; /** True after a refresh failed transiently (network/5xx): the session is not proven gone. */ unavailable?: () => boolean; }; type ReadAttempt = { problem?: unknown; reason: "abort" | "transient"; response?: Response; }; const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = { hasSession: false, }; const authSessionListeners = new Set<() => void>(); function abortError() { return new DOMException("Request aborted", "AbortError"); } async function authenticatedFetch(request: Request): 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); 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 fetchAppRead(authorized, isAuthRequest); 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 (!isApiErrorCode(apiErrorCode(problem), ApiErrorCode.Unauthorized)) return response; if (!isCurrent()) throw new DOMException("Session changed", "AbortError"); if (!(await sessionState.handlers.refresh())) { if (isCurrent() && !sessionState.handlers.unavailable?.()) 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 fetchAppRead( withToken(retryRequest, refreshedToken), isAuthRequest, ); if (!isCurrent()) throw new DOMException("Session changed", "AbortError"); return retried; } function configureAuthClient(handlers?: AuthHandlers) { sessionState.handlers = handlers; setAuthenticatedSession(Boolean(sessionState.handlers?.accessToken())); } async function fetchAppRead( request: Request, isAuthRequest: boolean, ): Promise { if (isAuthRequest || request.method !== "GET") return fetch(request); const retryableRequest = request.clone(); for (const [retryIndex, delayMs] of RETRY_DELAYS_MS.entries()) { const attempt = await readAttempt( retryIndex === 0 ? request : retryableRequest.clone(), ); if (attempt.reason === "abort") throw abortError(); const response = attempt.response; if (response && !isTransientReadFailure(response, attempt.problem)) return response; await waitForRetry(delayMs, request.signal); } const finalAttempt = await readAttempt(retryableRequest.clone()); if (finalAttempt.reason === "abort") throw abortError(); if (!finalAttempt.response) throw new TypeError("Network request failed"); return finalAttempt.response; } function hasAuthenticatedSession() { return sessionState.hasSession; } function isNetworkFailure(error: unknown): boolean { return error instanceof TypeError; } function isPermanentCode(code?: string): boolean { return isBrokerageAccessLoss(code) || isApiErrorCode(code, ApiErrorCode.Unauthorized); } function isTransientReadFailure( response: Response | undefined, problem: unknown, ): boolean { if (!response) return true; const code = apiErrorCode(problem); if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable)) return true; if (isPermanentCode(code)) return false; const transientStatuses = [ HTTP_BAD_GATEWAY, HTTP_GATEWAY_TIMEOUT, HTTP_INTERNAL_SERVER_ERROR, HTTP_REQUEST_TIMEOUT, HTTP_SERVICE_UNAVAILABLE, ]; return transientStatuses.includes(response.status) || ( response.status === HTTP_TOO_MANY_REQUESTS && retryAfterMs(response) === 0 ); } function parseJson(body: string): unknown { try { return JSON.parse(body); } catch { return undefined; } } async function readAttempt(request: Request): Promise { const controller = new AbortController(); const failure: { reason?: "abort" | "timeout" } = {}; const { promise: deadline, reject: rejectDeadline } = Promise.withResolvers(); const timeout = setTimeout(() => { failure.reason = "timeout"; controller.abort(); rejectDeadline(abortError()); }, GET_DEADLINE_MS); const abort = () => { failure.reason = "abort"; controller.abort(); rejectDeadline(abortError()); }; request.signal.addEventListener("abort", abort, { once: true }); const timedRequest = new Request(request, { signal: controller.signal }); try { const response = await Promise.race([fetch(timedRequest), deadline]); const body = await Promise.race([response.text(), deadline]); const problem = parseJson(body); return { problem, reason: "transient", response: new Response(body, response), }; } catch (error) { if (failure.reason === "abort") return { reason: "abort" }; if (failure.reason === "timeout" || isNetworkFailure(error)) return { reason: "transient" }; throw error; } finally { clearTimeout(timeout); request.signal.removeEventListener("abort", abort); } } function retryAfterMs(response: Response): number | undefined { const header = response.headers.get("Retry-After"); if (!header) return undefined; const seconds = Number(header); return Number.isFinite(seconds) ? seconds * SECONDS_TO_MS : undefined; } 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); } function waitForRetry(ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(resolve, ms); const abort = () => { clearTimeout(timeout); reject(abortError()); }; signal.addEventListener("abort", abort, { once: true }); }); } const apiClient = createClient({ baseUrl: apiBaseUrl, credentials: "include", fetch: authenticatedFetch, }); const apiQueryClient = createQueryClient(apiClient); export { apiClient, apiQueryClient, configureAuthClient, hasAuthenticatedSession as getAuthenticatedSessionSnapshot, subscribeAuthenticatedSession, };