86 lines
3.3 KiB
TypeScript
86 lines
3.3 KiB
TypeScript
import { plural } from '#lib/utils/plural.js';
|
|
export type ApiError = {
|
|
code: string;
|
|
message: string;
|
|
status: number;
|
|
/** Seconds until a 429'd request may be retried, from Retry-After. */
|
|
retryAfter?: number;
|
|
};
|
|
|
|
/**
|
|
* The sign-in, register and reset screens' wording for a throttled attempt,
|
|
* or null when err isn't one. Rounds up to whole minutes: the server's
|
|
* windows are minutes long, and "try again in 1 second" after a lockout
|
|
* would be untrue the moment it was read.
|
|
*/
|
|
export function rateLimitMessage(err: unknown): string | null {
|
|
const apiErr = err as ApiError | undefined;
|
|
if (apiErr?.status !== 429) return null;
|
|
const secs = apiErr.retryAfter;
|
|
if (!secs || secs <= 0) return 'Too many attempts. Try again in a few minutes.';
|
|
const mins = Math.ceil(secs / 60);
|
|
return `Too many attempts. Try again in ${plural(mins, 'minute', 'minutes')}.`;
|
|
}
|
|
|
|
export type User = {
|
|
id: string;
|
|
username: string;
|
|
is_admin: boolean;
|
|
};
|
|
|
|
export type LoginResponse = {
|
|
token: string;
|
|
user: User;
|
|
};
|
|
|
|
export async function apiFetch(path: string, init?: RequestInit): Promise<unknown> {
|
|
const res = await fetch(path, {
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
|
...init
|
|
});
|
|
const body = res.status === 204 ? null : await res.json().catch(() => null);
|
|
if (!res.ok) {
|
|
if (res.status === 401) {
|
|
// Lazy import: auth/store imports from this file, so a top-level
|
|
// import would be circular. By the time any 401 actually happens
|
|
// at runtime, both modules have finished loading.
|
|
const { logout } = await import('#lib/auth/store.svelte.js');
|
|
await logout({ silent: true });
|
|
}
|
|
// Two error envelope shapes ship in this codebase today:
|
|
// 1) {"error": {"code": "...", "message": "..."}} — non-admin endpoints
|
|
// 2) {"error": "code_string"} — admin endpoints
|
|
// Without handling (2), every admin error toast renders 'unknown'.
|
|
const errorField = body && (body as { error?: unknown }).error;
|
|
let code = 'unknown';
|
|
let message = res.statusText;
|
|
if (typeof errorField === 'string') {
|
|
code = errorField;
|
|
} else if (errorField && typeof errorField === 'object') {
|
|
const env = errorField as { code?: string; message?: string };
|
|
code = env.code ?? 'unknown';
|
|
message = env.message ?? res.statusText;
|
|
}
|
|
const err: ApiError = { code, message, status: res.status };
|
|
if (res.status === 429) {
|
|
const retryAfter = Number(res.headers.get('Retry-After'));
|
|
if (Number.isFinite(retryAfter) && retryAfter > 0) err.retryAfter = retryAfter;
|
|
}
|
|
throw err;
|
|
}
|
|
return body;
|
|
}
|
|
|
|
export const api = {
|
|
get: <T>(path: string): Promise<T> => apiFetch(path) as Promise<T>,
|
|
post: <T>(path: string, body: unknown): Promise<T> =>
|
|
apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise<T>,
|
|
put: <T>(path: string, body: unknown): Promise<T> =>
|
|
apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }) as Promise<T>,
|
|
patch: <T>(path: string, body: unknown): Promise<T> =>
|
|
apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }) as Promise<T>,
|
|
del: <T = null>(path: string): Promise<T> =>
|
|
apiFetch(path, { method: 'DELETE' }) as Promise<T>
|
|
};
|