/** * Shared API client for all MesmerTools HuggingFace Spaces. * * Spaces are static pages that call the mesmer.tools API directly from the * visitor's browser. That is deliberate: per-IP rate limits then apply per * visitor instead of being shared through one Space backend. CORS on * /api/v1/* is already `*`, so cross-origin fetches work with no proxy. * * Every helper throws `RateLimitError` on a 429 so the UI can surface the * "use the full tool for higher limits" cross-sell, and `ApiError` otherwise. */ import { SITE } from "./config.js"; export class RateLimitError extends Error { constructor(message) { super(message || "You've hit the free hourly limit."); this.name = "RateLimitError"; this.isRateLimit = true; } } export class ApiError extends Error { constructor(message, status = 0) { super(message || "Something went wrong."); this.name = "ApiError"; this.status = status; } } const DEFAULT_TIMEOUT = 60_000; async function doFetch(url, init, timeout) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeout ?? DEFAULT_TIMEOUT); try { return await fetch(url, { ...init, signal: ctrl.signal }); } catch (err) { if (err && err.name === "AbortError") { throw new ApiError( "The request timed out. The free demo can be slow under load — try the full tool on mesmer.tools.", 408, ); } throw new ApiError("Network error. Check your connection and try again.", 0); } finally { clearTimeout(timer); } } async function readJson(res) { try { return await res.json(); } catch { return null; } } /** * REST call. GET with `params`, or POST with a JSON `body`. * @returns parsed JSON response */ export async function callRest(apiPath, { method = "GET", params, body, timeout } = {}) { let url = SITE.origin + apiPath; const init = { method, headers: {} }; if (params) { const qs = new URLSearchParams(params).toString(); if (qs) url += (url.includes("?") ? "&" : "?") + qs; } if (body !== undefined) { init.headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); } const res = await doFetch(url, init, timeout); const data = await readJson(res); if (res.status === 429) throw new RateLimitError(data && data.error); if (!res.ok) throw new ApiError((data && data.error) || `Request failed (${res.status}).`, res.status); return data; } /** * tRPC v11 mutation (superjson transformer). Used by the logo space. * Input is wrapped as `{ json: input }`; the unwrapped value is returned. */ export async function callTrpcMutation(trpcPath, input, { timeout } = {}) { const res = await doFetch( SITE.origin + trpcPath, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ json: input }), }, timeout, ); const data = await readJson(res); if (res.status === 429) throw new RateLimitError(extractTrpcError(data)); if (!res.ok) throw new ApiError(extractTrpcError(data) || `Request failed (${res.status}).`, res.status); // superjson success envelope: { result: { data: { json: } } } const out = data && data.result && data.result.data; return out && typeof out === "object" && "json" in out ? out.json : out; } function extractTrpcError(data) { if (!data || !data.error) return null; // superjson error envelope: { error: { json: { message, data: {...} } } } return (data.error.json && data.error.json.message) || data.error.message || null; } /** Fetch a generated JSON artifact (benchmark, voices) from mesmer.tools. */ export async function fetchData(url, { timeout } = {}) { const res = await doFetch(url, { method: "GET" }, timeout ?? 20_000); if (!res.ok) throw new ApiError(`Could not load data (${res.status}).`, res.status); return readJson(res); }