perfect-postcode/frontend/src/lib/api.ts

88 lines
2.9 KiB
TypeScript

import type { FeatureMeta, FeatureFilters } from '../types';
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
import pb from './pocketbase';
export function logNonAbortError(label: string, error: unknown): void {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
console.error(`${label}:`, error);
}
export function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
/** Throw if response is not 2xx. Call before `.json()`. */
export function assertOk(res: Response, label: string): void {
if (!res.ok) {
throw new Error(`${label}: HTTP ${res.status} ${res.statusText}`);
}
}
export function authHeaders(init?: RequestInit): RequestInit {
const headers: Record<string, string> = {};
if (pb.authStore.isValid && pb.authStore.token) {
headers['Authorization'] = `Bearer ${pb.authStore.token}`;
}
if (!init) return { headers };
const existing = init.headers as Record<string, string> | undefined;
return { ...init, headers: { ...existing, ...headers } };
}
export function apiUrl(endpoint: string, params?: URLSearchParams): string {
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
const query = params?.toString();
return query ? `${path}?${query}` : path;
}
export async function fetchWithRetry<T>(
url: string,
onSuccess: (data: T) => void,
signal: AbortSignal
): Promise<void> {
let delay = INITIAL_RETRY_MS;
while (!signal.aborted) {
try {
const res = await fetch(url, authHeaders({ signal }));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
onSuccess(json);
return;
} catch (err) {
if (signal.aborted) return;
console.error(`Failed to fetch ${url}, retrying in ${delay}ms:`, err);
await new Promise((resolve) => setTimeout(resolve, delay));
delay = Math.min(delay * 2, MAX_RETRY_MS);
}
}
}
export async function shortenUrl(params: string): Promise<string> {
const res = await fetch(apiUrl('shorten'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ params }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return `${window.location.origin}${data.url}`;
}
export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[], exclude?: string): string {
const entries = Object.entries(filters);
if (entries.length === 0) return '';
return entries
.filter(([name]) => name !== exclude)
.map(([name, value]) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
return `${name}:${(value as string[]).join('|')}`;
}
const [min, max] = value as [number, number];
const maxStr = meta?.absolute && max === meta.max ? 'inf' : String(max);
return `${name}:${min}:${maxStr}`;
})
.join(';;');
}