150 lines
6.7 KiB
TypeScript
150 lines
6.7 KiB
TypeScript
import { getTenantSlug } from "./tenant";
|
|
|
|
// When NEXT_PUBLIC_PLATFORM_URL is set to "" (empty string), the canvas
|
|
// uses relative paths — correct for the combined tenant image where Go
|
|
// platform + canvas run on the same port via reverse proxy. The `??`
|
|
// operator preserves "" as a valid value; `||` would fall through to
|
|
// the localhost default.
|
|
export const PLATFORM_URL =
|
|
process.env.NEXT_PUBLIC_PLATFORM_URL ?? "http://localhost:8080";
|
|
|
|
// 15s is long enough for slow CP queries but short enough that a
|
|
// hung backend doesn't leave the UI spinning forever. The abort
|
|
// propagates through AbortController so React components can observe
|
|
// the error and render a retry affordance. Callers that know the
|
|
// endpoint is intentionally slow (org import walks a tree of
|
|
// workspaces with server-side pacing) can pass `timeoutMs` to
|
|
// override.
|
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
|
|
export interface RequestOptions {
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
async function request<T>(
|
|
method: string,
|
|
path: string,
|
|
body?: unknown,
|
|
retryCount = 0,
|
|
options?: RequestOptions,
|
|
): Promise<T> {
|
|
// SaaS cross-origin shape:
|
|
// - X-Molecule-Org-Slug: derived from window.location.hostname by
|
|
// getTenantSlug(). Control plane uses it for fly-replay routing.
|
|
// Empty on localhost / non-tenant hosts — safe to omit.
|
|
// - credentials:"include": sends the session cookie cross-origin.
|
|
// Cookie's Domain=.moleculesai.app attribute + cp's CORS allow this.
|
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
const slug = getTenantSlug();
|
|
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
|
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
|
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
|
|
|
const res = await fetch(`${PLATFORM_URL}${path}`, {
|
|
method,
|
|
headers,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
credentials: "include",
|
|
signal: AbortSignal.timeout(options?.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
});
|
|
// Transient rate-limit recovery. A single IP bucket can momentarily
|
|
// spike on page load (several panels hydrate simultaneously). Instead
|
|
// of bubbling up a 429 that blanks the Canvas, wait the
|
|
// Retry-After window and try once — any further 429 surfaces normally.
|
|
// GET / idempotent methods only; never auto-retry mutations.
|
|
if (res.status === 429 && retryCount === 0 && method === "GET") {
|
|
const retryAfterHeader = res.headers.get("Retry-After");
|
|
const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) : NaN;
|
|
const delayMs = Number.isFinite(retryAfter) ? Math.min(retryAfter, 20) * 1000 : 2000;
|
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
return request<T>(method, path, body, retryCount + 1, options);
|
|
}
|
|
if (res.status === 401) {
|
|
// Distinguish "session is dead" from "this endpoint refused this
|
|
// token." Old behaviour blanket-redirected on every 401, so a
|
|
// single transient 401 from a workspace-scoped endpoint
|
|
// (/workspaces/:id/peers, /plugins, etc. that need a workspace
|
|
// token rather than the tenant admin bearer) yanked the user
|
|
// back to AuthKit even when their session was perfectly fine.
|
|
// That broke the staging-tabs E2E for the entire 2026-04-25
|
|
// night; #2073/#2074 worked around the symptom in the test by
|
|
// mocking 401→200 for every fetch, but the user-facing bug
|
|
// stayed.
|
|
//
|
|
// The canonical "session is dead" signal is /cp/auth/me
|
|
// returning 401. For any 401 on a non-auth path, probe
|
|
// /cp/auth/me before deciding to redirect:
|
|
// - probe 401 → session is actually dead → redirect
|
|
// - probe 200 → session is fine, the endpoint just refused
|
|
// our specific token → throw a real error,
|
|
// caller renders an error state
|
|
// - probe network error → assume session-fine (conservative;
|
|
// better to throw than to redirect on a
|
|
// transient probe failure)
|
|
//
|
|
// Self-hosted / localhost / reserved subdomains still throw
|
|
// without redirecting (slug is empty in those cases) — same
|
|
// policy as before.
|
|
const isAuthPath = path.startsWith("/cp/auth/");
|
|
let sessionDead = isAuthPath;
|
|
if (!isAuthPath && slug) {
|
|
try {
|
|
const probe = await fetch(`${PLATFORM_URL}/cp/auth/me`, {
|
|
credentials: "include",
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
sessionDead = probe.status === 401;
|
|
} catch {
|
|
// Probe failed (network/timeout) — fall through to throw.
|
|
}
|
|
}
|
|
if (sessionDead && slug) {
|
|
const { redirectToLogin } = await import("./auth");
|
|
redirectToLogin("sign-in");
|
|
throw new Error("Session expired — redirecting to login");
|
|
}
|
|
throw new Error(`API ${method} ${path}: 401 ${await res.text()}`);
|
|
}
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
// Recognise the platform's structured "datastore unreachable"
|
|
// shape (returned by wsauth_middleware.abortAuthLookupError when
|
|
// Postgres/Redis is down). Surface as a typed error so callers
|
|
// can render a dedicated diagnostic instead of a generic toast.
|
|
if (res.status === 503 && text) {
|
|
try {
|
|
const parsed = JSON.parse(text) as { code?: string; error?: string };
|
|
if (parsed.code === "platform_unavailable") {
|
|
throw new PlatformUnavailableError(parsed.error || "platform datastore unavailable");
|
|
}
|
|
} catch (err) {
|
|
// Re-throw the typed error if that's what we just constructed.
|
|
// JSON.parse failures fall through to the generic Error below.
|
|
if (err instanceof PlatformUnavailableError) throw err;
|
|
}
|
|
}
|
|
throw new Error(`API ${method} ${path}: ${res.status} ${text}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/** Thrown when the platform reports its datastore (Postgres/Redis) is
|
|
* unreachable. Surface with a dedicated diagnostic UI rather than a
|
|
* generic API-error toast — the user's next action is to check local
|
|
* services, not to retry the API call. */
|
|
export class PlatformUnavailableError extends Error {
|
|
readonly code = "platform_unavailable" as const;
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "PlatformUnavailableError";
|
|
}
|
|
}
|
|
|
|
export const api = {
|
|
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, 0, options),
|
|
post: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("POST", path, body, 0, options),
|
|
patch: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("PATCH", path, body, 0, options),
|
|
put: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("PUT", path, body, 0, options),
|
|
del: <T>(path: string, options?: RequestOptions) => request<T>("DELETE", path, undefined, 0, options),
|
|
};
|