diff --git a/examples/react-native-expo/.env.example b/examples/react-native-expo/.env.example index e43d97e2..320f5ce6 100644 --- a/examples/react-native-expo/.env.example +++ b/examples/react-native-expo/.env.example @@ -1,2 +1,8 @@ # Set your ElevenLabs Agent ID here -EXPO_PUBLIC_AGENT_ID= \ No newline at end of file +EXPO_PUBLIC_AGENT_ID= + +# Optional: point to a self-hosted / in-VPC bridge instead of ElevenLabs cloud. +# WebRTC-only: sets both the token endpoint origin and the LiveKit server URL. +# Ignored for WebSocket sessions. +# Example: EXPO_PUBLIC_SERVER_URL=https://bridge.vpc.example.com +EXPO_PUBLIC_SERVER_URL= \ No newline at end of file diff --git a/examples/react-native-expo/App.tsx b/examples/react-native-expo/App.tsx index bb090228..07f581a8 100644 --- a/examples/react-native-expo/App.tsx +++ b/examples/react-native-expo/App.tsx @@ -58,6 +58,10 @@ const ConversationScreen = () => { connectionType, userId: "demo-user", textOnly: isTextOnly || undefined, + ...(connectionType === "webrtc" && + process.env.EXPO_PUBLIC_SERVER_URL && { + serverUrl: process.env.EXPO_PUBLIC_SERVER_URL, + }), }); }; diff --git a/packages/client/src/utils/BaseConnection.ts b/packages/client/src/utils/BaseConnection.ts index 0429501b..a60c690b 100644 --- a/packages/client/src/utils/BaseConnection.ts +++ b/packages/client/src/utils/BaseConnection.ts @@ -30,6 +30,15 @@ export type BaseSessionConfig = { origin?: string; authorization?: string; livekitUrl?: string; + /** + * Convenience URL for self-hosted / in-VPC WebRTC deployments. Derives both + * the token endpoint origin and the LiveKit WebSocket URL from a single base + * URL (e.g. `https://bridge.vpc.example.com` or `http://localhost:7880` for + * local dev). Explicit `origin` or `livekitUrl` values always take + * precedence. Only honoured by WebRTC connections; has no effect on WebSocket + * sessions. + */ + serverUrl?: string; overrides?: { agent?: { prompt?: ConversationConfigOverrideAgentPrompt; diff --git a/packages/client/src/utils/WebRTCConnection.test.ts b/packages/client/src/utils/WebRTCConnection.test.ts index fc59ae1d..41cf612d 100644 --- a/packages/client/src/utils/WebRTCConnection.test.ts +++ b/packages/client/src/utils/WebRTCConnection.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; // Track mock calls using a global object that can be accessed after mocking const mockCalls = { @@ -331,6 +331,150 @@ describe("WebRTCConnection", () => { }); }); + describe("serverUrl resolution", () => { + function setupRoomEvents(mockRoom: { on: Mock; once: Mock }) { + mockRoom.on.mockImplementation((event: string, callback: () => void) => { + if (event === "connected") queueMicrotask(callback); + }); + mockRoom.once.mockImplementation( + (event: string, callback: () => void) => { + if (event === "signalConnected") queueMicrotask(callback); + } + ); + } + + function mockTokenFetch() { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ token: "mock-livekit-token" }), + } as Response); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + it("fetches token from serverUrl origin and connects LiveKit to wss equivalent", async () => { + const mockRoom = new Room() as any; + setupRoomEvents(mockRoom); + const fetchMock = mockTokenFetch(); + + await WebRTCConnection.create({ + agentId: "test-agent", + serverUrl: "https://bridge.vpc.example.com", + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining( + "https://bridge.vpc.example.com/v1/convai/conversation/token" + ) + ); + expect(mockRoom.connect).toHaveBeenCalledWith( + "wss://bridge.vpc.example.com", + "mock-livekit-token" + ); + }); + + it("converts http:// serverUrl to ws:// for LiveKit and keeps http:// for token", async () => { + const mockRoom = new Room() as any; + setupRoomEvents(mockRoom); + const fetchMock = mockTokenFetch(); + + await WebRTCConnection.create({ + agentId: "test-agent", + serverUrl: "http://localhost:7880", + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining( + "http://localhost:7880/v1/convai/conversation/token" + ) + ); + expect(mockRoom.connect).toHaveBeenCalledWith( + "ws://localhost:7880", + "mock-livekit-token" + ); + }); + + it("converts ws:// serverUrl to http:// for token and keeps ws:// for LiveKit", async () => { + const mockRoom = new Room() as any; + setupRoomEvents(mockRoom); + const fetchMock = mockTokenFetch(); + + await WebRTCConnection.create({ + agentId: "test-agent", + serverUrl: "ws://localhost:7880", + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining( + "http://localhost:7880/v1/convai/conversation/token" + ) + ); + expect(mockRoom.connect).toHaveBeenCalledWith( + "ws://localhost:7880", + "mock-livekit-token" + ); + }); + + it("converts wss:// serverUrl to https:// for token and keeps wss:// for LiveKit", async () => { + const mockRoom = new Room() as any; + setupRoomEvents(mockRoom); + const fetchMock = mockTokenFetch(); + + await WebRTCConnection.create({ + agentId: "test-agent", + serverUrl: "wss://bridge.vpc.example.com", + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining( + "https://bridge.vpc.example.com/v1/convai/conversation/token" + ) + ); + expect(mockRoom.connect).toHaveBeenCalledWith( + "wss://bridge.vpc.example.com", + "mock-livekit-token" + ); + }); + + it("explicit origin takes precedence over serverUrl for token fetch", async () => { + const mockRoom = new Room() as any; + setupRoomEvents(mockRoom); + const fetchMock = mockTokenFetch(); + + await WebRTCConnection.create({ + agentId: "test-agent", + serverUrl: "https://bridge.vpc.example.com", + origin: "https://custom-origin.example.com", + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining( + "https://custom-origin.example.com/v1/convai/conversation/token" + ) + ); + expect(fetchMock).not.toHaveBeenCalledWith( + expect.stringContaining("bridge.vpc.example.com") + ); + }); + + it("explicit livekitUrl takes precedence over serverUrl for LiveKit connection", async () => { + const mockRoom = new Room() as any; + setupRoomEvents(mockRoom); + mockTokenFetch(); + + await WebRTCConnection.create({ + agentId: "test-agent", + serverUrl: "https://bridge.vpc.example.com", + livekitUrl: "wss://custom-livekit.example.com", + }); + + expect(mockRoom.connect).toHaveBeenCalledWith( + "wss://custom-livekit.example.com", + "mock-livekit-token" + ); + }); + }); + it.each([ { textOnly: true, shouldEnableMic: false }, { textOnly: false, shouldEnableMic: true }, diff --git a/packages/client/src/utils/WebRTCConnection.ts b/packages/client/src/utils/WebRTCConnection.ts index d48a6979..b3c39f24 100644 --- a/packages/client/src/utils/WebRTCConnection.ts +++ b/packages/client/src/utils/WebRTCConnection.ts @@ -41,9 +41,14 @@ const DEFAULT_LIVEKIT_WS_URL = "wss://livekit.rtc.elevenlabs.io"; const HTTPS_API_ORIGIN = "https://api.elevenlabs.io"; const AUDIO_VOLUME_THRESHOLD = 0.01; -// Convert WSS origin to HTTPS for API calls -function convertWssToHttps(origin: string): string { - return origin.replace(/^wss:\/\//, "https://"); +// Convert HTTP(S) URL to WS(S) for LiveKit connections +function convertToWss(url: string): string { + return url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://"); +} + +// Convert any WS(S) or HTTP(S) URL to HTTP(S) for API calls +function convertToHttps(url: string): string { + return url.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://"); } export type ConnectionConfig = SessionConfig & { @@ -230,6 +235,15 @@ export class WebRTCConnection extends BaseConnection { ): Promise { let conversationToken: string; + // serverUrl is a convenience that derives both the token origin and LiveKit + // URL from one base URL. Explicit origin/livekitUrl always take precedence. + const resolvedOrigin = + config.origin ?? + (config.serverUrl ? convertToHttps(config.serverUrl) : undefined); + const resolvedLivekitUrl = + config.livekitUrl ?? + (config.serverUrl ? convertToWss(config.serverUrl) : undefined); + // Handle different authentication scenarios if ("conversationToken" in config && config.conversationToken) { // Direct token provided @@ -238,8 +252,8 @@ export class WebRTCConnection extends BaseConnection { // Agent ID provided - fetch token from API try { const { name: source, version } = sourceInfo; - const configOrigin = config.origin ?? HTTPS_API_ORIGIN; - const origin = convertWssToHttps(configOrigin); //origin is wss, not https + const configOrigin = resolvedOrigin ?? HTTPS_API_ORIGIN; + const origin = convertToHttps(configOrigin); // normalize ws(s):// and http(s):// to http(s):// for token fetch let url = `${origin}/v1/convai/conversation/token?agent_id=${config.agentId}&source=${source}&version=${version}`; if (config.environment) { url += `&environment=${encodeURIComponent(config.environment)}`; @@ -300,7 +314,7 @@ export class WebRTCConnection extends BaseConnection { ); // Use configurable LiveKit URL or default if not provided - const livekitUrl = config.livekitUrl || DEFAULT_LIVEKIT_WS_URL; + const livekitUrl = resolvedLivekitUrl || DEFAULT_LIVEKIT_WS_URL; // Enable microphone on SignalConnected (before room.connect resolves). // The server may wait for the client to publish audio before fully