Files
fancy-dns/web/src/lib/api.ts
T
2026-09-27 11:34:26 +00:00

556 lines
15 KiB
TypeScript

/**
* 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, ParamValue>): 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<T>(path: string, init?: RequestInit): Promise<T> {
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<LoginResponse> {
const res = await apiFetch<LoginResponse>('/api/auth/login', {
method: 'POST',
body: JSON.stringify(body),
})
if (res?.token) setToken(res.token)
return res
}
export async function logout(): Promise<OkResponse> {
try {
return await apiFetch<OkResponse>('/api/auth/logout', { method: 'POST' })
} finally {
clearToken()
}
}
export function whoami(): Promise<WhoamiResponse> {
return apiFetch<WhoamiResponse>('/api/whoami')
}
export function getDashboard(range: string): Promise<Dashboard> {
return apiFetch<Dashboard>(apiUrl('/api/dashboard', { range }))
}
export function getBlocklists(): Promise<BlocklistsResponse> {
return apiFetch<BlocklistsResponse>('/api/blocklists')
}
export async function refreshBlocklists(names?: string[]): Promise<RefreshBlocklistsResponse> {
if (names && names.length > 0) {
return apiFetch<RefreshBlocklistsResponse>('/api/blocklists/refresh', {
method: 'POST',
body: JSON.stringify({ names }),
})
}
return apiFetch<RefreshBlocklistsResponse>('/api/blocklists/refresh', { method: 'POST' })
}
export function toggleBlocklist(
name: string,
enabled: boolean,
): Promise<ToggleBlocklistResponse> {
return apiFetch<ToggleBlocklistResponse>('/api/blocklists/toggle', {
method: 'POST',
body: JSON.stringify({ name, enabled }),
})
}
export function getDomains(filters: DomainFilters = {}): Promise<DomainsResponse> {
return apiFetch<DomainsResponse>(apiUrl('/api/domains', { ...filters }))
}
export function addDomain(input: AddDomainInput): Promise<OkResponse> {
return apiFetch<OkResponse>('/api/domains', {
method: 'POST',
body: JSON.stringify(input),
})
}
export function deleteDomain(name: string, action?: string, list?: string): Promise<OkResponse> {
return apiFetch<OkResponse>(apiUrl(`/api/domains/${seg(name)}`, { action, list }), {
method: 'DELETE',
})
}
export function checkDomain(name: string): Promise<DomainCheckResponse> {
return apiFetch<DomainCheckResponse>(apiUrl('/api/domains/check', { name }))
}
export function getClients(): Promise<ClientsResponse> {
return apiFetch<ClientsResponse>('/api/clients')
}
export function putClients(payload: ClientsPayload): Promise<SaveResponse> {
return apiFetch<SaveResponse>('/api/clients', {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export function getUpstreams(): Promise<UpstreamsResponse> {
return apiFetch<UpstreamsResponse>('/api/upstreams')
}
export function putUpstreams(payload: UpstreamsPayload): Promise<SaveResponse> {
return apiFetch<SaveResponse>('/api/upstreams', {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export function getLogs(filters: LogFilters = {}): Promise<LogsResponse> {
return apiFetch<LogsResponse>(apiUrl('/api/logs', { ...filters }))
}
export function clearLogs(): Promise<OkResponse> {
return apiFetch<OkResponse>('/api/logs/clear', { method: 'POST' })
}
export function getCache(): Promise<CacheResponse> {
return apiFetch<CacheResponse>('/api/cache')
}
export function getCacheTop(limit = 50): Promise<CacheTopResponse> {
return apiFetch<CacheTopResponse>(apiUrl('/api/cache/top', { limit }))
}
export function flushCache(): Promise<FlushCacheResponse> {
return apiFetch<FlushCacheResponse>('/api/cache/flush', { method: 'POST' })
}
export function getSettings(): Promise<SettingsResponse> {
return apiFetch<SettingsResponse>('/api/settings')
}
export function putSettings(yaml: string): Promise<SaveSettingsResponse> {
return apiFetch<SaveSettingsResponse>('/api/settings', {
method: 'PUT',
body: JSON.stringify({ config_yaml: yaml }),
})
}
export function validateSettings(yaml: string): Promise<ValidateResponse> {
return apiFetch<ValidateResponse>('/api/settings/validate', {
method: 'POST',
body: JSON.stringify({ config_yaml: yaml }),
})
}
export function reloadSettings(): Promise<ReloadSettingsResponse> {
return apiFetch<ReloadSettingsResponse>('/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<typeof setTimeout> | 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<void> => {
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<Uint8Array>,
handlers: LogStreamHandlers,
signal: AbortSignal,
): Promise<void> {
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 */
}
}
}