/** * @file dashboard-api-client.ts * @description Client for making API requests to the MCP dashboard. This client provides methods for sending HTTP requests (GET, POST, PUT, PATCH, DELETE) to the dashboard's API endpoints, with built-in support for retries on transient errors, request timeouts, and error handling. The client constructs URLs based on a base URL from the configuration and allows for query parameters and request bodies. It also defines a custom ApiError class for consistent error representation across the application. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../config/app-config.js` * - `../core/logger.js` * * ## Public surface * - `ApiError` — exported API; see TSDoc on the symbol for behavior. * - `DashboardApiClient` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **ApiError** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * **DashboardApiClient** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { setTimeout as sleep } from "node:timers/promises"; /** * Error type for every failed dashboard API call — non-2xx responses, * timeouts, and network failures all normalize to this shape. * {@link errorResult} surfaces `code`/`status`/`details` to the MCP client * instead of collapsing to a generic internal error. */ export class ApiError extends Error { status; /** Forwarded from the dashboard's error envelope, a synthesized * `HTTP_`, or this client's own code (`INVALID_PATH`, `TIMEOUT`, * `REQUEST_FAILED`, `UNREACHABLE_STATE`). */ code; details; constructor(message, options = {}) { super(message); this.name = "ApiError"; this.status = options.status; this.code = options.code; this.details = options.details; } } /** True for a DOM/Node `AbortError` from {@link DashboardApiClient.request}'s * per-attempt timeout controller. */ function isAbortError(error) { return (typeof error === "object" && error !== null && "name" in error && error.name === "AbortError"); } /** Statuses treated as transient/retryable: 408, 429, or any 5xx. */ function isRetryableStatus(status) { return status === 408 || status === 429 || status >= 500; } /** * Thin HTTP client every MCP tool handler uses to reach the dashboard's * local Express API — the sole network boundary of the server. Requests * resolve against `config.dashboardBaseUrl` and are restricted to `/api/*` * (see {@link buildUrl}). * * **Retry semantics**: only GET/DELETE mark themselves `idempotent`, so only * they retry automatically — `config.retryCount` extra attempts (default 2) * on a timeout or HTTP 408/429/5xx, each retry waiting * `config.retryBackoffMs * 2^(attempt-1)` (default 250ms, 500ms, ..., * exponential, no jitter). POST/PUT/PATCH are never retried, even for the * same transient statuses — a duplicated write is worse than one surfaced * failure. */ export class DashboardApiClient { config; logger; constructor(config, logger) { this.config = config; this.logger = logger; } /** GET — idempotent, eligible for automatic retry. */ async get(path, options = {}) { return this.request("GET", path, { ...options, idempotent: true }); } /** POST — never retried; used for creates and mutation-gated actions. */ async post(path, options = {}) { return this.request("POST", path, options); } /** PUT — full upsert semantics (e.g. pricing rules); never retried. */ async put(path, options = {}) { return this.request("PUT", path, options); } /** PATCH — partial update; never retried. */ async patch(path, options = {}) { return this.request("PATCH", path, options); } /** DELETE — idempotent, eligible for automatic retry. */ async delete(path, options = {}) { return this.request("DELETE", path, options); } /** * Resolves `path` against the dashboard base URL and applies query * params, enforcing that only `/api/*` paths can ever be requested — a * hard client-side allowlist independent of the dashboard's own routing. * @throws {ApiError} code `INVALID_PATH` if the resolved pathname doesn't * start with `/api/`. */ buildUrl(path, query) { const url = new URL(path, this.config.dashboardBaseUrl); if (!url.pathname.startsWith("/api/")) { throw new ApiError(`Invalid path "${path}". MCP client can only call /api/* endpoints.`, { code: "INVALID_PATH", }); } if (query) { for (const [key, value] of Object.entries(query)) { if (value !== undefined && value !== null) { url.searchParams.set(key, String(value)); } } } return url; } /** * Core request implementation shared by all five methods. Each attempt * gets its own {@link AbortController} armed with `config.requestTimeoutMs` * and best-effort JSON-parses the response (see {@link tryParseJson}). * `maxAttempts` is `config.retryCount + 1` when `options.idempotent`, * else `1`. On error, {@link shouldRetry} decides whether to back off and * loop or fall through to normalization: a non-ok response becomes an * {@link ApiError} via {@link toApiError}; an abort becomes `TIMEOUT`; any * other throw becomes `REQUEST_FAILED`. * @throws {ApiError} on any non-2xx response, timeout, or network failure * surviving the retry loop. */ async request(method, path, options) { const maxAttempts = options.idempotent ? this.config.retryCount + 1 : 1; const url = this.buildUrl(path, options.query); for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const abortController = new AbortController(); const timeout = setTimeout(() => abortController.abort(), this.config.requestTimeoutMs); try { const response = await fetch(url, { method, headers: { "Content-Type": "application/json", Accept: "application/json", }, body: options.body === undefined ? undefined : JSON.stringify(options.body), signal: abortController.signal, }); const rawBody = await response.text(); const body = rawBody ? this.tryParseJson(rawBody) : null; if (!response.ok) { throw this.toApiError(method, url, response.status, body ?? rawBody); } return body; } catch (error) { if (this.shouldRetry(error, attempt, maxAttempts)) { const backoffMs = this.config.retryBackoffMs * Math.pow(2, attempt - 1); this.logger.warn("Transient API error, retrying", { method, path: url.toString(), attempt, maxAttempts, backoffMs, error: this.getErrorMessage(error), }); await sleep(backoffMs); continue; } if (error instanceof ApiError) { throw error; } if (isAbortError(error)) { throw new ApiError(`Request timed out after ${this.config.requestTimeoutMs}ms: ${method} ${url.pathname}`, { code: "TIMEOUT" }); } throw new ApiError(`Request failed: ${method} ${url.pathname}`, { code: "REQUEST_FAILED", details: this.getErrorMessage(error), }); } finally { clearTimeout(timeout); } } throw new ApiError("Unreachable request state", { code: "UNREACHABLE_STATE" }); } /** Never retries on the last attempt; always retries an abort/timeout; * for an {@link ApiError} with a status, retries only if * {@link isRetryableStatus}; any other exception type is treated as * transient too. */ shouldRetry(error, attempt, maxAttempts) { if (attempt >= maxAttempts) return false; if (isAbortError(error)) return true; if (error instanceof ApiError && error.status !== undefined) { return isRetryableStatus(error.status); } return true; } /** Builds an {@link ApiError} from a non-ok response, preferring the * dashboard's `{ error: { code, message } }` envelope when present, * falling back to a generic `HTTP_`. */ toApiError(method, url, status, body) { const fallbackMessage = `${method} ${url.pathname} failed with HTTP ${status}`; if (body && typeof body === "object" && "error" in body && body.error && typeof body.error === "object" && "message" in body.error) { const maybeCode = "code" in body.error && typeof body.error.code === "string" ? body.error.code : undefined; const maybeMessage = typeof body.error.message === "string" ? body.error.message : fallbackMessage; return new ApiError(maybeMessage, { status, code: maybeCode, details: body }); } return new ApiError(fallbackMessage, { status, code: `HTTP_${status}`, details: body }); } /** Parses `input` as JSON, returning the raw string unchanged if invalid. */ tryParseJson(input) { try { return JSON.parse(input); } catch { return input; } } /** Normalizes any thrown value to a loggable string message. */ getErrorMessage(error) { if (error instanceof Error) return error.message; if (typeof error === "string") return error; return "Unknown error"; } }