87 lines
2.5 KiB
TypeScript
87 lines
2.5 KiB
TypeScript
// Central fetch wrapper: the access token is kept in memory only (never
|
|
// localStorage/sessionStorage, per the auth design), and a 401 triggers a
|
|
// single silent refresh-and-retry via the httpOnly refresh cookie.
|
|
let accessToken: string | null = null;
|
|
|
|
export function setAccessToken(token: string | null): void {
|
|
accessToken = token;
|
|
}
|
|
|
|
export function getAccessToken(): string | null {
|
|
return accessToken;
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
constructor(message: string, status: number) {
|
|
super(message);
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
const NO_RETRY_PATHS = ["/api/auth/admin/refresh", "/api/auth/admin/login"];
|
|
|
|
async function rawFetch(path: string, init: RequestInit): Promise<Response> {
|
|
const headers = new Headers(init.headers);
|
|
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
|
if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
|
|
headers.set("Content-Type", "application/json");
|
|
}
|
|
return fetch(path, { ...init, headers, credentials: "include" });
|
|
}
|
|
|
|
let refreshInFlight: Promise<boolean> | null = null;
|
|
|
|
async function refreshAccessToken(): Promise<boolean> {
|
|
if (!refreshInFlight) {
|
|
refreshInFlight = (async () => {
|
|
try {
|
|
const res = await fetch("/api/auth/admin/refresh", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) return false;
|
|
const data = (await res.json()) as { access_token: string };
|
|
setAccessToken(data.access_token);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
refreshInFlight = null;
|
|
}
|
|
})();
|
|
}
|
|
return refreshInFlight;
|
|
}
|
|
|
|
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
let res = await rawFetch(path, init);
|
|
|
|
if (res.status === 401 && !NO_RETRY_PATHS.includes(path)) {
|
|
const refreshed = await refreshAccessToken();
|
|
if (refreshed) {
|
|
res = await rawFetch(path, init);
|
|
}
|
|
}
|
|
|
|
if (!res.ok) {
|
|
let message = `Request failed with status ${res.status}`;
|
|
try {
|
|
const body = (await res.json()) as { error?: string };
|
|
if (body?.error) message = body.error;
|
|
} catch {
|
|
// response had no JSON body
|
|
}
|
|
throw new ApiError(message, res.status);
|
|
}
|
|
|
|
if (res.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
export function apiUpload<T>(path: string, formData: FormData): Promise<T> {
|
|
return apiFetch<T>(path, { method: "POST", body: formData });
|
|
}
|