Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`.
3 changes: 3 additions & 0 deletions public/vortex-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"apiBaseUrl": ""
}
49 changes: 39 additions & 10 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
apiCredentials?: RequestCredentials;
};

const rootEl = document.getElementById("root");
if (rootEl === null) {
throw new Error("no root");
async function loadRuntimeConfig(): Promise<void> {
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(
<React.StrictMode>
<App />
</React.StrictMode>,
);
async function bootstrap(): Promise<void> {
await loadRuntimeConfig();
initTheme("sky");

const rootEl = document.getElementById("root");
if (rootEl === null) {
throw new Error("no root");
}

const root = ReactDOM.createRoot(rootEl);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
}

void bootstrap();
69 changes: 60 additions & 9 deletions src/lib/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ import type {
PoolProposalPageDto,
} from "@/types/api";

type ApiClientRuntimeConfig = {
apiBaseUrl?: string;
apiHeaders?: Record<string, string>;
apiCredentials?: RequestCredentials;
};

declare global {
interface Window {
__VORTEX_CONFIG__?: ApiClientRuntimeConfig;
}
}

export type ApiErrorPayload = {
error?: {
message?: string;
Expand All @@ -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<string, string> {
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<T>(res: Response): Promise<T> {
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;
Expand All @@ -66,7 +113,10 @@ async function readJsonResponse<T>(res: Response): Promise<T> {
}

export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path, { credentials: "include" });
const res = await fetch(resolveApiUrl(path), {
credentials: getApiCredentials(),
headers: getApiHeaders(),
});
return await readJsonResponse<T>(res);
}

Expand All @@ -75,10 +125,11 @@ export async function apiPost<T>(
body: unknown,
init?: { headers?: HeadersInit },
): Promise<T> {
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 ?? {}),
},
Expand Down Expand Up @@ -159,14 +210,14 @@ export async function apiChamberChatSignalPost(
input: {
peerId: string;
kind: "offer" | "answer" | "candidate";
targetPeerId?: string;
toPeerId?: string;
payload: Record<string, unknown>;
},
): 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,
});
}
Expand Down
10 changes: 5 additions & 5 deletions src/pages/chambers/Chamber.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,14 +249,14 @@ const Chamber: React.FC = () => {
const sendSignal = useCallback(
async (input: {
kind: "offer" | "answer" | "candidate";
targetPeerId: string;
toPeerId: string;
payload: Record<string, unknown>;
}) => {
if (!id) return;
await apiChamberChatSignalPost(id, {
peerId,
kind: input.kind,
targetPeerId: input.targetPeerId,
toPeerId: input.toPeerId,
payload: input.payload,
});
},
Expand All @@ -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<string, unknown>,
});
};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down