Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion examples/react-native-expo/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
# Set your ElevenLabs Agent ID here
EXPO_PUBLIC_AGENT_ID=
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=
4 changes: 4 additions & 0 deletions examples/react-native-expo/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
});
};

Expand Down
9 changes: 9 additions & 0 deletions packages/client/src/utils/BaseConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
146 changes: 145 additions & 1 deletion packages/client/src/utils/WebRTCConnection.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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);
});
Comment thread
a1anfan marked this conversation as resolved.
mockRoom.once.mockImplementation(
(event: string, callback: () => void) => {
if (event === "signalConnected") queueMicrotask(callback);
}
);
}

function mockTokenFetch() {
const fetchMock = vi.fn<typeof fetch>().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();

Comment thread
a1anfan marked this conversation as resolved.
await WebRTCConnection.create({
agentId: "test-agent",
serverUrl: "https://bridge.vpc.example.com",
});
Comment thread
a1anfan marked this conversation as resolved.

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 },
Expand Down
26 changes: 20 additions & 6 deletions packages/client/src/utils/WebRTCConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down Expand Up @@ -230,6 +235,15 @@ export class WebRTCConnection extends BaseConnection {
): Promise<WebRTCConnection> {
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
Expand All @@ -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)}`;
Expand Down Expand Up @@ -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
Expand Down
Loading