From ef095c9fa3ad38d34c1817e118bddc2a554d544d Mon Sep 17 00:00:00 2001 From: ANDROIDHASSAN <84630851+ANDROIDHASSAN@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:32:51 +0530 Subject: [PATCH] fix(server-sdk): keep the base path of the server URL in API requests new URL(path, host) with the absolute Twirp path replaced any path on the host, so a server behind a reverse proxy at https://example.com/livekit was called at https://example.com/twirp/... Append the Twirp path to the host's own path instead, as the Go and Python server SDKs do. --- .changeset/twirp-keep-base-path.md | 5 ++ .../livekit-server-sdk/src/TwirpRPC.test.ts | 86 +++++++++++++++++++ packages/livekit-server-sdk/src/TwirpRPC.ts | 15 +++- 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 .changeset/twirp-keep-base-path.md diff --git a/.changeset/twirp-keep-base-path.md b/.changeset/twirp-keep-base-path.md new file mode 100644 index 00000000..c86f8be5 --- /dev/null +++ b/.changeset/twirp-keep-base-path.md @@ -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. diff --git a/packages/livekit-server-sdk/src/TwirpRPC.test.ts b/packages/livekit-server-sdk/src/TwirpRPC.test.ts index f964465d..ebf546af 100644 --- a/packages/livekit-server-sdk/src/TwirpRPC.test.ts +++ b/packages/livekit-server-sdk/src/TwirpRPC.test.ts @@ -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', + ]); + }); +}); diff --git a/packages/livekit-server-sdk/src/TwirpRPC.ts b/packages/livekit-server-sdk/src/TwirpRPC.ts index f93a9d04..b78f915e 100644 --- a/packages/livekit-server-sdk/src/TwirpRPC.ts +++ b/packages/livekit-server-sdk/src/TwirpRPC.ts @@ -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; } @@ -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 { const isJson = response.headers.get('content-type') === 'application/json';