From ae2023e315614353590b7bcd7f61b46dbf719621 Mon Sep 17 00:00:00 2001 From: yogi6969 <87521145+yogi6969@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:20:07 +0530 Subject: [PATCH] fix(ui): proxy WebSocket signaling upgrade so local web calls work (#425) (#454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): proxy WebSocket signaling upgrade so local web calls work (#425) 1.34.0 replaced the next.config `/api/* -> BACKEND_URL` rewrite with the Route Handler `api/v1/[...path]/route.ts`. Route Handlers proxy HTTP fine (so `/api/v1/*` still 200s) but cannot upgrade WebSocket connections. The removed *rewrite* used to carry the upgrade, so without it the signaling socket (`/api/v1/ws/signaling/...`) has no proxy path and every local web call dies before WebRTC negotiation — the symptom reported in #425. nginx would proxy the upgrade but only runs in the `remote` compose profile, so local OSS deployments have nothing to carry it. Re-add a `beforeFiles` rewrite scoped to `/api/v1/ws/:path*` so the upgrade is proxied to the backend *before* the `[...path]` Route Handler can swallow it. HTTP `/api/v1/*` is untouched and still flows through the Route Handler (auth/cookie handling intact). Verified on a 1.34.0-derived source build: signaling WS now reports `[accepted]` / `connection open` server-side and `WebSocket connected` + `ICE connection state: connected` client-side; WebRTC negotiates end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(ui): use direct localhost WebSocket signaling --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Abhishek Kumar --- .../run/[runId]/hooks/useWebSocketRTC.tsx | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) 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 f93e5f1b..b8b19182 100644 --- a/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx +++ b/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx @@ -36,6 +36,34 @@ 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); @@ -108,10 +136,26 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia const currentAllowInterruptRef = useRef(undefined); const interruptWarningShownRef = useRef(false); - // Get WebSocket URL from client configuration - const getWebSocketUrl = useCallback(() => { - // Get base URL from client configuration - const baseUrl = client.getConfig().baseUrl || 'http://127.0.0.1:8000'; + const getWebSocketUrl = useCallback(async () => { + let baseUrl = client.getConfig().baseUrl || 'http://127.0.0.1:8000'; + + if (isLocalhostUi()) { + // Local Docker exposes the API on localhost:8000 while the UI runs + // on localhost:3010. WebSocket upgrades cannot pass through the + // Next.js route-handler HTTP proxy, so local browser calls should + // connect to the API directly when that port is available. 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; + } + // Convert HTTP to WS protocol const wsUrl = baseUrl.replace(/^http/, 'ws'); return `${wsUrl}/api/v1/ws/signaling/${workflowId}/${workflowRunId}?token=${accessToken}`; @@ -292,9 +336,10 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia return pc; }; - const connectWebSocket = useCallback(() => { + const connectWebSocket = useCallback(async () => { + const wsUrl = await getWebSocketUrl(); + return new Promise((resolve, reject) => { - const wsUrl = getWebSocketUrl(); logger.info(`Connecting to WebSocket: ${wsUrl}`); const ws = new WebSocket(wsUrl); @@ -307,7 +352,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia ws.onerror = (error) => { logger.error('WebSocket error:', error); - reject(error); + reject(new Error(`WebSocket connection failed at ${wsUrl}`)); }; ws.onclose = (event) => { @@ -774,6 +819,9 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia } } catch (error) { logger.error('Failed to start connection:', error); + if (error instanceof Error) { + setPermissionError(error.message); + } setConnectionStatus('failed'); } finally { setIsStarting(false);