diff --git a/frontend/__tests__/utils/websocket-url.test.ts b/frontend/__tests__/utils/websocket-url.test.ts new file mode 100644 index 0000000000..eab8acca29 --- /dev/null +++ b/frontend/__tests__/utils/websocket-url.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + extractBaseHost, + extractPathPrefix, + buildHttpBaseUrl, + buildWebSocketUrl, +} from "#/utils/websocket-url"; + +describe("websocket-url utilities", () => { + beforeEach(() => { + vi.stubGlobal("location", { + host: "localhost:3001", + protocol: "https:", + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("extractBaseHost", () => { + it("should extract host from a standard URL", () => { + const result = extractBaseHost( + "https://example.com/api/conversations/123", + ); + expect(result).toBe("example.com"); + }); + + it("should extract host with port from URL", () => { + const result = extractBaseHost( + "http://localhost:3000/api/conversations/123", + ); + expect(result).toBe("localhost:3000"); + }); + + it("should extract host from proxy deployment URL", () => { + const result = extractBaseHost( + "https://openhands.example.com/runtime/55313/api/conversations/abc123", + ); + expect(result).toBe("openhands.example.com"); + }); + + it("should return window.location.host for relative URLs", () => { + const result = extractBaseHost("/api/conversations/123"); + expect(result).toBe("localhost:3001"); + }); + + it("should return window.location.host for null, undefined, or invalid URL", () => { + expect(extractBaseHost(null)).toBe("localhost:3001"); + expect(extractBaseHost(undefined)).toBe("localhost:3001"); + expect(extractBaseHost("not-a-valid-url")).toBe("localhost:3001"); + }); + }); + + describe("extractPathPrefix", () => { + it("should return empty string for URL without path prefix", () => { + const result = extractPathPrefix( + "https://example.com/api/conversations/123", + ); + expect(result).toBe(""); + }); + + it("should extract path prefix from proxy deployment URL", () => { + const result = extractPathPrefix( + "https://openhands.example.com/runtime/55313/api/conversations/abc123", + ); + expect(result).toBe("/runtime/55313"); + }); + + it("should handle multiple path segments before /api/conversations", () => { + const result = extractPathPrefix( + "https://example.com/prefix/sub/path/api/conversations/123", + ); + expect(result).toBe("/prefix/sub/path"); + }); + + it("should remove trailing slash from path prefix", () => { + // This test ensures the function handles URLs where the path ends with / + const result = extractPathPrefix( + "https://example.com/runtime/55313/api/conversations/123", + ); + expect(result).not.toMatch(/\/$/); + }); + + it("should return empty string for relative URLs, null, undefined, or invalid URL", () => { + expect(extractPathPrefix("/api/conversations/123")).toBe(""); + expect(extractPathPrefix(null)).toBe(""); + expect(extractPathPrefix(undefined)).toBe(""); + expect(extractPathPrefix("not-a-valid-url")).toBe(""); + }); + }); + + describe("buildHttpBaseUrl", () => { + it("should build HTTP URL without path prefix", () => { + const result = buildHttpBaseUrl( + "https://example.com/api/conversations/123", + ); + expect(result).toBe("https://example.com"); + }); + + it("should build HTTP URL with path prefix for proxy deployment", () => { + const result = buildHttpBaseUrl( + "https://openhands.example.com/runtime/55313/api/conversations/abc123", + ); + expect(result).toBe("https://openhands.example.com/runtime/55313"); + }); + + it("should use http protocol when window.location.protocol is http:", () => { + vi.stubGlobal("location", { + host: "localhost:3001", + protocol: "http:", + }); + + const result = buildHttpBaseUrl( + "http://localhost:3000/api/conversations/123", + ); + expect(result).toBe("http://localhost:3000"); + }); + + it("should fallback to window.location for null URL", () => { + const result = buildHttpBaseUrl(null); + expect(result).toBe("https://localhost:3001"); + }); + }); + + describe("buildWebSocketUrl", () => { + it("should return null when conversationId is undefined or empty", () => { + expect( + buildWebSocketUrl( + undefined, + "https://example.com/api/conversations/123", + ), + ).toBeNull(); + expect( + buildWebSocketUrl("", "https://example.com/api/conversations/123"), + ).toBeNull(); + }); + + it("should build WebSocket URL without path prefix", () => { + const result = buildWebSocketUrl( + "conv-123", + "https://example.com/api/conversations/conv-123", + ); + expect(result).toBe("wss://example.com/sockets/events/conv-123"); + }); + + it("should build WebSocket URL with path prefix for proxy deployment", () => { + const result = buildWebSocketUrl( + "abc123", + "https://openhands.example.com/runtime/55313/api/conversations/abc123", + ); + expect(result).toBe( + "wss://openhands.example.com/runtime/55313/sockets/events/abc123", + ); + }); + + it("should use ws protocol when window.location.protocol is http:", () => { + vi.stubGlobal("location", { + host: "localhost:3001", + protocol: "http:", + }); + + const result = buildWebSocketUrl( + "conv-123", + "http://localhost:3000/api/conversations/conv-123", + ); + expect(result).toBe("ws://localhost:3000/sockets/events/conv-123"); + }); + + it("should fallback to window.location.host for null URL", () => { + const result = buildWebSocketUrl("conv-123", null); + expect(result).toBe("wss://localhost:3001/sockets/events/conv-123"); + }); + + it("should handle complex path prefixes", () => { + const result = buildWebSocketUrl( + "test-conv", + "https://app.example.com/org/team/runtime/12345/api/conversations/test-conv", + ); + expect(result).toBe( + "wss://app.example.com/org/team/runtime/12345/sockets/events/test-conv", + ); + }); + }); +}); diff --git a/frontend/src/utils/websocket-url.ts b/frontend/src/utils/websocket-url.ts index fa6b907d0e..0e72c24dc8 100644 --- a/frontend/src/utils/websocket-url.ts +++ b/frontend/src/utils/websocket-url.ts @@ -17,17 +17,39 @@ export function extractBaseHost( return window.location.host; } +/** + * Extracts the path prefix from conversation URL (everything before /api/conversations) + * This is needed for proxy deployments where agent-servers are accessed via paths like /runtime/{port}/ + * @param conversationUrl The conversation URL (e.g., "http://localhost:3000/runtime/55313/api/conversations/123") + * @returns Path prefix without trailing slash (e.g., "/runtime/55313") or empty string + */ +export function extractPathPrefix( + conversationUrl: string | null | undefined, +): string { + if (conversationUrl && !conversationUrl.startsWith("/")) { + try { + const url = new URL(conversationUrl); + const pathBeforeApi = url.pathname.split("/api/conversations")[0] || ""; + return pathBeforeApi.replace(/\/$/, ""); // Remove trailing slash + } catch { + return ""; + } + } + return ""; +} + /** * Builds the HTTP base URL for V1 API calls * @param conversationUrl The conversation URL containing host/port - * @returns HTTP base URL (e.g., "http://localhost:3000") + * @returns HTTP base URL (e.g., "http://localhost:3000" or "http://localhost:3000/runtime/55313") */ export function buildHttpBaseUrl( conversationUrl: string | null | undefined, ): string { const baseHost = extractBaseHost(conversationUrl); + const pathPrefix = extractPathPrefix(conversationUrl); const protocol = window.location.protocol === "https:" ? "https:" : "http:"; - return `${protocol}//${baseHost}`; + return `${protocol}//${baseHost}${pathPrefix}`; } /** @@ -45,10 +67,12 @@ export function buildWebSocketUrl( } const baseHost = extractBaseHost(conversationUrl); + const pathPrefix = extractPathPrefix(conversationUrl); - // Build WebSocket URL: ws://host:port/sockets/events/{conversationId} + // Build WebSocket URL: ws://host:port[/path-prefix]/sockets/events/{conversationId} + // The path prefix (e.g., /runtime/55313) is needed for proxy deployments // Note: Query params should be passed via the useWebSocket hook options const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - return `${protocol}//${baseHost}/sockets/events/${conversationId}`; + return `${protocol}//${baseHost}${pathPrefix}/sockets/events/${conversationId}`; }