mesmer-logo / shared /api-client.js
mesmertech's picture
Deploy static space build
0bc2c6b verified
Raw
History Blame Contribute Delete
3.89 kB
/**
* 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: <value> } } }
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);
}