/**
* Thin typed HTTP client. The extension uses it with a bearer token; the web
* panel uses it same-origin with cookie auth (credentials: include).
*/
export class ApiError extends Error {
status: number;
detail: unknown;
constructor(status: number, detail: unknown) {
super(`API error ${status}: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
this.status = status;
this.detail = detail;
}
}
export interface ApiClientOptions {
/** Base URL, e.g. "http://localhost:8001" or "" for same-origin. */
baseUrl?: string;
/** Bearer token (extension mode). Omit to rely on cookie auth. */
token?: string | null;
/** Called on 401 — hook to clear local session state. */
onUnauthorized?: () => void;
}
export interface HttpClient {
baseUrl: string;
get<T>(path: string): Promise<T>;
post<T>(path: string, body?: unknown): Promise<T>;
patch<T>(path: string, body?: unknown): Promise<T>;
del(path: string): Promise<void>;
/** Multipart upload; `file` is sent as the "file" field. */
upload<T>(path: string, file: File): Promise<T>;
/** Low-level fetch with auth headers applied. */
raw(path: string, init?: RequestInit): Promise<Response>;
}
export function createHttpClient(options: ApiClientOptions = {}): HttpClient {
const baseUrl = options.baseUrl ?? "";
async function raw(path: string, init: RequestInit = {}): Promise<Response> {
const headers = new Headers(init.headers);
if (options.token) headers.set("Authorization", `Bearer ${options.token}`);
if (init.body && !(init.body instanceof FormData) && !(init.body instanceof Blob)) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(baseUrl + path, {
credentials: options.token ? "omit" : "include",
...init,
headers,
});
if (response.status === 401) options.onUnauthorized?.();
return response;
}
async function parse<T>(response: Response): Promise<T> {
if (response.status === 204) return undefined as T;
const text = await response.text();
let data: unknown = undefined;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!response.ok) {
const detail = (data as { detail?: unknown } | undefined)?.detail ?? data;
throw new ApiError(response.status, detail);
}
return data as T;
}
return {
baseUrl,
raw,
get: <T,>(path: string) => raw(path).then((r) => parse<T>(r)),
post: <T,>(path: string, body?: unknown) =>
raw(path, { method: "POST", body: JSON.stringify(body ?? {}) }).then((r) => parse<T>(r)),
patch: <T,>(path: string, body?: unknown) =>
raw(path, { method: "PATCH", body: JSON.stringify(body ?? {}) }).then((r) => parse<T>(r)),
del: async (path: string) => {
await parse(await raw(path, { method: "DELETE" }));
},
upload: <T,>(path: string, file: File) => {
const form = new FormData();
form.append("file", file);
return raw(path, { method: "POST", body: form }).then((r) => parse<T>(r));
},
};
}