303 lines
7.8 KiB
TypeScript
303 lines
7.8 KiB
TypeScript
import createClient from "openapi-fetch";
|
|
import createQueryClient from "openapi-react-query";
|
|
|
|
import type { paths } from "./generated/schema";
|
|
|
|
import { ApiErrorCode, apiErrorCode, isApiErrorCode } 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<boolean>;
|
|
/**
|
|
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<Response> {
|
|
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<Response> {
|
|
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 [
|
|
ApiErrorCode.InvalidTbankToken,
|
|
ApiErrorCode.NotFound,
|
|
ApiErrorCode.TbankNotConnected,
|
|
ApiErrorCode.Unauthorized,
|
|
].some(permanent => isApiErrorCode(code, permanent));
|
|
}
|
|
|
|
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<ReadAttempt> {
|
|
const controller = new AbortController();
|
|
const failure: { reason?: "abort" | "timeout" } = {};
|
|
|
|
const { promise: deadline, reject: rejectDeadline }
|
|
= Promise.withResolvers<never>();
|
|
|
|
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<void> {
|
|
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<paths>({
|
|
baseUrl: apiBaseUrl,
|
|
credentials: "include",
|
|
fetch: authenticatedFetch,
|
|
});
|
|
|
|
const apiQueryClient = createQueryClient(apiClient);
|
|
|
|
export {
|
|
apiClient,
|
|
apiQueryClient,
|
|
configureAuthClient,
|
|
hasAuthenticatedSession as getAuthenticatedSessionSnapshot,
|
|
subscribeAuthenticatedSession,
|
|
};
|