Frontend verify / verify (pull_request) Canceled after 0s
Frontend security / security (pull_request) Canceled after 0s
Frontend security / security (push) Canceled after 0s
Frontend verify / verify (push) Canceled after 0s
Conventional commits / commits (pull_request) Canceled after 0s
Use owner enums for known codes; preserve wire values and unknown fallbacks. Verified npm ci and verify: 379 tests, 5 lint checks, app and Storybook builds.
150 lines
4.6 KiB
TypeScript
150 lines
4.6 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 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>;
|
|
};
|
|
|
|
const inFlight = new Set<{ invalidated: boolean; pathname: string }>();
|
|
const latestReads = new Map<string, { pending: number; sequence: number }>();
|
|
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<Response> {
|
|
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<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) && 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 (!isApiErrorCode(apiErrorCode(problem), ApiErrorCode.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<paths>({
|
|
baseUrl: apiBaseUrl,
|
|
credentials: "include",
|
|
fetch: authenticatedFetch,
|
|
});
|
|
|
|
const apiQueryClient = createQueryClient(apiClient);
|
|
|
|
export {
|
|
apiClient,
|
|
apiQueryClient,
|
|
configureAuthClient,
|
|
hasAuthenticatedSession as getAuthenticatedSessionSnapshot,
|
|
invalidateApiRequests,
|
|
subscribeAuthenticatedSession,
|
|
};
|