/** * Typed client for the fancy-dns admin API. * * Every path is relative (`/api/...`) because the Go binary serves the built * assets and the API from the same origin; in dev Vite proxies `/api` to the * local daemon. */ import type { BlocklistsResponse, CacheResponse, CacheTopResponse, ClientsResponse, ClientGroup, Dashboard, DefaultPolicy, DomainAction, DomainCheck, DomainFilters, DomainsResponse, LogFilters, LogItem, LoginResponse, LogsResponse, LiveStats, OkResponse, SaveSettingsResponse, SettingsResponse, UpstreamBootstrap, UpstreamPolicy, UpstreamServer, UpstreamsResponse, ValidateResponse, } from './types.ts' const TOKEN_KEY = 'fancydns.token' /** An HTTP-level failure. `body` is the raw response text for the logs. */ export class ApiError extends Error { status: number body: string constructor(status: number, message: string, body = '') { super(message) this.name = 'ApiError' this.status = status this.body = body } } export function getToken(): string { try { return localStorage.getItem(TOKEN_KEY) ?? '' } catch { // Private mode / disabled storage: behave as an anonymous session. return '' } } export function setToken(token: string): void { try { localStorage.setItem(TOKEN_KEY, token) } catch { /* non-fatal: the session just does not survive a reload */ } } export function clearToken(): void { try { localStorage.removeItem(TOKEN_KEY) } catch { /* ignore */ } } /** Types the API returns for endpoints the shared types.ts does not name. */ export interface WhoamiResponse { name: string mode: string read_only: boolean can_write: boolean auth_mode: string base_path: string } export interface LoginInput { api_key?: string username?: string password?: string } export interface RefreshBlocklistsResponse { started: boolean names: string[] } export interface ToggleBlocklistResponse { ok: boolean name: string enabled: boolean } export interface AddDomainInput { name: string action: DomainAction | string list?: string source?: string content?: string } /** A lookup failure comes back 200 with an `error` string, not a 4xx. */ export type DomainCheckResponse = DomainCheck & { error?: string } export interface ClientsPayload { groups: ClientGroup[] default_policy: DefaultPolicy | string } export interface UpstreamsPayload { policy: UpstreamPolicy | string servers: UpstreamServer[] bootstraps: UpstreamBootstrap[] healthcheck_interval_s?: number max_attempts?: number breaker_threshold?: number } export interface SaveResponse { ok: boolean version: string warnings?: string[] } export interface FlushCacheResponse { ok: boolean entries: number } export interface ReloadSettingsResponse { ok: boolean version: string } export type LogStreamHandlers = { onEntry: (entry: LogItem) => void onStats: (stats: LiveStats) => void onReady: (info: { at?: string; version?: string }) => void onError: (message: string) => void onOpen: () => void } /** Query values the server should not see at all. */ type ParamValue = string | number | boolean | null | undefined /** Builds `/api/thing?a=1&b=2`, dropping empty values. */ export function apiUrl(path: string, params?: Record): string { if (!params) return path const search = new URLSearchParams() for (const [key, value] of Object.entries(params)) { if (value === null || value === undefined) continue if (typeof value === 'string' && value === '') continue search.set(key, String(value)) } const qs = search.toString() return qs ? `${path}?${qs}` : path } /** One redirect per session, however many requests race into a 401. */ let redirecting = false function sendToLogin(): void { if (redirecting) return redirecting = true const next = typeof location === 'undefined' ? '' : location.pathname location.assign(`/auth/login?next=${encodeURIComponent(next)}`) } function isLoginPath(path: string): boolean { return path.includes('/auth/login') } async function errorMessage(res: Response): Promise<{ message: string; body: string }> { let body = '' try { body = await res.text() } catch { body = '' } let message = body if (body) { try { const parsed: unknown = JSON.parse(body) if (parsed && typeof parsed === 'object' && 'error' in parsed) { const err = (parsed as { error: unknown }).error if (typeof err === 'string' && err !== '') message = err } } catch { /* not JSON: show the raw text */ } } if (message === '') message = res.statusText || `HTTP ${res.status}` return { message, body } } /** * `fetch` with JSON conveniences, the API key header and a single 401 * redirect. Non-2xx always throws so callers can surface the message. */ export async function apiFetch(path: string, init?: RequestInit): Promise { const headers = new Headers(init?.headers) if (!headers.has('Accept')) headers.set('Accept', 'application/json') const hasBody = init?.body !== undefined && init?.body !== null if (hasBody && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json') } const token = getToken() if (token && !headers.has('X-API-Key')) headers.set('X-API-Key', token) let res: Response try { res = await fetch(path, { ...init, headers, credentials: 'same-origin' }) } catch (err) { // Network failure, DNS, offline, or an aborted request from a unmounted // component: never surface AbortError noise to the user. if (err instanceof DOMException && err.name === 'AbortError') throw err const message = err instanceof Error ? err.message : 'network request failed' throw new ApiError(0, `cannot reach the fancy-dns API: ${message}`) } if (res.status === 401) { // A failed login is not a dead session: do not bounce the login page to // itself. if (!isLoginPath(path)) { clearToken() sendToLogin() } const { message, body } = await errorMessage(res) throw new ApiError(401, message, body) } if (!res.ok) { const { message, body } = await errorMessage(res) throw new ApiError(res.status, message, body) } if (res.status === 204 || res.headers.get('Content-Length') === '0') { return undefined as T } const text = await res.text() if (text === '') return undefined as T try { return JSON.parse(text) as T } catch { throw new ApiError(res.status, 'the API returned a malformed response', text) } } /** Percent-encodes a single path segment. */ function seg(value: string): string { return encodeURIComponent(value) } export async function login(body: LoginInput): Promise { const res = await apiFetch('/api/auth/login', { method: 'POST', body: JSON.stringify(body), }) if (res?.token) setToken(res.token) return res } export async function logout(): Promise { try { return await apiFetch('/api/auth/logout', { method: 'POST' }) } finally { clearToken() } } export function whoami(): Promise { return apiFetch('/api/whoami') } export function getDashboard(range: string): Promise { return apiFetch(apiUrl('/api/dashboard', { range })) } export function getBlocklists(): Promise { return apiFetch('/api/blocklists') } export async function refreshBlocklists(names?: string[]): Promise { if (names && names.length > 0) { return apiFetch('/api/blocklists/refresh', { method: 'POST', body: JSON.stringify({ names }), }) } return apiFetch('/api/blocklists/refresh', { method: 'POST' }) } export function toggleBlocklist( name: string, enabled: boolean, ): Promise { return apiFetch('/api/blocklists/toggle', { method: 'POST', body: JSON.stringify({ name, enabled }), }) } export function getDomains(filters: DomainFilters = {}): Promise { return apiFetch(apiUrl('/api/domains', { ...filters })) } export function addDomain(input: AddDomainInput): Promise { return apiFetch('/api/domains', { method: 'POST', body: JSON.stringify(input), }) } export function deleteDomain(name: string, action?: string, list?: string): Promise { return apiFetch(apiUrl(`/api/domains/${seg(name)}`, { action, list }), { method: 'DELETE', }) } export function checkDomain(name: string): Promise { return apiFetch(apiUrl('/api/domains/check', { name })) } export function getClients(): Promise { return apiFetch('/api/clients') } export function putClients(payload: ClientsPayload): Promise { return apiFetch('/api/clients', { method: 'PUT', body: JSON.stringify(payload), }) } export function getUpstreams(): Promise { return apiFetch('/api/upstreams') } export function putUpstreams(payload: UpstreamsPayload): Promise { return apiFetch('/api/upstreams', { method: 'PUT', body: JSON.stringify(payload), }) } export function getLogs(filters: LogFilters = {}): Promise { return apiFetch(apiUrl('/api/logs', { ...filters })) } export function clearLogs(): Promise { return apiFetch('/api/logs/clear', { method: 'POST' }) } export function getCache(): Promise { return apiFetch('/api/cache') } export function getCacheTop(limit = 50): Promise { return apiFetch(apiUrl('/api/cache/top', { limit })) } export function flushCache(): Promise { return apiFetch('/api/cache/flush', { method: 'POST' }) } export function getSettings(): Promise { return apiFetch('/api/settings') } export function putSettings(yaml: string): Promise { return apiFetch('/api/settings', { method: 'PUT', body: JSON.stringify({ config_yaml: yaml }), }) } export function validateSettings(yaml: string): Promise { return apiFetch('/api/settings/validate', { method: 'POST', body: JSON.stringify({ config_yaml: yaml }), }) } export function reloadSettings(): Promise { return apiFetch('/api/settings/reload', { method: 'POST' }) } const STREAM_PATH = '/api/logs/stream' const RECONNECT_MIN_MS = 1000 const RECONNECT_MAX_MS = 15000 /** * Subscribes to the live query log. * * Native `EventSource` cannot send the `X-API-Key` header and the daemon * accepts no cookie, so the stream is read with `fetch` plus a minimal SSE * parser. That costs a hand-rolled reconnect, which is what EventSource would * have given us for free; the handler contract is unchanged. * * Returns a teardown function that is safe to call more than once. */ export function streamLogs(handlers: LogStreamHandlers): () => void { const controller = new AbortController() let stopped = false let backoff = RECONNECT_MIN_MS let timer: ReturnType | undefined let attempt = 0 // One error report per outage streak: a flaky link must not toast every 15s. let reported = false const open = (): void => { if (stopped) return void run() } const run = async (): Promise => { if (stopped) return const token = getToken() const headers = new Headers({ Accept: 'text/event-stream' }) if (token) headers.set('X-API-Key', token) try { const res = await fetch(STREAM_PATH, { headers, credentials: 'same-origin', signal: controller.signal, cache: 'no-store', }) if (res.status === 401) { clearToken() sendToLogin() handlers.onError('unauthorized: the live log stream needs a valid session') return } if (!res.ok || !res.body) { const message = res.ok ? 'the live log stream is not supported by this browser' : `the live log stream failed with HTTP ${res.status}` if (!reported) { reported = true handlers.onError(message) } schedule() return } reported = false attempt = 0 backoff = RECONNECT_MIN_MS handlers.onOpen() await readSse(res.body, handlers, controller.signal) if (stopped) return // A clean end of stream still means the connection is gone. schedule() } catch (err) { if (stopped) return if (controller.signal.aborted) return const message = err instanceof Error ? err.message : 'the live log stream dropped' if (!reported) { reported = true handlers.onError(message) } schedule() } } const schedule = (): void => { if (stopped) return attempt += 1 const delay = Math.min(RECONNECT_MAX_MS, backoff) backoff = Math.min(RECONNECT_MAX_MS, Math.round(backoff * 1.8)) timer = setTimeout(open, delay) } open() return () => { if (stopped) return stopped = true if (timer !== undefined) clearTimeout(timer) controller.abort() } } /** Splits an SSE byte stream into `event:`/`data:` frames. */ async function readSse( body: ReadableStream, handlers: LogStreamHandlers, signal: AbortSignal, ): Promise { const reader = body.getReader() const decoder = new TextDecoder() let buffer = '' const handle = (frame: string): void => { let event = 'message' const data: string[] = [] for (const rawLine of frame.split('\n')) { const line = rawLine.replace(/\r$/, '') if (line === '' || line.startsWith(':')) continue if (line.startsWith('event:')) event = line.slice(6).trim() else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, '')) } if (data.length === 0) return let payload: unknown try { payload = JSON.parse(data.join('\n')) } catch { return } if (event === 'entry') { handlers.onEntry(payload as LogItem) } else if (event === 'stats') { handlers.onStats(payload as LiveStats) } else if (event === 'ready') { handlers.onReady(payload as { at?: string; version?: string }) } } try { for (;;) { if (signal.aborted) return const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) let split = buffer.indexOf('\n\n') while (split !== -1) { const frame = buffer.slice(0, split) buffer = buffer.slice(split + 2) handle(frame) split = buffer.indexOf('\n\n') } } if (buffer.trim() !== '') handle(buffer) } finally { try { reader.releaseLock() } catch { /* already released by the abort */ } } }