Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
7 changes: 6 additions & 1 deletion examples/react-native-expo/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
# 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.
# Sets both the token endpoint origin and the LiveKit server URL.
Comment thread
a1anfan marked this conversation as resolved.
Outdated
Comment thread
a1anfan marked this conversation as resolved.
Outdated
Comment thread
a1anfan marked this conversation as resolved.
Outdated
# Example: EXPO_PUBLIC_SERVER_URL=https://bridge.vpc.example.com
EXPO_PUBLIC_SERVER_URL=
3 changes: 3 additions & 0 deletions examples/react-native-expo/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ const ConversationScreen = () => {
connectionType,
userId: "demo-user",
textOnly: isTextOnly || undefined,
...(process.env.EXPO_PUBLIC_SERVER_URL && {
serverUrl: process.env.EXPO_PUBLIC_SERVER_URL,
}),
Comment thread
a1anfan marked this conversation as resolved.
Outdated
});
};

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
125 changes: 124 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,129 @@ 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("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
23 changes: 21 additions & 2 deletions packages/client/src/utils/WebRTCConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ 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 & {
onDebug?: (info: unknown) => void;
};
Expand Down Expand Up @@ -230,6 +240,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,7 +257,7 @@ 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 configOrigin = resolvedOrigin ?? HTTPS_API_ORIGIN;
const origin = convertWssToHttps(configOrigin); //origin is wss, not https
Comment thread
a1anfan marked this conversation as resolved.
Outdated
Comment thread
a1anfan marked this conversation as resolved.
Outdated
Comment thread
a1anfan marked this conversation as resolved.
Outdated
let url = `${origin}/v1/convai/conversation/token?agent_id=${config.agentId}&source=${source}&version=${version}`;
if (config.environment) {
Expand Down Expand Up @@ -300,7 +319,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