codev / desktop /src /api /client.ts
chenbhao's picture
feat: desktop
1f21206
Raw
History Blame
4.91 kB
const ENV_BASE_URL =
typeof import.meta !== 'undefined' &&
typeof import.meta.env?.VITE_DESKTOP_SERVER_URL === 'string' &&
import.meta.env.VITE_DESKTOP_SERVER_URL.length > 0
? import.meta.env.VITE_DESKTOP_SERVER_URL
: undefined
const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456'
let baseUrl = DEFAULT_BASE_URL
let authToken: string | null = null
const DIAGNOSTICS_PATH = '/api/diagnostics/events'
function getErrorMessage(status: number, body: unknown) {
if (body && typeof body === 'object' && 'message' in body && typeof body.message === 'string') {
return body.message
}
if (typeof body === 'string' && body.trim().length > 0) {
return body
}
return `API error ${status}`
}
export function setBaseUrl(url: string) {
baseUrl = url.replace(/\/$/, '')
}
export function getBaseUrl() {
return baseUrl
}
export function getApiUrl(pathOrUrl: string) {
try {
return new URL(pathOrUrl).toString()
} catch {
const normalizedPath = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`
return `${baseUrl}${normalizedPath}`
}
}
export function setAuthToken(token: string | null) {
const trimmed = token?.trim() ?? ''
authToken = trimmed.length > 0 ? trimmed : null
}
export function getAuthToken() {
return authToken
}
export function getDefaultBaseUrl() {
return DEFAULT_BASE_URL
}
export function hasExplicitDefaultBaseUrl() {
return Boolean(ENV_BASE_URL)
}
export class ApiError extends Error {
constructor(
public status: number,
public body: unknown,
) {
super(getErrorMessage(status, body))
this.name = 'ApiError'
}
}
async function request<T>(method: string, path: string, body?: unknown, options?: { timeout?: number }): Promise<T> {
const url = `${baseUrl}${path}`
const headers = buildHeaders()
const controller = new AbortController()
const timeoutMs = options?.timeout ?? 30_000
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
clearTimeout(timeout)
if (!res.ok) {
const errorBody = await res.json().catch(() => res.text())
throw new ApiError(res.status, errorBody)
}
if (res.status === 204) return undefined as T
return res.json() as Promise<T>
} catch (err) {
clearTimeout(timeout)
if (controller.signal.aborted) {
const timeoutError = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)
reportApiFailure(method, path, timeoutError)
throw timeoutError
}
reportApiFailure(method, path, err)
throw err
}
}
function reportApiFailure(method: string, path: string, error: unknown) {
if (path.startsWith('/api/diagnostics')) return
const details: Record<string, unknown> = {
method,
path,
errorName: error instanceof Error ? error.name : typeof error,
message: sanitizeDiagnosticValue(error instanceof Error ? error.message : String(error)),
}
if (error instanceof ApiError) {
details.status = error.status
details.response = sanitizeDiagnosticValue(error.body)
}
void rawRecordDiagnosticEvent({
type: 'client_api_request_failed',
severity: 'warn',
summary: `${method} ${path} failed: ${details.message}`,
details,
})
}
export function rawRecordDiagnosticEvent(event: {
type: string
severity?: 'debug' | 'info' | 'warn' | 'error'
summary: string
sessionId?: string
details?: unknown
}) {
return fetch(`${baseUrl}${DIAGNOSTICS_PATH}`, {
method: 'POST',
headers: buildHeaders(),
body: JSON.stringify(event),
}).catch(() => undefined)
}
function buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (authToken) {
headers.Authorization = `Bearer ${authToken}`
}
return headers
}
function sanitizeDiagnosticValue(value: unknown): unknown {
if (!authToken) return value
if (typeof value === 'string') {
return value.split(authToken).join('[redacted]')
}
if (Array.isArray(value)) {
return value.map((entry) => sanitizeDiagnosticValue(entry))
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, sanitizeDiagnosticValue(entry)]),
)
}
return value
}
export const api = {
get: <T>(path: string, options?: { timeout?: number }) => request<T>('GET', path, undefined, options),
post: <T>(path: string, body?: unknown, options?: { timeout?: number }) => request<T>('POST', path, body, options),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
}