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
5 changes: 5 additions & 0 deletions .changeset/twirp-keep-base-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'livekit-server-sdk': patch
---

Keep the base path of the server URL in API requests, so a server reached under a path (e.g. `https://example.com/livekit` behind a reverse proxy) is called at `https://example.com/livekit/twirp/...` instead of `https://example.com/twirp/...`, as the Go and Python server SDKs do. A URL that carries a path the server does not serve the API under should drop that path.
86 changes: 86 additions & 0 deletions packages/livekit-server-sdk/src/TwirpRPC.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,89 @@ describe('request id', () => {
expect(new Set(ids).size).toBe(1);
});
});

describe('request url', () => {
afterEach(() => {
vi.restoreAllMocks();
});

const requestedUrl = async (host: string, prefix?: string) => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) } as unknown as Response);
const rpc = new TwirpRpc(host, 'livekit', { failover: false, prefix });
await rpc.request('RoomService', 'ListRooms', {}, {});
return `${fetchSpy.mock.calls[0][0]}`;
};

// A server behind a reverse proxy is often reached under a path.
it.each([
['https://example.com', 'https://example.com/twirp/livekit.RoomService/ListRooms'],
['https://example.com/', 'https://example.com/twirp/livekit.RoomService/ListRooms'],
[
'https://example.com/livekit',
'https://example.com/livekit/twirp/livekit.RoomService/ListRooms',
],
[
'https://example.com/livekit/',
'https://example.com/livekit/twirp/livekit.RoomService/ListRooms',
],
[
'wss://example.com/livekit',
'https://example.com/livekit/twirp/livekit.RoomService/ListRooms',
],
])('keeps the base path of %s', async (host, expected) => {
expect(await requestedUrl(host)).toBe(expected);
});

it('joins a custom prefix with or without a leading slash', async () => {
const expected = 'https://example.com/livekit/rpc/livekit.RoomService/ListRooms';
expect(await requestedUrl('https://example.com/livekit', '/rpc')).toBe(expected);
vi.restoreAllMocks();
expect(await requestedUrl('https://example.com/livekit', 'rpc')).toBe(expected);
});

it('drops a query string or fragment on the host', async () => {
expect(await requestedUrl('https://example.com/livekit?region=x#y')).toBe(
'https://example.com/livekit/twirp/livekit.RoomService/ListRooms',
);
});

// Fallback regions are bare origins, so a retry goes to the region's root.
it('sends a failover attempt to the region origin', async () => {
let attempt = 0;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
if (`${input}`.endsWith('/settings/regions')) {
return {
ok: true,
status: 200,
headers: { get: () => 'max-age=0' },
json: async () => ({ regions: [{ url: 'https://r1.basepath.livekit.cloud' }] }),
} as unknown as Response;
}
attempt += 1;
return (attempt < 2
? {
ok: false,
status: 503,
statusText: 'Service Unavailable',
headers: { get: () => null },
text: async () => 'unavailable',
}
: { ok: true, status: 200, json: async () => ({}) }) as unknown as Response;
});

const rpc = new TwirpRpc('https://primary.basepath.livekit.cloud/livekit', 'livekit', {
failoverBackoffMs: 0,
});
await rpc.request('RoomService', 'ListRooms', {}, {});

const urls = fetchSpy.mock.calls
.map(([input]) => `${input}`)
.filter((url) => !url.endsWith('/settings/regions'));
expect(urls).toEqual([
'https://primary.basepath.livekit.cloud/livekit/twirp/livekit.RoomService/ListRooms',
'https://r1.basepath.livekit.cloud/twirp/livekit.RoomService/ListRooms',
]);
});
});
15 changes: 14 additions & 1 deletion packages/livekit-server-sdk/src/TwirpRPC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ export class TwirpRpc {
let response: Response | undefined;
let transportError: unknown;
try {
response = await fetch(new URL(path, current), init);
response = await fetch(twirpUrl(current, path), init);
} catch (e) {
transportError = e;
}
Expand Down Expand Up @@ -254,6 +254,19 @@ export class TwirpRpc {
}
}

/**
* Appends the Twirp path to the host's own path. Resolving it with `new URL(path, host)`
* would replace a base path on the host (e.g. a server behind a reverse proxy at
* https://example.com/livekit), since the Twirp path is absolute.
*/
function twirpUrl(host: string, path: string): URL {
const url = new URL(host);
url.pathname = `${url.pathname.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
url.search = '';
url.hash = '';
return url;
}

/** Builds a TwirpError from a non-2xx response, mirroring Twirp's JSON error shape. */
async function toTwirpError(response: Response): Promise<TwirpError> {
const isJson = response.headers.get('content-type') === 'application/json';
Expand Down
Loading