From 89b740d9f73feff3e627c38de48c665c95fcb736 Mon Sep 17 00:00:00 2001
From: Jean Brito
- curl -X {ep.method} http://localhost:20128
+ curl -X {ep.method} {baseUrl}
{ep.path.replace("/api/", "/")}
{ep.security ? ' -H "Authorization: Bearer YOUR_KEY"' : ""}
{ep.requestBody
diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx
index 10f3e25412..3be6a00ca1 100644
--- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx
+++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo, useCallback } from "react";
import Link from "next/link";
import { Card, Button, Input, Modal, CardSkeleton, SegmentedControl } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
+import { useDisplayBaseUrl } from "@/shared/hooks";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
import { useTranslations } from "next-intl";
@@ -1007,7 +1008,8 @@ export default function APIPageClient({ machineId }: APIPageClientProps) {
}
}, [fetchTailscaleStatus, handleTailscaleEnable, tailscalePassword, translateOrFallback]);
- const [baseUrl, setBaseUrl] = useState("/v1");
+ const displayBaseUrl = useDisplayBaseUrl();
+ const baseUrl = `${displayBaseUrl}/v1`;
const normalizedCloudBaseUrl = cloudBaseUrl
? resolvedMachineId && !cloudBaseUrl.endsWith(`/${resolvedMachineId}`)
? `${cloudBaseUrl}/${resolvedMachineId}`
@@ -1015,14 +1017,6 @@ export default function APIPageClient({ machineId }: APIPageClientProps) {
: null;
const cloudEndpointNew = normalizedCloudBaseUrl ? `${normalizedCloudBaseUrl}/v1` : null;
- // Hydration fix: Only access window on client side
- useEffect(() => {
- if (typeof window !== "undefined") {
- const defaultOrigin = process.env.NEXT_PUBLIC_BASE_URL || window.location.origin;
- setBaseUrl(`${defaultOrigin}/v1`);
- }
- }, []);
-
if (loading) {
return (
diff --git a/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx b/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx
index ce993e3663..8b7aa4e33f 100644
--- a/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx
+++ b/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx
@@ -104,4 +104,48 @@ describe("ApiEndpointsTab", () => {
expect(document.body.textContent).toContain("1 endpoints across 1 categories");
expect(document.body.textContent).toContain("/api/v1/chat/completions");
});
+
+ it("renders curl example using window.location.origin when NEXT_PUBLIC_BASE_URL is unset", async () => {
+ fetchMock.mockResolvedValue(
+ jsonResponse({
+ info: { title: "OmniRoute API", version: "3.7.6" },
+ servers: [],
+ tags: [{ name: "Chat" }],
+ endpoints: [
+ {
+ method: "POST",
+ path: "/api/v1/chat/completions",
+ tags: ["Chat"],
+ summary: "Create chat completion",
+ description: "Create chat completion",
+ security: false,
+ parameters: [],
+ requestBody: false,
+ responses: ["200"],
+ },
+ ],
+ schemas: [],
+ })
+ );
+
+ renderApiEndpointsTab();
+
+ await waitForText("OmniRoute API");
+
+ // Expand the endpoint to reveal the curl example
+ const endpointRow = document.body.querySelector("code.font-mono.flex-1");
+ if (endpointRow?.parentElement) {
+ await act(async () => {
+ endpointRow.parentElement!.click();
+ });
+ }
+
+ // After mount the hook swaps DEFAULT_DISPLAY_BASE_URL for window.location.origin.
+ // In jsdom the default origin is "http://localhost".
+ const expectedOrigin = window.location.origin; // "http://localhost" in jsdom
+ await waitForText(`curl -X POST ${expectedOrigin}/v1/chat/completions`);
+ expect(document.body.textContent).toContain(
+ `curl -X POST ${expectedOrigin}/v1/chat/completions`
+ );
+ });
});
diff --git a/src/app/(dashboard)/dashboard/onboarding/page.tsx b/src/app/(dashboard)/dashboard/onboarding/page.tsx
index 71995da7f7..df80edcaa5 100644
--- a/src/app/(dashboard)/dashboard/onboarding/page.tsx
+++ b/src/app/(dashboard)/dashboard/onboarding/page.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
+import { useDisplayBaseUrl } from "@/shared/hooks";
const STEP_IDS = ["welcome", "security", "provider", "test", "done"];
const STEP_ICONS = ["waving_hand", "lock", "dns", "play_circle", "check_circle"];
@@ -20,9 +21,10 @@ export default function OnboardingWizard() {
const router = useRouter();
const t = useTranslations("onboarding");
const tc = useTranslations("common");
+ const baseUrl = useDisplayBaseUrl();
const [step, setStep] = useState(0);
const [loading, setLoading] = useState(true);
- const [apiEndpoint, setApiEndpoint] = useState("http://localhost:20128/api/v1");
+ const [apiEndpoint, setApiEndpoint] = useState(`${baseUrl}/api/v1`);
// Security step state
const [password, setPassword] = useState("");
diff --git a/src/app/landing/components/GetStarted.tsx b/src/app/landing/components/GetStarted.tsx
index 2fa01f4195..9eb847d475 100644
--- a/src/app/landing/components/GetStarted.tsx
+++ b/src/app/landing/components/GetStarted.tsx
@@ -2,12 +2,13 @@
import { useState } from "react";
import { useTranslations } from "next-intl";
import { copyToClipboard } from "@/shared/utils/clipboard";
+import { useDisplayBaseUrl } from "@/shared/hooks";
export default function GetStarted() {
const t = useTranslations("landing");
const [copied, setCopied] = useState(false);
- const endpoint = "http://localhost:20128";
+ const endpoint = useDisplayBaseUrl();
const dashboardUrl = `${endpoint}/dashboard`;
const command = "npx omniroute";
diff --git a/src/shared/hooks/__tests__/useDisplayBaseUrl.test.tsx b/src/shared/hooks/__tests__/useDisplayBaseUrl.test.tsx
new file mode 100644
index 0000000000..c6dd92b137
--- /dev/null
+++ b/src/shared/hooks/__tests__/useDisplayBaseUrl.test.tsx
@@ -0,0 +1,176 @@
+// @vitest-environment jsdom
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { DEFAULT_DISPLAY_BASE_URL } from "../useDisplayBaseUrl";
+
+const cleanupCallbacks: Array<() => void> = [];
+
+function makeContainer(): HTMLElement {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ cleanupCallbacks.push(() => {
+ container.remove();
+ });
+ return container;
+}
+
+describe("useDisplayBaseUrl", () => {
+ beforeEach(() => {
+ (
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT = true;
+ });
+
+ afterEach(() => {
+ while (cleanupCallbacks.length > 0) {
+ cleanupCallbacks.pop()?.();
+ }
+ document.body.innerHTML = "";
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ });
+
+ it("returns env value on first render and after mount when NEXT_PUBLIC_BASE_URL is set", async () => {
+ vi.stubEnv("NEXT_PUBLIC_BASE_URL", "https://example.com");
+
+ const { useDisplayBaseUrl } = await import("../useDisplayBaseUrl");
+
+ const container = makeContainer();
+ const root = createRoot(container);
+
+ function C() {
+ const url = useDisplayBaseUrl();
+ return {url};
+ }
+
+ // Synchronous act: commits render and flushes synchronous effects.
+ // The queueMicrotask in useEffect has not yet fired.
+ act(() => {
+ root.render( );
+ });
+
+ // Env set: first render shows env value (useEffect no-ops when envValue is set)
+ expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
+ "https://example.com"
+ );
+
+ // Flush microtasks and any remaining async work
+ await act(async () => {});
+
+ // Env still wins after mount
+ expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
+ "https://example.com"
+ );
+ });
+
+ it("returns DEFAULT_DISPLAY_BASE_URL on first render and origin after mount when env unset", async () => {
+ vi.stubEnv("NEXT_PUBLIC_BASE_URL", "");
+
+ const { useDisplayBaseUrl } = await import("../useDisplayBaseUrl");
+
+ const container = makeContainer();
+ const root = createRoot(container);
+
+ function C() {
+ const url = useDisplayBaseUrl();
+ return {url};
+ }
+
+ // Synchronous act commits render. useEffect fires but queueMicrotask
+ // schedules setState for after this act() call returns.
+ act(() => {
+ root.render( );
+ });
+
+ // Pre-microtask: DOM still shows the initial state (DEFAULT_DISPLAY_BASE_URL)
+ expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
+ DEFAULT_DISPLAY_BASE_URL
+ );
+
+ // Flush queueMicrotask callback (setState) and resulting re-render
+ await act(async () => {});
+
+ // After mount: swaps to window.location.origin
+ expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
+ window.location.origin
+ );
+ });
+
+ it("trims and strips trailing slash from env value", async () => {
+ vi.stubEnv("NEXT_PUBLIC_BASE_URL", " https://x.com/ ");
+
+ const { useDisplayBaseUrl } = await import("../useDisplayBaseUrl");
+
+ const container = makeContainer();
+ const root = createRoot(container);
+
+ function C() {
+ const url = useDisplayBaseUrl();
+ return {url};
+ }
+
+ await act(async () => {
+ root.render( );
+ });
+
+ expect(container.querySelector('[data-testid="value"]')?.textContent).toBe("https://x.com");
+ });
+
+ it("strips trailing slash from window.location.origin after mount", async () => {
+ vi.stubEnv("NEXT_PUBLIC_BASE_URL", "");
+
+ // Stub window.location with trailing slash on origin
+ vi.stubGlobal("location", { origin: "http://192.168.13.62:20128/" });
+
+ const { useDisplayBaseUrl } = await import("../useDisplayBaseUrl");
+
+ const container = makeContainer();
+ const root = createRoot(container);
+
+ function C() {
+ const url = useDisplayBaseUrl();
+ return {url};
+ }
+
+ // Render and flush all effects including queueMicrotask
+ await act(async () => {
+ root.render( );
+ });
+
+ expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
+ "http://192.168.13.62:20128"
+ );
+ });
+
+ it("treats empty-string env as unset and falls through to origin after mount", async () => {
+ vi.stubEnv("NEXT_PUBLIC_BASE_URL", "");
+
+ const { useDisplayBaseUrl } = await import("../useDisplayBaseUrl");
+
+ const container = makeContainer();
+ const root = createRoot(container);
+
+ function C() {
+ const url = useDisplayBaseUrl();
+ return {url};
+ }
+
+ // Synchronous act: render committed, useEffect fired, microtask queued but not yet run
+ act(() => {
+ root.render( );
+ });
+
+ // Empty env treated as unset → initial state is DEFAULT_DISPLAY_BASE_URL
+ expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
+ DEFAULT_DISPLAY_BASE_URL
+ );
+
+ // Flush queueMicrotask + re-render
+ await act(async () => {});
+
+ // After mount: resolves to origin
+ const result = container.querySelector('[data-testid="value"]')?.textContent;
+ expect(result).toBe(window.location.origin.replace(/\/+$/, ""));
+ });
+});
diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts
index 68c81c2d54..896a06fc7e 100644
--- a/src/shared/hooks/index.ts
+++ b/src/shared/hooks/index.ts
@@ -1,2 +1,3 @@
// Shared Hooks - Export all
export { useTheme } from "./useTheme";
+export { useDisplayBaseUrl, DEFAULT_DISPLAY_BASE_URL } from "./useDisplayBaseUrl";
diff --git a/src/shared/hooks/useDisplayBaseUrl.ts b/src/shared/hooks/useDisplayBaseUrl.ts
new file mode 100644
index 0000000000..96081ba583
--- /dev/null
+++ b/src/shared/hooks/useDisplayBaseUrl.ts
@@ -0,0 +1,49 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+export const DEFAULT_DISPLAY_BASE_URL = "http://localhost:20128";
+
+function normalizeUrl(value?: string): string | null {
+ const trimmed = value?.trim();
+ if (!trimmed) return null;
+ return trimmed.replace(/\/+$/, "");
+}
+
+/**
+ * Returns the public base URL to display in the dashboard.
+ *
+ * Resolution chain:
+ * 1. NEXT_PUBLIC_BASE_URL (env, trimmed + slash-normalized) — wins if set.
+ * 2. window.location.origin after client mount — when env is unset.
+ * 3. DEFAULT_DISPLAY_BASE_URL ("http://localhost:20128") — SSR / first render fallback.
+ *
+ * DISPLAY ONLY — do NOT use this hook for OAuth `redirect_uri`.
+ * OAuth callers must read `process.env.NEXT_PUBLIC_BASE_URL` directly to avoid
+ * host-header attack surface. For server-side resolution, use
+ * `src/shared/utils/resolveOmniRouteBaseUrl.ts` instead.
+ */
+export function useDisplayBaseUrl(): string {
+ const envValue = normalizeUrl(process.env.NEXT_PUBLIC_BASE_URL);
+
+ const [url, setUrl] = useState(envValue ?? DEFAULT_DISPLAY_BASE_URL);
+
+ useEffect(() => {
+ if (envValue) return;
+ const origin = normalizeUrl(window.location.origin) ?? DEFAULT_DISPLAY_BASE_URL;
+ // Schedule via queueMicrotask so setState is called inside a callback,
+ // not synchronously in the effect body (react-hooks/set-state-in-effect).
+ // The unmounted guard prevents a stale setState on a torn-down root
+ // (relevant under React strict mode's double-invoke, where cleanup runs
+ // before the microtask fires on the first effect invocation).
+ let unmounted = false;
+ queueMicrotask(() => {
+ if (!unmounted) setUrl(origin);
+ });
+ return () => {
+ unmounted = true;
+ };
+ }, [envValue]);
+
+ return url;
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index 34b035d509..8fb8eb8c6e 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -9,6 +9,7 @@ export default defineConfig({
include: [
"src/app/**/dashboard/cache/__tests__/**/*.test.tsx",
"src/app/**/dashboard/endpoint/__tests__/**/*.test.tsx",
+ "src/shared/hooks/__tests__/**/*.test.tsx",
"src/lib/memory/__tests__/**/*.test.ts",
"src/lib/skills/__tests__/**/*.test.ts",
"tests/unit/encryption.test.ts",