diff --git a/README.md b/README.md index 2565ece..1a350a4 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,4 @@ Docs live separately in `humanode-network/vortex-simulator-docs`. - `dist/` is generated build output. - UI expects the API at `/api/*`. During local dev, Rsbuild proxies `/api/*` to `http://127.0.0.1:8788` by default (override with `API_PROXY_TARGET`). +- To point the UI at a different API host, set `RSBUILD_PUBLIC_API_BASE_URL` at build time or serve `public/vortex-config.json` with `{"apiBaseUrl":"https://api.example.com"}`. diff --git a/public/vortex-config.json b/public/vortex-config.json new file mode 100644 index 0000000..f6d80c7 --- /dev/null +++ b/public/vortex-config.json @@ -0,0 +1,3 @@ +{ + "apiBaseUrl": "" +} diff --git a/src/index.tsx b/src/index.tsx index 4ba6f4d..17c0ba3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,16 +8,45 @@ import "./styles/global.css"; import "./styles/base.css"; import { initTheme } from "./lib/theme"; -initTheme("sky"); +type RuntimeConfig = { + apiBaseUrl?: string; + apiHeaders?: Record; + apiCredentials?: RequestCredentials; +}; -const rootEl = document.getElementById("root"); -if (rootEl === null) { - throw new Error("no root"); +async function loadRuntimeConfig(): Promise { + if (typeof window === "undefined") return; + const target = window as typeof window & { + __VORTEX_CONFIG__?: RuntimeConfig; + }; + if (target.__VORTEX_CONFIG__) return; + try { + const res = await fetch("/vortex-config.json", { cache: "no-store" }); + if (!res.ok) return; + const json = (await res.json()) as unknown; + if (json && typeof json === "object") { + target.__VORTEX_CONFIG__ = json as RuntimeConfig; + } + } catch { + // Ignore missing runtime config. + } } -const root = ReactDOM.createRoot(rootEl); -root.render( - - - , -); +async function bootstrap(): Promise { + await loadRuntimeConfig(); + initTheme("sky"); + + const rootEl = document.getElementById("root"); + if (rootEl === null) { + throw new Error("no root"); + } + + const root = ReactDOM.createRoot(rootEl); + root.render( + + + , + ); +} + +void bootstrap(); diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index aafab04..cd0e72f 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -27,6 +27,18 @@ import type { PoolProposalPageDto, } from "@/types/api"; +type ApiClientRuntimeConfig = { + apiBaseUrl?: string; + apiHeaders?: Record; + apiCredentials?: RequestCredentials; +}; + +declare global { + interface Window { + __VORTEX_CONFIG__?: ApiClientRuntimeConfig; + } +} + export type ApiErrorPayload = { error?: { message?: string; @@ -47,16 +59,51 @@ export function getApiErrorPayload(error: unknown): ApiErrorPayload | null { return data as ApiErrorPayload; } +const envApiBaseUrl = + import.meta.env.RSBUILD_PUBLIC_API_BASE_URL ?? + import.meta.env.VITE_API_BASE_URL ?? + ""; + +function getRuntimeConfig(): ApiClientRuntimeConfig | undefined { + if (typeof window === "undefined") return undefined; + return window.__VORTEX_CONFIG__; +} + +function getApiBaseUrl(): string { + const runtimeConfig = getRuntimeConfig(); + return runtimeConfig?.apiBaseUrl ?? envApiBaseUrl ?? ""; +} + +function getApiCredentials(): RequestCredentials { + const runtimeConfig = getRuntimeConfig(); + return runtimeConfig?.apiCredentials ?? "include"; +} + +function getApiHeaders(): Record { + const runtimeConfig = getRuntimeConfig(); + return runtimeConfig?.apiHeaders ?? {}; +} + +function resolveApiUrl(path: string): string { + if (/^https?:\/\//i.test(path)) return path; + const apiBaseUrl = getApiBaseUrl(); + if (!apiBaseUrl) return path; + const base = apiBaseUrl.replace(/\/$/, ""); + const suffix = path.startsWith("/") ? path : `/${path}`; + return `${base}${suffix}`; +} + async function readJsonResponse(res: Response): Promise { const contentType = res.headers.get("content-type") ?? ""; const isJson = contentType.toLowerCase().includes("application/json"); - const body = isJson ? ((await res.json()) as unknown) : null; + const body = isJson ? ((await res.json()) as unknown) : await res.text(); if (!res.ok) { const payload = (body as ApiErrorPayload | null) ?? null; - const message = + const rawMessage = payload?.error?.message ?? - (typeof body === "string" ? body : null) ?? - `HTTP ${res.status}`; + (typeof body === "string" && body.trim() ? body : null) ?? + res.statusText; + const message = `HTTP ${res.status}${rawMessage ? `: ${rawMessage}` : ""}`; const error = new Error(message) as ApiError; if (payload) error.data = payload; error.status = res.status; @@ -66,7 +113,10 @@ async function readJsonResponse(res: Response): Promise { } export async function apiGet(path: string): Promise { - const res = await fetch(path, { credentials: "include" }); + const res = await fetch(resolveApiUrl(path), { + credentials: getApiCredentials(), + headers: getApiHeaders(), + }); return await readJsonResponse(res); } @@ -75,10 +125,11 @@ export async function apiPost( body: unknown, init?: { headers?: HeadersInit }, ): Promise { - const res = await fetch(path, { + const res = await fetch(resolveApiUrl(path), { method: "POST", - credentials: "include", + credentials: getApiCredentials(), headers: { + ...getApiHeaders(), "content-type": "application/json", ...(init?.headers ?? {}), }, @@ -159,14 +210,14 @@ export async function apiChamberChatSignalPost( input: { peerId: string; kind: "offer" | "answer" | "candidate"; - targetPeerId?: string; + toPeerId?: string; payload: Record; }, ): Promise<{ ok: true }> { return await apiPost<{ ok: true }>(`/api/chambers/${chamberId}/chat/signal`, { peerId: input.peerId, kind: input.kind, - targetPeerId: input.targetPeerId, + toPeerId: input.toPeerId, payload: input.payload, }); } diff --git a/src/pages/chambers/Chamber.tsx b/src/pages/chambers/Chamber.tsx index 11f9b60..a433022 100644 --- a/src/pages/chambers/Chamber.tsx +++ b/src/pages/chambers/Chamber.tsx @@ -249,14 +249,14 @@ const Chamber: React.FC = () => { const sendSignal = useCallback( async (input: { kind: "offer" | "answer" | "candidate"; - targetPeerId: string; + toPeerId: string; payload: Record; }) => { if (!id) return; await apiChamberChatSignalPost(id, { peerId, kind: input.kind, - targetPeerId: input.targetPeerId, + toPeerId: input.toPeerId, payload: input.payload, }); }, @@ -273,7 +273,7 @@ const Chamber: React.FC = () => { if (!event.candidate) return; void sendSignal({ kind: "candidate", - targetPeerId: remotePeerId, + toPeerId: remotePeerId, payload: event.candidate.toJSON() as Record, }); }; @@ -321,7 +321,7 @@ const Chamber: React.FC = () => { if (pc.localDescription) { await sendSignal({ kind: "answer", - targetPeerId: remotePeerId, + toPeerId: remotePeerId, payload: pc.localDescription.toJSON() as unknown as Record< string, unknown @@ -404,7 +404,7 @@ const Chamber: React.FC = () => { if (pc.localDescription) { await sendSignal({ kind: "offer", - targetPeerId: peer.peerId, + toPeerId: peer.peerId, payload: pc.localDescription.toJSON() as unknown as Record< string, unknown