diff --git a/src/state/CallViewModel/localMember/RtcTransportAutoDiscovery.test.ts b/src/state/CallViewModel/localMember/RtcTransportAutoDiscovery.test.ts new file mode 100644 index 00000000..9314b993 --- /dev/null +++ b/src/state/CallViewModel/localMember/RtcTransportAutoDiscovery.test.ts @@ -0,0 +1,233 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { + beforeEach, + describe, + expect, + it, + type MockedObject, + vi, +} from "vitest"; +import { type IClientWellKnown, MatrixError } from "matrix-js-sdk"; +import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; +import { + type LivekitTransportConfig, + type Transport, +} from "matrix-js-sdk/lib/matrixrtc"; + +import type { ResolvedConfigOptions } from "../../../config/ConfigOptions.ts"; +import { + RtcTransportAutoDiscovery, + type RtcTransportAutoDiscoveryProps, +} from "./RtcTransportAutoDiscovery.ts"; + +type DiscoveryClient = RtcTransportAutoDiscoveryProps["client"]; + +const backendTransport: LivekitTransportConfig = { + type: "livekit", + livekit_service_url: "https://backend.example.org", +}; + +const wellKnownTransport: LivekitTransportConfig = { + type: "livekit", + livekit_service_url: "https://well-known.example.org", +}; + +function makeClient(): MockedObject { + return { + getDomain: vi.fn().mockReturnValue("example.org"), + baseUrl: "https://matrix.example.org", + _unstable_getRTCTransports: vi.fn().mockResolvedValue([]), + getAccessToken: vi.fn().mockReturnValue("access_token"), + getOpenIdToken: vi.fn(), + getDeviceId: vi.fn(), + } as unknown as MockedObject; +} + +function makeResolvedConfig(livekitServiceUrl?: string): ResolvedConfigOptions { + return { + livekit: livekitServiceUrl + ? { + livekit_service_url: livekitServiceUrl, + } + : undefined, + } as ResolvedConfigOptions; +} + +function makeWellKnown(rtcFoci?: Transport[]): IClientWellKnown { + return { + "org.matrix.msc4143.rtc_foci": rtcFoci, + } as unknown as IClientWellKnown; +} + +describe("RtcTransportAutoDiscovery", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + const VALID_TEST_CASES: Array<{ transports: Transport[] }> = [ + { transports: [backendTransport] }, + // will pick the first livekit transport in the list, even if there are other non-livekit transports + { transports: [{ type: "not_livekit" }, backendTransport] }, + ]; + it.each(VALID_TEST_CASES)( + "prefers backend transport over well-known and app config $transports", + async ({ transports }) => { + // it("prefers backend transport over well-known and app config", async () => { + const client = makeClient(); + client._unstable_getRTCTransports.mockResolvedValue(transports); + + const wellKnownFetcher = vi + .fn<(domain: string) => Promise>() + .mockResolvedValue(makeWellKnown([wellKnownTransport])); + + const discovery = new RtcTransportAutoDiscovery({ + client, + resolvedConfig: makeResolvedConfig("https://config.example.org"), + wellKnownFetcher, + logger: rootLogger, + }); + + await expect( + discovery.discoverPreferredTransport(), + ).resolves.toStrictEqual(backendTransport); + + expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(1); + expect(wellKnownFetcher).not.toHaveBeenCalled(); + }, + ); + + it("Retries limit_exceeded backend transport over well-known", async () => { + const client = makeClient(); + client._unstable_getRTCTransports + .mockRejectedValueOnce( + new MatrixError( + { + errcode: "M_LIMIT_EXCEEDED", + error: "Too many requests", + retry_after_ms: 100, + }, + 429, + ), + ) + .mockResolvedValue([backendTransport]); + + const wellKnownFetcher = vi + .fn<(domain: string) => Promise>() + .mockResolvedValue(makeWellKnown([wellKnownTransport])); + + const discovery = new RtcTransportAutoDiscovery({ + client, + resolvedConfig: makeResolvedConfig("https://config.example.org"), + wellKnownFetcher, + logger: rootLogger, + }); + + await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual( + backendTransport, + ); + + expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(2); + expect(wellKnownFetcher).not.toHaveBeenCalled(); + }); + + const INVALID_TEST_CASES: Array<{ transports: Transport[] }> = [ + { transports: [] }, + { transports: [{ type: "not_livekit" }] }, + ]; + it.each(INVALID_TEST_CASES)( + "falls back to well-known when backend has no (valid) livekit transports $transports", + async ({ transports }) => { + const client = makeClient(); + client._unstable_getRTCTransports.mockResolvedValue(transports); + + const wellKnownFetcher = vi + .fn<(domain: string) => Promise>() + .mockResolvedValue(makeWellKnown([wellKnownTransport])); + + const discovery = new RtcTransportAutoDiscovery({ + client, + resolvedConfig: makeResolvedConfig("https://config.example.org"), + wellKnownFetcher, + logger: rootLogger, + }); + + await expect( + discovery.discoverPreferredTransport(), + ).resolves.toStrictEqual(wellKnownTransport); + + expect(wellKnownFetcher).toHaveBeenCalledWith("example.org"); + }, + ); + + it("skips backend discovery in widget mode and uses well-known", async () => { + const client = makeClient(); + // widget mode is detected by the absence of an access token + client.getAccessToken.mockReturnValue(null); + + const wellKnownFetcher = vi + .fn<(domain: string) => Promise>() + .mockResolvedValue(makeWellKnown([wellKnownTransport])); + + const discovery = new RtcTransportAutoDiscovery({ + client, + resolvedConfig: makeResolvedConfig("https://config.example.org"), + wellKnownFetcher, + logger: rootLogger, + }); + + await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual( + wellKnownTransport, + ); + + expect(client._unstable_getRTCTransports).not.toHaveBeenCalled(); + expect(wellKnownFetcher).toHaveBeenCalledWith("example.org"); + }); + + it("falls back to app config when backend fails and well-known has no rtc_foci", async () => { + const client = makeClient(); + client._unstable_getRTCTransports.mockRejectedValue( + new MatrixError({ errcode: "M_UNKNOWN" }, 404), + ); + + const wellKnownFetcher = vi + .fn<(domain: string) => Promise>() + .mockResolvedValue({} as IClientWellKnown); + + const discovery = new RtcTransportAutoDiscovery({ + client, + resolvedConfig: makeResolvedConfig("https://config.example.org"), + wellKnownFetcher, + logger: rootLogger, + }); + + await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual( + { + type: "livekit", + livekit_service_url: "https://config.example.org", + }, + ); + }); + + it("returns null when backend, well-known and config are all unavailable", async () => { + const client = makeClient(); + client._unstable_getRTCTransports.mockResolvedValue([]); + + const wellKnownFetcher = vi + .fn<(domain: string) => Promise>() + .mockResolvedValue({} as IClientWellKnown); + + const discovery = new RtcTransportAutoDiscovery({ + client, + resolvedConfig: makeResolvedConfig(undefined), + wellKnownFetcher, + logger: rootLogger, + }); + + await expect(discovery.discoverPreferredTransport()).resolves.toBeNull(); + }); +}); diff --git a/src/state/CallViewModel/localMember/RtcTransportAutoDiscovery.ts b/src/state/CallViewModel/localMember/RtcTransportAutoDiscovery.ts new file mode 100644 index 00000000..6d2bac46 --- /dev/null +++ b/src/state/CallViewModel/localMember/RtcTransportAutoDiscovery.ts @@ -0,0 +1,172 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ +import { + isLivekitTransportConfig, + type LivekitTransportConfig, +} from "matrix-js-sdk/lib/matrixrtc"; +import { type IClientWellKnown, type MatrixClient } from "matrix-js-sdk"; +import { type Logger } from "matrix-js-sdk/lib/logger"; + +import type { ResolvedConfigOptions } from "../../../config/ConfigOptions.ts"; +import { doNetworkOperationWithRetry } from "../../../utils/matrix.ts"; + +type TransportDiscoveryClient = Pick< + MatrixClient, + "getDomain" | "_unstable_getRTCTransports" | "getAccessToken" +>; + +export interface RtcTransportAutoDiscoveryProps { + client: TransportDiscoveryClient; + resolvedConfig: ResolvedConfigOptions; + wellKnownFetcher: (domain: string) => Promise; + logger: Logger; +} + +export class RtcTransportAutoDiscovery { + private readonly client: TransportDiscoveryClient; + private readonly resolvedConfig: ResolvedConfigOptions; + private readonly wellKnownFetcher: ( + domain: string, + ) => Promise; + private readonly logger: Logger; + + public constructor({ + client, + resolvedConfig, + wellKnownFetcher, + logger, + }: RtcTransportAutoDiscoveryProps) { + this.client = client; + this.resolvedConfig = resolvedConfig; + this.wellKnownFetcher = wellKnownFetcher; + this.logger = logger.getChild("[RtcTransportAutoDiscovery]"); + } + + public async discoverPreferredTransport(): Promise { + // 1) backend transports + const backendTransport = await this.tryBackendTransports(); + if (backendTransport) { + this.logger.info( + `Found backend transport: ${backendTransport.livekit_service_url}`, + ); + return backendTransport; + } + + this.logger.info("No backend transport found, falling back to well-known"); + // 2) .well-known transports + const wellKnownTransport = await this.tryWellKnownTransports(); + if (wellKnownTransport) { + this.logger.info( + `Found .well-known transport: ${wellKnownTransport.livekit_service_url}`, + ); + return wellKnownTransport; + } + + this.logger.info( + "No .well-known transport found, falling back to app config", + ); + + // 3) app config URL + const configTransport = this.tryConfigTransport(); + if (configTransport) { + this.logger.info( + `Found app config transport: ${configTransport.livekit_service_url}`, + ); + return configTransport; + } + + return null; + } + + /** + * Fetches the first rtc_foci from the backend. + * This will not throw errors, but instead just log them and return null if the expected config is not found or malformed. + * @private + */ + private async tryBackendTransports(): Promise { + const client = this.client; + // MSC4143: Attempt to fetch transports from backend. + // TODO: Workaround for an issue in the js-sdk RoomWidgetClient that + // is not yet implementing _unstable_getRTCTransports properly (via widget API new action). + // For now we just skip this call if we are in a widget. + // In widget mode the client is a `RoomWidgetClient` which has no access token (it is using the widget API). + // Could be removed once the js-sdk is fixed (https://github.com/matrix-org/matrix-js-sdk/issues/5245) + const isSPA = !!client.getAccessToken(); + if (isSPA && "_unstable_getRTCTransports" in client) { + this.logger.info("First try to use getRTCTransports end point ..."); + try { + const transportList = await doNetworkOperationWithRetry(async () => + client._unstable_getRTCTransports(), + ); + const first = transportList.filter(isLivekitTransportConfig)[0]; + if (first) { + return first; + } else { + this.logger.info( + `No livekit transport found in getRTCTransports end point`, + transportList, + ); + } + } catch (ex) { + this.logger.info(`Failed to use getRTCTransports end point: ${ex}`); + } + } else { + this.logger.debug(`getRTCTransports end point not available`); + } + + return null; + } + + /** + * Fetches the first rtc_foci from the .well-known/matrix/client. + * This will not throw errors, but instead just log them and return null if the expected config is not found or malformed. + * @private + */ + private async tryWellKnownTransports(): Promise { + // Legacy MSC4143 (to be removed) WELL_KNOWN: Prioritize the .well-known/matrix/client, if available. + const client = this.client; + const domain = client.getDomain(); + if (domain) { + // we use AutoDiscovery instead of relying on the MatrixClient having already + // been fully configured and started + + const wellKnownFoci = await this.wellKnownFetcher(domain); + + const fociConfig = wellKnownFoci["org.matrix.msc4143.rtc_foci"]; + if (fociConfig) { + if (!Array.isArray(fociConfig)) { + this.logger.warn( + `org.matrix.msc4143.rtc_foci is not an array in .well-known`, + ); + } else { + return fociConfig[0]; + } + } else { + this.logger.info( + `No .well-known "org.matrix.msc4143.rtc_foci" found for ${domain}`, + wellKnownFoci, + ); + } + } else { + // Should never happen, but just in case + this.logger.warn(`No domain configured for client`); + } + + return null; + } + + private tryConfigTransport(): LivekitTransportConfig | null { + const url = this.resolvedConfig.livekit?.livekit_service_url; + if (url) { + return { + type: "livekit", + livekit_service_url: url, + }; + } + return null; + } +}