// Single source of truth for Vue → MulmoClaude server HTTP calls. // // Before this module existed there were 56 scattered `fetch("/api/...")` // calls across 29 files, each doing its own JSON serialization, its own // `!res.ok` check, and its own ad-hoc error extraction. This made any // cross-cutting concern — auth headers, error formatting, retry policy, // logging — impossible to add without touching every call site. // // All HTTP traffic from the Vue app should now go through one of: // // apiGet(path, query?) // apiPost(path, body?) // apiPut(path, body?) // apiDelete(path, body?) // apiCall(path, opts) ← generic, for methods not above // apiFetchRaw(path, opts) ← when you need the raw Response // (binary, streaming, etc.) // // Return type is a discriminated union `ApiResult`: // // { ok: true, data: T } // { ok: false, error: string, status: number } // // Callers pattern-match on `result.ok` — no more mixing try/catch with // `!res.ok` branches. Network errors and HTTP errors surface through the // same `{ ok: false }` shape. // // Future extension hooks (see #272 for auth token): // - setAuthToken() populates a module-level token used by every call // - interceptors could go here for logging, retry, metrics import { ref, type Ref } from "vue"; import { errorMessage } from "./errors"; import { hasStringProp } from "./types"; // ── Backend reachability signal (#1479) ───────────────────────────── // // `apiCall` returns `{ ok:false, status:0 }` when `fetch` itself // throws — the classic "network error" / `ERR_CONNECTION_REFUSED` // shape. That used to be silently swallowed feature-by-feature; now // we flip a module-level ref so any consumer (App.vue's banner) can // react. Eager: surfaces backend-down at the FIRST failing user- // triggered fetch, no need to wait for the 15s health poll. /** True while the backend appears reachable. Flipped to false on a * `fetch` throw (network error, server stopped, DNS, etc.) and back * to true on any successful HTTP response. */ export const backendReachable: Ref = ref(true); /** Last network-error message observed when `backendReachable` was * flipped to false. Useful for the offline banner's small print. */ export const lastBackendError: Ref = ref(null); /** A `fetch` rejection that came from caller-driven `AbortController` * cancellation (the spec says it's a `DOMException` with `name === * "AbortError"`). Normal flow — must NOT flip `backendReachable`. */ function isAbortError(err: unknown): boolean { if (typeof DOMException !== "undefined" && err instanceof DOMException && err.name === "AbortError") return true; return hasStringProp(err, "name") && err.name === "AbortError"; } // ── Auth token (populated by bootstrap; consumed by every call) ───── let authToken: string | null = null; /** * Set the bearer token used on every API call. Call once during app * bootstrap, typically after reading a `` tag or window global * populated by the server. */ export function setAuthToken(token: string | null): void { authToken = token; } // ── Types ──────────────────────────────────────────────────────────── export type ApiResult = { ok: true; data: T } | { ok: false; error: string; status: number }; export type ApiQuery = Record; export interface ApiOptions { method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | undefined; /** JSON-serialized into the request body. Omit for GET/DELETE. */ body?: unknown; /** Appended as a query string. `undefined` values are dropped. */ query?: ApiQuery | undefined; /** AbortSignal — pass through to fetch. */ signal?: AbortSignal | undefined; /** * Extra headers. Content-Type is set automatically for JSON bodies; * Authorization is injected from `authToken`. */ headers?: Record | undefined; } // Use Parameters rather than global DOM lib types so // this module doesn't depend on DOM lib being in the ESLint globals. type FetchInit = NonNullable[1]>; type FetchBody = FetchInit["body"]; // ── Internals ──────────────────────────────────────────────────────── function buildQueryString(query: ApiQuery | undefined): string { if (!query) return ""; const parts: string[] = []; for (const [key, value] of Object.entries(query)) { if (value === undefined) continue; parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); } return parts.length === 0 ? "" : `?${parts.join("&")}`; } function buildHeaders(opts: { headers?: Record | undefined }, hasBody: boolean): Record { const headers: Record = { ...(opts.headers ?? {}) }; if (hasBody && headers["Content-Type"] === undefined) { headers["Content-Type"] = "application/json"; } if (authToken && headers["Authorization"] === undefined) { headers["Authorization"] = `Bearer ${authToken}`; } return headers; } async function extractError(res: Response): Promise<{ error: string; status: number }> { const { status } = res; // Try to parse a `{ error: string }` body first — that's the server's // standard error shape. `in` narrowing lets us read `body.error` // without any type assertion. try { const body: unknown = await res.clone().json(); if (hasStringProp(body, "error")) { return { error: body.error, status }; } } catch { // Body wasn't JSON — fall through. } return { error: res.statusText || `Request failed (${status})`, status, }; } // ── Core call ─────────────────────────────────────────────────────── /** * Generic HTTP call. Returns a discriminated union on success vs * failure. Network errors are caught and surfaced as * `{ ok: false, status: 0 }`. Assumes JSON response bodies (all * MulmoClaude `/api/*` endpoints return JSON on success); use * `apiFetchRaw` for binary / streaming / non-JSON responses. */ export async function apiCall(path: string, opts: ApiOptions = {}): Promise> { const method = opts.method ?? "GET"; const hasBody = opts.body !== undefined; const url = `${path}${buildQueryString(opts.query)}`; // `fetch`'s init rejects an explicit `undefined` signal, so only set it // when the caller passed one. const init: FetchInit = { method, headers: buildHeaders(opts, hasBody), ...(opts.signal !== undefined ? { signal: opts.signal } : {}), }; if (hasBody) { init.body = JSON.stringify(opts.body); } let res: Response; try { res = await fetch(url, init); } catch (err) { const message = errorMessage(err); // `fetch` throws on EITHER a true network failure (server // stopped, DNS, CORS preflight) OR a caller-driven // AbortController cancellation. The second case is a normal flow // (file/plugin refresh races, navigation cancel) — flipping the // global offline flag for those would surface a false banner. // Only the first case warrants the signal. if (!isAbortError(err)) { backendReachable.value = false; lastBackendError.value = message; } return { ok: false, error: message, status: 0, }; } // Any reply at all means the server is talking — re-arm the // reachable flag. HTTP-level errors (4xx/5xx) leave it true; only // network-error throws above flip it false. if (!backendReachable.value) { backendReachable.value = true; lastBackendError.value = null; } if (!res.ok) { const { error, status } = await extractError(res); return { ok: false, error, status }; } // `res.json()` returns `Promise`, which is assignable to T // without a cast. try { const data: T = await res.json(); return { ok: true, data }; } catch (err) { return { ok: false, error: `Invalid JSON response: ${errorMessage(err)}`, status: res.status, }; } } // ── Convenience verbs ─────────────────────────────────────────────── export function apiGet(path: string, query?: ApiQuery, extra: Omit = {}): Promise> { return apiCall(path, { ...extra, method: "GET", query }); } export function apiPost(path: string, body?: unknown, extra: Omit = {}): Promise> { return apiCall(path, { ...extra, method: "POST", body }); } export function apiPut(path: string, body?: unknown, extra: Omit = {}): Promise> { return apiCall(path, { ...extra, method: "PUT", body }); } export function apiDelete(path: string, body?: unknown, extra: Omit = {}): Promise> { return apiCall(path, { ...extra, method: "DELETE", body }); } // ── Raw Response escape hatch ─────────────────────────────────────── export interface RawOptions { method?: string | undefined; /** Accepts any value fetch accepts (string / Blob / FormData / …). * `FetchBody` already includes `undefined`; the member keeps `null` * (a body fetch does accept) plus the explicit `undefined` callers * pass for "no body". */ body?: Exclude | undefined; headers?: Record | undefined; signal?: AbortSignal | undefined; query?: ApiQuery | undefined; } /** * Escape hatch for endpoints returning binary / streaming / non-JSON * bodies (PDF download, audio blob, SSE, etc.). Auth header is still * applied; other handling is the caller's responsibility. * * Throws on network errors. Does NOT check `res.ok`. */ export async function apiFetchRaw(path: string, opts: RawOptions = {}): Promise { const url = `${path}${buildQueryString(opts.query)}`; const init: FetchInit = { method: opts.method ?? "GET", headers: buildHeaders(opts, false), ...(opts.body !== undefined ? { body: opts.body } : {}), ...(opts.signal !== undefined ? { signal: opts.signal } : {}), }; return fetch(url, init); }