From 23382b8afba0c513e1536125e380e8d618ca25b2 Mon Sep 17 00:00:00 2001
From: Abhishek Kumar
Date: Sat, 27 Jun 2026 15:00:11 +0530
Subject: [PATCH] feat: centralise and simplify the url configuration
---
ui/src/app/api/config/version/route.ts | 7 ++
.../run/[runId]/hooks/useWebSocketRTC.tsx | 75 +++-------------
ui/src/components/MCPSection.tsx | 90 +++++++++++++------
ui/src/context/AppConfigContext.tsx | 25 ++++++
ui/src/lib/apiClient.ts | 30 ++++++-
5 files changed, 135 insertions(+), 92 deletions(-)
diff --git a/ui/src/app/api/config/version/route.ts b/ui/src/app/api/config/version/route.ts
index a031cf4a..bc65c8b2 100644
--- a/ui/src/app/api/config/version/route.ts
+++ b/ui/src/app/api/config/version/route.ts
@@ -36,6 +36,7 @@ export async function GET() {
let turnEnabled = false;
let forceTurnRelay = false;
let tunnelUrl: string | null = null;
+ let backendApiEndpoint: string | null = null;
let backendStatus: "reachable" | "unreachable" = "unreachable";
let backendMessage: string | null = `Backend is not reachable at ${backendUrl}.`;
@@ -55,6 +56,11 @@ export async function GET() {
turnEnabled = Boolean(data.turn_enabled);
forceTurnRelay = Boolean(data.force_turn_relay);
tunnelUrl = data.tunnel_url ?? null;
+ backendApiEndpoint =
+ typeof data.backend_api_endpoint === "string" &&
+ data.backend_api_endpoint.length > 0
+ ? trimTrailingSlash(data.backend_api_endpoint)
+ : null;
backendStatus = "reachable";
backendMessage = null;
}
@@ -71,6 +77,7 @@ export async function GET() {
turnEnabled,
forceTurnRelay,
tunnelUrl,
+ backendApiEndpoint,
backend: {
status: backendStatus,
url: backendUrl,
diff --git a/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx b/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx
index bbbd1550..db7a6ce8 100644
--- a/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx
+++ b/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx
@@ -6,6 +6,7 @@ import { TurnCredentialsResponse } from "@/client/types.gen";
import { WorkflowValidationError } from "@/components/flow/types";
import type { ConversationNodeTransitionItem, RealtimeFeedbackMessage as FeedbackMessage } from "@/components/workflow/conversation";
import { useAppConfig } from "@/context/AppConfigContext";
+import { resolveBrowserBackendUrl } from '@/lib/apiClient';
import logger from '@/lib/logger';
import { sdpFilterCodec } from "../utils";
@@ -37,34 +38,6 @@ const HANDLED_SERVICE_ERROR_TYPES = new Set([
'quota_check_failed',
]);
-const LOCALHOST_API_BASE_URL = 'http://localhost:8000';
-const LOCALHOST_API_HEALTH_URL = `${LOCALHOST_API_BASE_URL}/api/v1/health`;
-const LOCALHOST_API_PROBE_TIMEOUT_MS = 1500;
-
-function isLocalhostUi() {
- if (typeof window === 'undefined') return false;
-
- return ['localhost', '127.0.0.1', '::1'].includes(window.location.hostname);
-}
-
-async function probeLocalhostApi() {
- const controller = new AbortController();
- const timeout = window.setTimeout(() => controller.abort(), LOCALHOST_API_PROBE_TIMEOUT_MS);
-
- try {
- const response = await fetch(LOCALHOST_API_HEALTH_URL, {
- cache: 'no-store',
- signal: controller.signal,
- });
-
- return response.ok;
- } catch {
- return false;
- } finally {
- window.clearTimeout(timeout);
- }
-}
-
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables, onNodeTransition }: UseWebSocketRTCProps) => {
const [connectionStatus, setConnectionStatus] = useState('idle');
const [connectionActive, setConnectionActive] = useState(false);
@@ -137,41 +110,15 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const currentAllowInterruptRef = useRef(undefined);
const interruptWarningShownRef = useRef(false);
- const getWebSocketUrl = useCallback(async () => {
- // An explicitly configured backend URL always wins. When set, honor it
- // verbatim and skip the localhost autodetect below — the operator has
- // told us exactly where the API lives. Read the env var directly (not
- // client.getConfig().baseUrl) so we can distinguish "explicitly set"
- // from the client's window.location.origin fallback.
- const configuredBackendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
-
- let baseUrl: string;
-
- if (configuredBackendUrl) {
- baseUrl = configuredBackendUrl;
- } else if (isLocalhostUi()) {
- // No backend URL configured and the UI is on localhost: the client
- // would otherwise fall back to window.location.origin (the UI port,
- // e.g. 3010), which is wrong for the API. Local Docker exposes the
- // API on localhost:8000. WebSocket upgrades cannot pass through the
- // Next.js route-handler HTTP proxy, so connect to the API directly
- // when that port is reachable. A Next.js rewrite/proxy for the
- // upgrade was considered, but we keep the WebRTC signaling path
- // direct so signaling and the API's ICE/WebRTC handling terminate
- // at the same local endpoint.
- const localhostApiReachable = await probeLocalhostApi();
-
- if (!localhostApiReachable) {
- throw new Error('Dograh API is not reachable at http://localhost:8000. Ensure the api container is running and port 8000 is published.');
- }
-
- baseUrl = LOCALHOST_API_BASE_URL;
- } else {
- // Same-origin deployment: UI and API share an origin.
- baseUrl = client.getConfig().baseUrl || 'http://127.0.0.1:8000';
- }
-
- // Convert HTTP to WS protocol
+ const getWebSocketUrl = useCallback(() => {
+ // Single source of truth for the browser→API base URL: the centrally
+ // resolved API client config (NEXT_PUBLIC_BACKEND_URL → the backend
+ // endpoint reported by /health → window.location.origin), seeded by
+ // createClientConfig and upgraded by AppConfigProvider. The backend now
+ // reports the endpoint it runs on, so the old localhost autodetect that
+ // forced :8000 (back when an unset endpoint fell through to the UI origin)
+ // is no longer needed.
+ const baseUrl = client.getConfig().baseUrl || resolveBrowserBackendUrl();
const wsUrl = baseUrl.replace(/^http/, 'ws');
return `${wsUrl}/api/v1/ws/signaling/${workflowId}/${workflowRunId}?token=${accessToken}`;
}, [workflowId, workflowRunId, accessToken]);
@@ -352,7 +299,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
};
const connectWebSocket = useCallback(async () => {
- const wsUrl = await getWebSocketUrl();
+ const wsUrl = getWebSocketUrl();
return new Promise((resolve, reject) => {
logger.info(`Connecting to WebSocket: ${wsUrl}`);
diff --git a/ui/src/components/MCPSection.tsx b/ui/src/components/MCPSection.tsx
index 6412641f..00c637a4 100644
--- a/ui/src/components/MCPSection.tsx
+++ b/ui/src/components/MCPSection.tsx
@@ -6,22 +6,41 @@ import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
+import { useAppConfig } from "@/context/AppConfigContext";
+import { resolveBrowserBackendUrl } from "@/lib/apiClient";
+
+const MCP_PATH = "/api/v1/mcp/";
export function MCPSection() {
- const backendUrl =
- process.env.NEXT_PUBLIC_BACKEND_URL ||
- (typeof window !== "undefined" ? window.location.origin : "");
- const endpoint = `${backendUrl}/api/v1/mcp/`;
+ const { config } = useAppConfig();
+ // Backend URL: the address the deployment runs on (a private IP when the backend
+ // sits on one). Tunnel URL, when present: the publicly reachable Cloudflare tunnel
+ // URL externally-hosted assistants should use to reach an otherwise-private host.
+ const backendUrl = resolveBrowserBackendUrl(config?.backendApiEndpoint);
+ const tunnelUrl = config?.tunnelUrl ?? null;
- const [endpointCopied, setEndpointCopied] = useState(false);
+ const endpoints = [
+ ...(tunnelUrl
+ ? [
+ {
+ key: "tunnel",
+ label: "Public URL (Cloudflare tunnel)",
+ url: `${tunnelUrl}${MCP_PATH}`,
+ },
+ ]
+ : []),
+ { key: "backend", label: "Backend URL", url: `${backendUrl}${MCP_PATH}` },
+ ];
- const handleCopy = async (
- value: string,
- setter: (v: boolean) => void,
- ) => {
+ const [copiedKey, setCopiedKey] = useState(null);
+
+ const handleCopy = async (value: string, key: string) => {
await navigator.clipboard.writeText(value);
- setter(true);
- setTimeout(() => setter(false), 2000);
+ setCopiedKey(key);
+ setTimeout(
+ () => setCopiedKey((current) => (current === key ? null : current)),
+ 2000,
+ );
};
return (
@@ -39,23 +58,40 @@ export function MCPSection() {
Get your API key
-
-
- {endpoint}
-
-
+
+ {endpoints.map(({ key, label, url }) => (
+
+ {endpoints.length > 1 && (
+
+ {label}
+
+ )}
+
+
+ {url}
+
+
+
+
+ ))}
+ {tunnelUrl && (
+
+ Use the public URL from externally-hosted assistants; the backend URL
+ works from the deployment's own network.
+
+ )}
diff --git a/ui/src/context/AppConfigContext.tsx b/ui/src/context/AppConfigContext.tsx
index 7cf98205..af8256c2 100644
--- a/ui/src/context/AppConfigContext.tsx
+++ b/ui/src/context/AppConfigContext.tsx
@@ -2,6 +2,9 @@
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
+import { client } from '@/client/client.gen';
+import { resolveBrowserBackendUrl } from '@/lib/apiClient';
+
type BackendStatus = 'reachable' | 'unreachable';
interface AppConfig {
@@ -14,6 +17,11 @@ interface AppConfig {
// Public URL when the deployment is reached through a Cloudflare tunnel
// (host has no public IP); null for a directly-reachable deployment.
tunnelUrl: string | null;
+ // The URL the backend reports it is running on (via /health). This is the
+ // address the browser reaches the backend at — a private IP when the backend
+ // runs on one. null until /health is reached. Used to resolve the API client
+ // base URL; distinct from tunnelUrl, which is only for external consumers.
+ backendApiEndpoint: string | null;
backendStatus: BackendStatus;
backendUrl: string;
backendMessage: string | null;
@@ -33,6 +41,7 @@ const defaultConfig: AppConfig = {
turnEnabled: false,
forceTurnRelay: false,
tunnelUrl: null,
+ backendApiEndpoint: null,
backendStatus: 'unreachable',
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL || 'unknown',
backendMessage: process.env.NEXT_PUBLIC_BACKEND_URL
@@ -60,6 +69,21 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
const backendUrl = typeof backend.url === 'string' && backend.url.length > 0
? backend.url
: defaultConfig.backendUrl;
+ const backendApiEndpoint = typeof data.backendApiEndpoint === 'string' && data.backendApiEndpoint.length > 0
+ ? data.backendApiEndpoint
+ : null;
+
+ // createClientConfig seeds the API client base URL before /health is
+ // known. Now that the backend has reported the endpoint it runs on,
+ // re-apply the single browser→API preference order so all SDK calls
+ // (and anything reading client.getConfig().baseUrl) hit it directly —
+ // window.location.origin would be wrong when the API is served from a
+ // different host/port. resolveBrowserBackendUrl keeps NEXT_PUBLIC_BACKEND_URL
+ // ahead of the reported endpoint. Guard on a present endpoint so a
+ // transient /health failure never downgrades a good base URL to origin.
+ if (backendApiEndpoint) {
+ client.setConfig({ baseUrl: resolveBrowserBackendUrl(backendApiEndpoint) });
+ }
setConfig({
uiVersion: data.ui || 'dev',
@@ -69,6 +93,7 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
turnEnabled: Boolean(data.turnEnabled),
forceTurnRelay: Boolean(data.forceTurnRelay),
tunnelUrl: typeof data.tunnelUrl === 'string' ? data.tunnelUrl : null,
+ backendApiEndpoint,
backendStatus,
backendUrl,
backendMessage: typeof backend.message === 'string' && backend.message.length > 0
diff --git a/ui/src/lib/apiClient.ts b/ui/src/lib/apiClient.ts
index d0268206..4136cd55 100644
--- a/ui/src/lib/apiClient.ts
+++ b/ui/src/lib/apiClient.ts
@@ -5,6 +5,31 @@ export function getServerBackendUrl() {
return process.env.BACKEND_URL || 'http://api:8000';
}
+/**
+ * Resolve the base URL the browser should use to reach the backend API.
+ *
+ * Precedence:
+ * 1. NEXT_PUBLIC_BACKEND_URL — explicit build-time operator config, always wins.
+ * 2. backendApiEndpoint — the URL the backend reports it is running on via /health
+ * (surfaced through AppConfigContext). This is the address the browser actually
+ * reaches the backend at; for a backend on a private IP it is that private IP.
+ * Unknown at module init, so createClientConfig seeds without it and
+ * AppConfigProvider upgrades the client once /health resolves.
+ * 3. window.location.origin — same-origin public deployment.
+ *
+ * This is the browser→API order. It is intentionally NOT tunnel-aware: the
+ * Cloudflare tunnel URL is only for externally-hosted consumers (telephony
+ * webhooks, MCP, external API triggers) that cannot reach a private IP — see
+ * resolveWebhookBaseUrl.
+ */
+export function resolveBrowserBackendUrl(backendApiEndpoint?: string | null): string {
+ return (
+ process.env.NEXT_PUBLIC_BACKEND_URL ||
+ backendApiEndpoint ||
+ (typeof window !== 'undefined' ? window.location.origin : '')
+ );
+}
+
export const createClientConfig: CreateClientConfig = (config) => {
// Use different URLs for server-side vs client-side
const isServer = typeof window === 'undefined';
@@ -13,7 +38,10 @@ export const createClientConfig: CreateClientConfig = (config) => {
if (isServer) {
baseUrl = getServerBackendUrl();
} else {
- baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin;
+ // The backend-reported endpoint is not known yet at module init;
+ // AppConfigProvider upgrades the client base URL once /health reports it
+ // (when no explicit NEXT_PUBLIC_BACKEND_URL is configured).
+ baseUrl = resolveBrowserBackendUrl();
}
return {