[SDK] Move SFU authentication into livekit/auth and choose the endpoint by role

The OpenID flow moves to src/livekit/auth/openID.ts with the SFU config
types in a sibling file, takes an options object, and replaces the
MatrixRTC mode with the role we take on the transport plus the session's
slot ID instead of a hard-coded m.call#ROOM.

This changes behaviour: publishers on a livekit_service_url transport
always use the legacy /sfu/get endpoint, so that subscribers can predict
their user:device participant identity, and subscribers always use the
default /get_token endpoint. The MatrixRTC mode no longer influences which
endpoint is used.

On the SDK this lands as sdk/src/media-backend/livekit/auth/ with the connection map, the discriminated connection state and the client-server API flow of the following commits of element-hq/element-call#4331, which rewrite the same files:
- Model ConnectionState as a discriminated union
- Show the whole transport in debug UI and logs (SDK part)
- Let the publishing Connection authenticate itself
- Reduce ConnectionManager to a ConnectionMap
- Use the unstable LiveKit transport type
- Identify transports by server name as well as URLs
- Get SFU tokens and delegate delayed leaves through the MSC4195 CS API
- Fall back to get_token as subscriber on widget API errors
- Share a connection between homeservers using the same JWT service
- Make the auth choice mode independent again

Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
(cherry picked from commit adb380cd9ed108856e42d992fc03ced8c44f1e56)
This commit is contained in:
Robin
2026-10-13 13:00:58 +02:00
committed by Timo K.
parent de8b5d98ec
commit b4bd1f81a0
29 changed files with 2495 additions and 1525 deletions
+4 -4
View File
@@ -63,8 +63,8 @@ export {
} from "./src/media-backend/livekit/LivekitBackend"; } from "./src/media-backend/livekit/LivekitBackend";
// For a host that creates LiveKit tracks itself, such as a preview before joining // For a host that creates LiveKit tracks itself, such as a preview before joining
export { convertToLivekitProcessor } from "./src/media-backend/livekit/videoProcessor"; export { convertToLivekitProcessor } from "./src/media-backend/livekit/videoProcessor";
// For a developer panel to check a transport url before using it // For a developer panel to check a transport before using it
export { export {
getSFUConfigWithOpenID as authenticateWithTransport, getSFUConfig as authenticateWithTransport,
type TokenEndpoint, type GetSFUConfigParams as AuthenticateWithTransportParams,
} from "./src/media-backend/livekit/openIDSFU"; } from "./src/media-backend/livekit/auth";
+3 -8
View File
@@ -119,8 +119,9 @@ export function createRTCParticipation(
: `${userId}:${deviceId}`, : `${userId}:${deviceId}`,
}; };
const backend = createBackend(scope, client, options, matrixRTCMode, { const backend = createBackend(scope, client, options, {
roomId: room.roomId, roomId: room.roomId,
slotId: jsSdkSession.slotId,
ownMembershipIdentity, ownMembershipIdentity,
publish: options.publish, publish: options.publish,
encryptionSystem, encryptionSystem,
@@ -285,16 +286,10 @@ function createBackend(
scope: ObservableScope, scope: ObservableScope,
client: MatrixClient, client: MatrixClient,
{ backend, mediaQuality }: RTCParticipationOptions, { backend, mediaQuality }: RTCParticipationOptions,
matrixRTCMode: MatrixRTCMode,
context: MediaBackendContext, context: MediaBackendContext,
): MediaBackend { ): MediaBackend {
if (backend) return backend(scope, context); if (backend) return backend(scope, context);
return createLivekitBackend(scope, context, { return createLivekitBackend(scope, context, { client, mediaQuality });
client,
mediaQuality,
tokenEndpoint:
matrixRTCMode === MatrixRTCMode.Matrix_2_0 ? "msc4195" : "legacy",
});
} }
/** The slot's per-participant keys: the ones it already holds on subscribe, then each new one. */ /** The slot's per-participant keys: the ones it already holds on subscribe, then each new one. */
+5 -4
View File
@@ -10,12 +10,13 @@ Please see LICENSE in the repository root for full details.
* backend and other participants. Resolved by the host. * backend and other participants. Resolved by the host.
*/ */
export enum MatrixRTCMode { export enum MatrixRTCMode {
/** Multi-SFU transport, legacy JWT endpoint, state events. */ /** Multi-SFU transport, membership state events. */
Compatibility = "compatibility", Compatibility = "compatibility",
/** /**
* Multi-SFU transport with sticky events, a hashed RTC backend identity and * Multi-SFU transport with sticky membership events and a uuid member id.
* the new JWT endpoint for the local membership. Remote memberships always * The mode decides how the membership is written, not how the media
* try the new endpoint first, then the legacy one. * transport is authenticated with: that follows the transport's shape and
* our role on it.
*/ */
Matrix_2_0 = "matrix_2_0", Matrix_2_0 = "matrix_2_0",
} }
+5 -1
View File
@@ -36,13 +36,17 @@ export function createTransportRegistry(
return transport; return transport;
}; };
// Deduplicated: a backend may hold more than one connection to a transport,
// one per homeserver it has to authenticate through
const connected$ = scope.behavior( const connected$ = scope.behavior(
connections$.pipe( connections$.pipe(
map((connections) => map((connections) => [
...new Set(
connections connections
.filter(({ state }) => state === MediaConnectionState.Connected) .filter(({ state }) => state === MediaConnectionState.Connected)
.map(({ transport }) => get(transport)), .map(({ transport }) => get(transport)),
), ),
]),
), ),
); );
+2
View File
@@ -53,6 +53,8 @@ export interface MediaKey {
export interface MediaBackendContext { export interface MediaBackendContext {
roomId: string; roomId: string;
/** The slot the session is in, `application#id`; a token is issued for it. */
slotId: string;
ownMembershipIdentity: CallMembershipIdentityParts; ownMembershipIdentity: CallMembershipIdentityParts;
/** What the local member publishes at the join. */ /** What the local member publishes at the join. */
publish: PublishRequest[]; publish: PublishRequest[];
+118 -139
View File
@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
import { import {
afterEach, afterEach,
beforeEach,
describe, describe,
expect, expect,
it, it,
@@ -28,25 +27,28 @@ import {
} from "livekit-client"; } from "livekit-client";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import EventEmitter from "events"; import EventEmitter from "events";
import { type IOpenIDToken } from "matrix-js-sdk"; import { MatrixError } from "matrix-js-sdk";
import { logger, type Logger } from "matrix-js-sdk/lib/logger"; import { logger, type Logger } from "matrix-js-sdk/lib/logger";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import {
type LivekitGetTokenResponse,
type UnstableLivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc";
import { import {
Connection, Connection,
ConnectionState, type ConnectionState,
type ConnectionOpts, type ConnectionOpts,
} from "./Connection.ts"; } from "./Connection.ts";
import { ObservableScope } from "../../reactive/ObservableScope"; import { ObservableScope } from "../../reactive/ObservableScope";
import { type OpenIDClientParts } from "./openIDSFU"; import { type ClientGetTokenParts, type ClientOpenIDParts } from "./auth";
import { MatrixRTCError, FailToGetOpenIdToken } from "../../errors"; import { MatrixRTCError } from "../../errors";
import { testJWTToken } from "../../utils/test-fixtures"; import { testJWTToken } from "../../utils/test-fixtures";
import { ownMemberMock } from "../../utils/test"; import { ownMemberMock } from "../../utils/test";
import { mockRemoteParticipant } from "./test"; import { mockRemoteParticipant } from "./test";
let testScope: ObservableScope; let testScope: ObservableScope;
let client: MockedObject<OpenIDClientParts>; let client: MockedObject<ClientGetTokenParts & ClientOpenIDParts>;
let fakeLivekitRoom: MockedObject<LivekitRoom>; let fakeLivekitRoom: MockedObject<LivekitRoom>;
@@ -54,23 +56,39 @@ let localParticipantEventEmiter: EventEmitter;
let fakeLocalParticipant: MockedObject<LocalParticipant>; let fakeLocalParticipant: MockedObject<LocalParticipant>;
const ROOM_ID = "!roomID:example.org"; const ROOM_ID = "!roomID:example.org";
const SLOT_ID = "m.call#ROOM";
const livekitFocus: LivekitTransport = { const transport: UnstableLivekitTransport = {
livekit_service_url: "https://matrix-rtc.example.org/livekit/jwt",
type: "livekit", type: "livekit",
url: "wss://matrix-rtc.m.localhost/livekit/sfu",
}; };
/** The options of a subscriber connection to `transport`. */
function connectionOpts(): ConnectionOpts {
return {
client,
roomId: ROOM_ID,
slotId: SLOT_ID,
role: "subscriber",
transport,
serverName: "m.localhost",
scope: testScope,
ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom,
};
}
function setupTest(): void { function setupTest(): void {
testScope = new ObservableScope(); testScope = new ObservableScope();
client = vi.mocked<OpenIDClientParts>({ client = vi.mocked<ClientGetTokenParts & ClientOpenIDParts>({
getOpenIdToken: vi.fn().mockResolvedValue({ getOpenIdToken: vi.fn().mockResolvedValue({
access_token: "rYsmGUEwNjKgJYyeNUkZseJN", access_token: "rYsmGUEwNjKgJYyeNUkZseJN",
token_type: "Bearer", token_type: "Bearer",
matrix_server_name: "example.org", matrix_server_name: "example.org",
expires_in: 3600, expires_in: 3600,
}), }),
getDeviceId: vi.fn().mockReturnValue("ABCDEF"), _unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: testJWTToken }),
} as unknown as OpenIDClientParts); });
localParticipantEventEmiter = new EventEmitter(); localParticipantEventEmiter = new EventEmitter();
@@ -113,24 +131,7 @@ function setupTest(): void {
} }
function setupRemoteConnection(): Connection { function setupRemoteConnection(): Connection {
const opts: ConnectionOpts = { const opts = connectionOpts();
client: client,
roomId: ROOM_ID,
transport: livekitFocus,
scope: testScope,
ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom,
};
fetchMock.post(`${livekitFocus.livekit_service_url}/sfu/get`, () => {
return {
status: 200,
body: {
url: "wss://matrix-rtc.m.localhost/livekit/sfu",
jwt: testJWTToken,
},
};
});
fakeLivekitRoom.connect.mockImplementation(async (): Promise<void> => { fakeLivekitRoom.connect.mockImplementation(async (): Promise<void> => {
const changeEv = RoomEvent.ConnectionStateChanged; const changeEv = RoomEvent.ConnectionStateChanged;
@@ -153,60 +154,33 @@ afterEach(() => {
}); });
describe("Start connection states", () => { describe("Start connection states", () => {
beforeEach(() => {
fetchMock.post(
`https://matrix-rtc.example.org/livekit/jwt/get_token`,
() => {
return {
// Return a non-retryable error, if not, the retry logic will
// wait and fail the test with a timeout.
status: 404,
};
},
);
});
it("start in initialized state", () => { it("start in initialized state", () => {
setupTest(); setupTest();
const opts: ConnectionOpts = { const opts = connectionOpts();
client: client,
roomId: ROOM_ID,
transport: livekitFocus,
scope: testScope,
ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom,
};
const connection = new Connection(opts, logger); const connection = new Connection(opts, logger);
expect(connection.state$.getValue()).toEqual("Initialized"); expect(connection.state$.getValue()).toEqual({ state: "initialized" });
}); });
it("fail to getOpenId token then error state", async () => { it("fail to get SFU token then error state", async () => {
setupTest(); setupTest();
vi.useFakeTimers(); vi.useFakeTimers();
const opts: ConnectionOpts = { const opts = connectionOpts();
client: client,
roomId: ROOM_ID,
transport: livekitFocus,
scope: testScope,
ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom,
};
const connection = new Connection(opts, logger); const connection = new Connection(opts, logger);
const capturedStates: (ConnectionState | Error)[] = []; const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
const deferred = Promise.withResolvers<IOpenIDToken>(); const deferred = Promise.withResolvers<LivekitGetTokenResponse>();
client.getOpenIdToken.mockImplementation( client._unstable_getLivekitToken.mockImplementation(
async (): Promise<IOpenIDToken> => { async (): Promise<LivekitGetTokenResponse> => {
return await deferred.promise; return await deferred.promise;
}, },
); );
@@ -217,18 +191,20 @@ describe("Start connection states", () => {
let capturedState = capturedStates.pop(); let capturedState = capturedStates.pop();
expect(capturedState).toBeDefined(); expect(capturedState).toBeDefined();
expect(capturedState!).toEqual("FetchingConfig"); expect(capturedState).toEqual({ state: "authenticating" });
deferred.reject(new FailToGetOpenIdToken(new Error("Failed to get token"))); deferred.reject(new MatrixRTCError("Failed to get token"));
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
capturedState = capturedStates.pop(); capturedState = capturedStates.pop();
if (capturedState instanceof Error) { if (capturedState?.state === "error") {
expect(capturedState).toBeInstanceOf(FailToGetOpenIdToken); expect(capturedState.error.message).toEqual("Failed to get token");
expect(connection.transport).toEqual(livekitFocus); expect(connection.transport).toEqual(transport);
} else { } else {
expect.fail("Expected FailedToStart state but got " + capturedState); expect.fail(
"Expected error state but got " + JSON.stringify(capturedState),
);
} }
}); });
@@ -236,34 +212,26 @@ describe("Start connection states", () => {
setupTest(); setupTest();
vi.useFakeTimers(); vi.useFakeTimers();
const opts: ConnectionOpts = { const opts = connectionOpts();
client: client,
roomId: ROOM_ID,
transport: livekitFocus,
scope: testScope,
ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom,
};
const connection = new Connection(opts, logger); const connection = new Connection(opts, logger);
const capturedStates: (ConnectionState | Error)[] = []; const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
const deferredSFU = Promise.withResolvers<void>(); const deferredSFU = Promise.withResolvers<void>();
// mock the /sfu/get call client._unstable_getLivekitToken.mockImplementation(async () => {
fetchMock.post(`${livekitFocus.livekit_service_url}/sfu/get`, async () => {
await deferredSFU.promise; await deferredSFU.promise;
return { throw new MatrixError(
status: 500, {
body: {
errcode: "M_LOOKUP_FAILED", errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver", error: "Failed to look up user info from homeserver",
}, },
}; 500,
);
}); });
connection.start().catch(() => { connection.start().catch(() => {
@@ -272,7 +240,7 @@ describe("Start connection states", () => {
let capturedState = capturedStates.pop(); let capturedState = capturedStates.pop();
expect(capturedState).toBeDefined(); expect(capturedState).toBeDefined();
expect(capturedState).toEqual(ConnectionState.FetchingConfig); expect(capturedState).toEqual({ state: "authenticating" });
deferredSFU.resolve(); deferredSFU.resolve();
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
@@ -280,15 +248,17 @@ describe("Start connection states", () => {
capturedState = capturedStates.pop(); capturedState = capturedStates.pop();
if ( if (
capturedState instanceof MatrixRTCError && capturedState?.state === "error" &&
capturedState.cause instanceof Error capturedState.error.cause instanceof Error
) { ) {
expect(capturedState.cause.message).toContain( expect(capturedState.error.cause.message).toContain(
"Failed to look up user info from homeserver", "Failed to look up user info from homeserver",
); );
expect(connection.transport).toEqual(livekitFocus); expect(connection.transport).toEqual(transport);
} else { } else {
expect.fail("Expected FailedToStart state but got " + capturedState); expect.fail(
"Expected error state but got " + JSON.stringify(capturedState),
);
} }
}); });
@@ -296,35 +266,17 @@ describe("Start connection states", () => {
setupTest(); setupTest();
vi.useFakeTimers(); vi.useFakeTimers();
const opts: ConnectionOpts = { const opts = connectionOpts();
client: client,
roomId: ROOM_ID,
transport: livekitFocus,
scope: testScope,
ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom,
};
const connection = new Connection(opts, logger); const connection = new Connection(opts, logger);
const capturedStates: (ConnectionState | Error)[] = []; const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
const deferredSFU = Promise.withResolvers<void>(); const deferredSFU = Promise.withResolvers<void>();
// mock the /sfu/get call
fetchMock.post(`${livekitFocus.livekit_service_url}/sfu/get`, () => {
return {
status: 200,
body: {
url: "wss://matrix-rtc.m.localhost/livekit/sfu",
jwt: testJWTToken,
},
};
});
fakeLivekitRoom.connect.mockImplementation(async () => { fakeLivekitRoom.connect.mockImplementation(async () => {
await deferredSFU.promise; await deferredSFU.promise;
throw new Error("Failed to connect to livekit"); throw new Error("Failed to connect to livekit");
@@ -337,7 +289,7 @@ describe("Start connection states", () => {
let capturedState = capturedStates.pop(); let capturedState = capturedStates.pop();
expect(capturedState).toBeDefined(); expect(capturedState).toBeDefined();
expect(capturedState).toEqual(ConnectionState.FetchingConfig); expect(capturedState).toEqual({ state: "authenticating" });
deferredSFU.resolve(); deferredSFU.resolve();
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
@@ -345,13 +297,13 @@ describe("Start connection states", () => {
capturedState = capturedStates.pop(); capturedState = capturedStates.pop();
if ( if (
capturedState instanceof MatrixRTCError && capturedState?.state === "error" &&
capturedState.cause instanceof Error capturedState.error.cause instanceof Error
) { ) {
expect(capturedState.cause.message).toContain( expect(capturedState.error.cause.message).toContain(
"Failed to connect to livekit", "Failed to connect to livekit",
); );
expect(connection.transport).toEqual(livekitFocus); expect(connection.transport).toEqual(transport);
} else { } else {
expect.fail( expect.fail(
"Expected FailedToStart state but got " + JSON.stringify(capturedState), "Expected FailedToStart state but got " + JSON.stringify(capturedState),
@@ -365,7 +317,7 @@ describe("Start connection states", () => {
const connection = setupRemoteConnection(); const connection = setupRemoteConnection();
const capturedStates: (ConnectionState | Error)[] = []; const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
@@ -375,22 +327,59 @@ describe("Start connection states", () => {
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
const initialState = capturedStates.shift(); const initialState = capturedStates.shift();
expect(initialState).toEqual(ConnectionState.Initialized); expect(initialState).toEqual({ state: "initialized" });
const fetchingState = capturedStates.shift(); const fetchingState = capturedStates.shift();
expect(fetchingState).toEqual(ConnectionState.FetchingConfig); expect(fetchingState).toEqual({ state: "authenticating" });
const disconnectedState = capturedStates.shift(); const disconnectedState = capturedStates.shift();
expect(disconnectedState).toEqual(ConnectionState.LivekitDisconnected); expect(disconnectedState).toEqual({
state: "authenticated",
livekitState: LivekitConnectionState.Disconnected,
});
const connectingState = capturedStates.shift(); const connectingState = capturedStates.shift();
expect(connectingState).toEqual(ConnectionState.LivekitConnecting); expect(connectingState).toEqual({
state: "authenticated",
livekitState: LivekitConnectionState.Connecting,
});
const connectedState = capturedStates.shift(); const connectedState = capturedStates.shift();
expect(connectedState).toEqual(ConnectionState.LivekitConnected); expect(connectedState).toEqual({
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
});
});
it("authenticates a publisher on a livekit_service_url transport through the JWT service", async () => {
setupTest();
const serviceUrl = "https://matrix-rtc.example.org/livekit/jwt";
fetchMock.post(`${serviceUrl}/sfu/get`, {
status: 200,
body: { url: "wss://sfu", jwt: testJWTToken },
});
fakeLivekitRoom.connect.mockResolvedValue(undefined);
const connection = new Connection(
{
...connectionOpts(),
role: "publisher",
transport: { type: "livekit", livekit_service_url: serviceUrl },
},
logger,
);
await connection.start();
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
expect(fetchMock.called(`${serviceUrl}/sfu/get`)).toBe(true);
expect(fakeLivekitRoom.connect).toHaveBeenCalledWith(
"wss://sfu",
testJWTToken,
);
expect(connection.livekitAlias).toBe("!example_room_id");
}); });
it("stopping while connecting does not report an error", async () => { it("stopping while connecting does not report an error", async () => {
setupTest(); setupTest();
const connection = setupRemoteConnection(); const connection = setupRemoteConnection();
const capturedStates: (ConnectionState | Error)[] = []; const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
@@ -407,11 +396,11 @@ describe("Start connection states", () => {
pendingConnect.reject(new Error("Client initiated disconnect")); pendingConnect.reject(new Error("Client initiated disconnect"));
await stopping; await stopping;
// start() resolves rather than rejecting (it is not awaited by the // start() resolves rather than rejecting (it is not awaited by the caller,
// ConnectionManager, so a rejection would be unhandled). // so a rejection would be unhandled).
await expect(started).resolves.toBeUndefined(); await expect(started).resolves.toBeUndefined();
expect(capturedStates.at(-1)).toEqual(ConnectionState.Stopped); expect(capturedStates.at(-1)).toEqual({ state: "stopped" });
expect(capturedStates.some((st) => st instanceof Error)).toBe(false); expect(capturedStates.some((st) => st.state === "error")).toBe(false);
}); });
it("shutting down the scope should stop the connection", async () => { it("shutting down the scope should stop the connection", async () => {
@@ -544,17 +533,7 @@ describe("remote track logging", () => {
warn, warn,
error: vi.fn(), error: vi.fn(),
} as unknown as Logger; } as unknown as Logger;
new Connection( new Connection(connectionOpts(), testLogger);
{
client,
roomId: ROOM_ID,
transport: livekitFocus,
scope: testScope,
ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom,
},
testLogger,
);
info.mockClear(); // drop the constructor log line info.mockClear(); // drop the constructor log line
const bob = mockRemoteParticipant({ const bob = mockRemoteParticipant({
+76 -88
View File
@@ -13,6 +13,7 @@ import {
import { import {
ConnectionError, ConnectionError,
ConnectionErrorReason, ConnectionErrorReason,
type ConnectionState as LivekitConnectionState,
type Participant, type Participant,
type RemoteParticipant, type RemoteParticipant,
type RemoteTrackPublication, type RemoteTrackPublication,
@@ -22,16 +23,18 @@ import {
type Track, type Track,
type TrackPublication, type TrackPublication,
} from "livekit-client"; } from "livekit-client";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, map } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { import {
getSFUConfigWithOpenID, type ClientGetTokenParts,
type OpenIDClientParts, type ClientOpenIDParts,
type ConnectionRole,
getSFUConfig,
type SFUConfig, type SFUConfig,
} from "./openIDSFU"; } from "./auth";
import { type Behavior } from "../../reactive/Behavior"; import { type Behavior } from "../../reactive/Behavior";
import { type ObservableScope } from "../../reactive/ObservableScope"; import { type ObservableScope } from "../../reactive/ObservableScope";
import { import {
@@ -44,19 +47,20 @@ import {
} from "../../errors"; } from "../../errors";
export interface ConnectionOpts { export interface ConnectionOpts {
/**
* For the local transport we already do know the jwt token and url. We can reuse it.
* On top the local transport will send additional data to the jwt server to use delayed event delegation.
*/
existingSFUConfig?: SFUConfig;
/** The identity parts to use on this connection */ /** The identity parts to use on this connection */
ownMembershipIdentity: CallMembershipIdentityParts; ownMembershipIdentity: CallMembershipIdentityParts;
/** Whether we publish on this connection or only subscribe. */
role: ConnectionRole;
/** The media transport to connect to. */ /** The media transport to connect to. */
transport: LivekitTransport; transport: UnstableLivekitTransport;
/** The Matrix client to use for OpenID and SFU config requests. */ /** The homeserver the transport belongs to. */
client: OpenIDClientParts; serverName: string;
/** The room ID this connection is associated with. */ /** The Matrix client, for the token exchange. */
client: ClientGetTokenParts & ClientOpenIDParts;
/** The Matrix room the session is in. */
roomId: string; roomId: string;
/** The MatrixRTC slot the session is in. */
slotId: string;
/** The observable scope to use for this connection. */ /** The observable scope to use for this connection. */
scope: ObservableScope; scope: ObservableScope;
@@ -64,23 +68,15 @@ export interface ConnectionOpts {
livekitRoomFactory: () => LivekitRoom; livekitRoomFactory: () => LivekitRoom;
} }
export enum ConnectionState { export type ConnectionState =
/** The start state of a connection. It has been created but nothing has loaded yet. */ /** Created, nothing started yet. */
Initialized = "Initialized", | { state: "initialized" }
/** `start` has been called on the connection. It aquires the jwt info to conenct to the LK Room */ /** `start` was called; the SFU token is being fetched. */
FetchingConfig = "FetchingConfig", | { state: "authenticating" }
Stopped = "Stopped", /** The token is in hand; the LiveKit room's own state from here. */
/** The same as ConnectionState.Disconnected from `livekit-client` */ | { state: "authenticated"; livekitState: LivekitConnectionState }
LivekitDisconnected = "disconnected", | { state: "error"; error: MatrixRTCError }
/** The same as ConnectionState.Connecting from `livekit-client` */ | { state: "stopped" };
LivekitConnecting = "connecting",
/** The same as ConnectionState.Connected from `livekit-client` */
LivekitConnected = "connected",
/** The same as ConnectionState.Reconnecting from `livekit-client` */
LivekitReconnecting = "reconnecting",
/** The same as ConnectionState.SignalReconnecting from `livekit-client` */
LivekitSignalReconnecting = "signalReconnecting",
}
/** /**
* A connection to a Matrix RTC LiveKit backend. * A connection to a Matrix RTC LiveKit backend.
@@ -89,19 +85,25 @@ export enum ConnectionState {
*/ */
export class Connection { export class Connection {
// Private Behavior // Private Behavior
private readonly _state$ = new BehaviorSubject< private readonly _state$ = new BehaviorSubject<ConnectionState>({
ConnectionState | MatrixRTCError state: "initialized",
>(ConnectionState.Initialized); });
/** /**
* The current state of the connection to the media transport. * The current state of the connection to the media transport.
*/ */
public readonly state$: Behavior<ConnectionState | Error> = this._state$; public readonly state$: Behavior<ConnectionState> = this._state$;
/** Whether we publish on this connection or only subscribe. */
public readonly role: ConnectionRole;
/** /**
* The media transport to connect to. * The media transport to connect to.
*/ */
public readonly transport: LivekitTransport; public readonly transport: UnstableLivekitTransport;
/** The homeserver the transport belongs to. */
public readonly serverName: string;
public readonly livekitRoom: LivekitRoom; public readonly livekitRoom: LivekitRoom;
@@ -130,12 +132,11 @@ export class Connection {
* */ * */
protected stopped = false; protected stopped = false;
// TODO: can we just keep the ConnectionOpts object instead of spreading? private readonly client: ClientGetTokenParts & ClientOpenIDParts;
private readonly client: OpenIDClientParts;
private readonly roomId: string; private readonly roomId: string;
private readonly slotId: string;
private readonly logger: Logger; private readonly logger: Logger;
private readonly ownMembershipIdentity: CallMembershipIdentityParts; private readonly ownMembershipIdentity: CallMembershipIdentityParts;
private readonly existingSFUConfig?: SFUConfig;
/** /**
* Creates a new connection to a matrix RTC LiveKit backend. * Creates a new connection to a matrix RTC LiveKit backend.
* *
@@ -145,19 +146,19 @@ export class Connection {
*/ */
public constructor(opts: ConnectionOpts, logger: Logger) { public constructor(opts: ConnectionOpts, logger: Logger) {
this.ownMembershipIdentity = opts.ownMembershipIdentity; this.ownMembershipIdentity = opts.ownMembershipIdentity;
this.existingSFUConfig = opts.existingSFUConfig;
this.roomId = opts.roomId; this.roomId = opts.roomId;
this.slotId = opts.slotId;
this.logger = logger.getChild( this.logger = logger.getChild(
"[Connection " + opts.transport.livekit_service_url + "]", `[Connection ${JSON.stringify(opts.transport)}]`,
); );
this.logger.info( this.logger.debug(`constructor called`);
`constructor: ${opts.transport.livekit_service_url} roomId: ${this.roomId} withSfuConfig?: ${opts.existingSFUConfig ? JSON.stringify(opts.existingSFUConfig) : "undefined"}`, const { role, transport, serverName, client, scope } = opts;
);
const { transport, client, scope } = opts;
this.scope = scope; this.scope = scope;
this.livekitRoom = opts.livekitRoomFactory(); this.livekitRoom = opts.livekitRoomFactory();
this.role = role;
this.transport = transport; this.transport = transport;
this.serverName = serverName;
this.client = client; this.client = client;
this.remoteParticipants$ = scope.behavior( this.remoteParticipants$ = scope.behavior(
@@ -284,9 +285,9 @@ export class Connection {
* Starts the connection. * Starts the connection.
* *
* This will: * This will:
* 1. Request an OpenId token `request_token` (allows matrix users to verify their identity with a third-party service.) * 1. Authenticate with the transport for our role, through the homeserver
* 2. Use this token to request the SFU config to the MatrixRtc authentication service. * or the JWT service.
* 3. Connect to the configured LiveKit room. * 2. Connect to the LiveKit room the token is for.
* *
* The errors are also represented as a state in the `state$` observable. * The errors are also represented as a state in the `state$` observable.
* It is safe to ignore those errors and handle them accordingly via the `state$` observable. * It is safe to ignore those errors and handle them accordingly via the `state$` observable.
@@ -298,16 +299,10 @@ export class Connection {
this.logger.debug("Starting Connection"); this.logger.debug("Starting Connection");
this.stopped = false; this.stopped = false;
try { try {
this._state$.next(ConnectionState.FetchingConfig); this._state$.next({ state: "authenticating" });
// We should already have this information after creating the localTransport. const { url, jwt, livekitAlias } = await this.getSFUConfig();
// only call getSFUConfigWithOpenID for connections where we do not have a token yet. (existingJwtTokenData === undefined)
const { url, jwt, livekitAlias } =
this.existingSFUConfig ??
(await this.getSFUConfigForRemoteConnection());
this.logger.debug( this.logger.debug(
"Starting Connection to: ", "Starting Connection - jwt: ",
this.transport.livekit_service_url,
"jwt: ",
jwt, jwt,
"wss: ", "wss: ",
url, url,
@@ -318,16 +313,12 @@ export class Connection {
// If we were stopped while fetching the config, don't proceed to connect // If we were stopped while fetching the config, don't proceed to connect
if (this.stopped) return; if (this.stopped) return;
// Setup observer once we are done with getSFUConfigWithOpenID // Setup observer once we are done with getSFUConfig
connectionStateObserver(this.livekitRoom) connectionStateObserver(this.livekitRoom)
.pipe( .pipe(this.scope.bind())
this.scope.bind(), .subscribe((livekitState) =>
map((s) => s as unknown as ConnectionState), this._state$.next({ state: "authenticated", livekitState }),
) );
.subscribe((lkState) => {
// It is save to cast lkState to ConnectionState as they are fully overlapping.
this._state$.next(lkState);
});
try { try {
this.logger.info(`livekitRoom.connect ${url}`); this.logger.info(`livekitRoom.connect ${url}`);
@@ -371,36 +362,37 @@ export class Connection {
// stop() was called while we were connecting, which makes the pending // stop() was called while we were connecting, which makes the pending
// connect reject. That is the abort we asked for, not a failure, so // connect reject. That is the abort we asked for, not a failure, so
// don't record an error state on a stopped connection or rethrow it // don't record an error state on a stopped connection or rethrow it
// (start() is not awaited by the ConnectionManager, so a throw here // (start() is not necessarily awaited by the caller, so a throw here
// becomes an unhandled promise rejection). // becomes an unhandled promise rejection).
this.logger.debug(`Connect aborted because the connection was stopped`); this.logger.debug(`Connect aborted because the connection was stopped`);
return; return;
} }
this.logger.debug(`Failed to connect to LiveKit room: ${error}`); this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
this._state$.next( this._state$.next({
state: "error",
error:
error instanceof MatrixRTCError error instanceof MatrixRTCError
? error ? error
: error instanceof Error : error instanceof Error
? new UnknownRTCError(error) ? new UnknownRTCError(error)
: new UnknownRTCError(new Error(`${error}`)), : new UnknownRTCError(new Error(`${error}`)),
); });
// Its okay to ignore the throw. The error is part of the state. // Its okay to ignore the throw. The error is part of the state.
throw error; throw error;
} }
} }
protected async getSFUConfigForRemoteConnection(): Promise<SFUConfig> { protected async getSFUConfig(): Promise<SFUConfig> {
// This will only be called for sfu's where we do not publish ourselves. return await getSFUConfig({
// For the local connection we will use the existingJwtTokenData client: this.client,
return await getSFUConfigWithOpenID( membership: this.ownMembershipIdentity,
this.client, role: this.role,
this.ownMembershipIdentity, transport: this.transport,
this.transport.livekit_service_url, serverName: this.serverName,
this.roomId, roomId: this.roomId,
// dont pass any custom opts for the subscribe only connections slotId: this.slotId,
{}, logger: this.logger,
this.logger, });
);
} }
/** /**
@@ -410,17 +402,13 @@ export class Connection {
* If the connection is already stopped, this is a no-op. * If the connection is already stopped, this is a no-op.
*/ */
public async stop(): Promise<void> { public async stop(): Promise<void> {
this.logger.debug( this.logger.debug("stop: disconnecting from lk room");
`stop: disconnecing from lk room ${this.transport.livekit_service_url}`,
);
if (this.stopped) return; if (this.stopped) return;
// Mark as stopped before disconnecting so that a connect() aborted by the // Mark as stopped before disconnecting so that a connect() aborted by the
// disconnect sees the flag and does not report the abort as an error. // disconnect sees the flag and does not report the abort as an error.
this.stopped = true; this.stopped = true;
await this.livekitRoom.disconnect(); await this.livekitRoom.disconnect();
this._state$.next(ConnectionState.Stopped); this._state$.next({ state: "stopped" });
this.logger.debug( this.logger.debug("stop: DONE disconnecting from lk room");
`stop: DONE disconnecing from lk room ${this.transport.livekit_service_url}`,
);
} }
} }
@@ -14,18 +14,23 @@ import {
// origin than the page // origin than the page
import E2EEWorker from "livekit-client/e2ee-worker?worker&inline"; import E2EEWorker from "livekit-client/e2ee-worker?worker&inline";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { BehaviorSubject, combineLatest, map } from "rxjs"; import { BehaviorSubject, combineLatest, map } from "rxjs";
import { buildLiveKitOptions } from "./livekitOptions";
import { type OpenIDClientParts, type SFUConfig } from "./openIDSFU";
import { type Behavior } from "../../reactive/Behavior";
import { Connection, type ConnectionOpts, ConnectionState } from "./Connection";
import { type ObservableScope } from "../../reactive/ObservableScope";
import { type ResolvedTransport } from "../../api"; import { type ResolvedTransport } from "../../api";
import { type PublishRequest } from "../../media-api";
import { type MediaQuality } from "../../config"; import { type MediaQuality } from "../../config";
import { type PublishRequest } from "../../media-api";
import { type Behavior } from "../../reactive/Behavior";
import { type ObservableScope } from "../../reactive/ObservableScope";
import {
type ClientGetTokenParts,
type ClientOpenIDParts,
type ConnectionRole,
type SFUConfig,
} from "./auth";
import { Connection, type ConnectionOpts } from "./Connection";
import { buildLiveKitOptions } from "./livekitOptions";
import { import {
audioCaptureOptions, audioCaptureOptions,
type PublishRequestFor, type PublishRequestFor,
@@ -36,25 +41,27 @@ import {
export interface ConnectionFactory { export interface ConnectionFactory {
createConnection( createConnection(
scope: ObservableScope, scope: ObservableScope,
transport: LivekitTransport, role: ConnectionRole,
transport: UnstableLivekitTransport,
serverName: string,
ownMembershipIdentity: CallMembershipIdentityParts, ownMembershipIdentity: CallMembershipIdentityParts,
logger: Logger, logger: Logger,
sfuConfig?: SFUConfig,
): Connection; ): Connection;
} }
/** /**
* A connection that keeps what it fetched from the JWT service, so that the * A connection that keeps what it fetched when authenticating, so that the
* transport can report it as `resolved$`. Nothing while the config is still * transport can report it as `resolved$`. Nothing while the token is still
* being fetched, and nothing again once the connection has stopped or failed. * being fetched, and nothing again once the connection has stopped or failed.
*/ */
export class ResolvedConnection extends Connection { export class ResolvedConnection extends Connection {
private readonly sfuConfig$: BehaviorSubject<SFUConfig | undefined>; private readonly sfuConfig$ = new BehaviorSubject<SFUConfig | undefined>(
undefined,
);
public readonly resolved$: Behavior<ResolvedTransport | undefined>; public readonly resolved$: Behavior<ResolvedTransport | undefined>;
public constructor(opts: ConnectionOpts, logger: Logger) { public constructor(opts: ConnectionOpts, logger: Logger) {
super(opts, logger); super(opts, logger);
this.sfuConfig$ = new BehaviorSubject(opts.existingSFUConfig);
// One object per fetch, so that a change of state does not read as a // One object per fetch, so that a change of state does not read as a
// change of what was fetched // change of what was fetched
const resolved$ = this.sfuConfig$.pipe( const resolved$ = this.sfuConfig$.pipe(
@@ -63,7 +70,7 @@ export class ResolvedConnection extends Connection {
this.resolved$ = opts.scope.behavior( this.resolved$ = opts.scope.behavior(
combineLatest([resolved$, this.state$]).pipe( combineLatest([resolved$, this.state$]).pipe(
map(([resolved, state]) => map(([resolved, state]) =>
state === ConnectionState.Stopped || state instanceof Error state.state === "stopped" || state.state === "error"
? undefined ? undefined
: resolved, : resolved,
), ),
@@ -71,8 +78,8 @@ export class ResolvedConnection extends Connection {
); );
} }
protected override async getSFUConfigForRemoteConnection(): Promise<SFUConfig> { protected override async getSFUConfig(): Promise<SFUConfig> {
const config = await super.getSFUConfigForRemoteConnection(); const config = await super.getSFUConfig();
this.sfuConfig$.next(config); this.sfuConfig$.next(config);
return config; return config;
} }
@@ -94,8 +101,9 @@ function resolvedTransport(config: SFUConfig): ResolvedTransport {
*/ */
export class LivekitConnectionFactory implements ConnectionFactory { export class LivekitConnectionFactory implements ConnectionFactory {
public constructor( public constructor(
private readonly client: OpenIDClientParts, private readonly client: ClientGetTokenParts & ClientOpenIDParts,
private readonly roomId: string, private readonly roomId: string,
private readonly slotId: string,
private readonly publish: PublishRequest[], private readonly publish: PublishRequest[],
private readonly audioOutputDeviceId$: Behavior<string | undefined>, private readonly audioOutputDeviceId$: Behavior<string | undefined>,
private readonly keyProvider: BaseKeyProvider | undefined, private readonly keyProvider: BaseKeyProvider | undefined,
@@ -104,16 +112,19 @@ export class LivekitConnectionFactory implements ConnectionFactory {
public createConnection( public createConnection(
scope: ObservableScope, scope: ObservableScope,
transport: LivekitTransport, role: ConnectionRole,
transport: UnstableLivekitTransport,
serverName: string,
ownMembershipIdentity: CallMembershipIdentityParts, ownMembershipIdentity: CallMembershipIdentityParts,
logger: Logger, logger: Logger,
sfuConfig?: SFUConfig,
): Connection { ): Connection {
return new ResolvedConnection( return new ResolvedConnection(
{ {
existingSFUConfig: sfuConfig,
roomId: this.roomId, roomId: this.roomId,
slotId: this.slotId,
role,
transport, transport,
serverName,
client: this.client, client: this.client,
scope, scope,
livekitRoomFactory: () => livekitRoomFactory: () =>
@@ -1,297 +0,0 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import {
combineLatest,
map,
type Observable,
of,
switchMap,
startWith,
catchError,
NEVER,
} from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type RemoteParticipant } from "livekit-client";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Behavior } from "../../reactive/Behavior";
import { type Connection } from "./Connection.ts";
import { Epoch, type ObservableScope } from "../../reactive/ObservableScope";
import { generateItemsWithEpoch } from "../../reactive/observable";
import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { type SFUConfig } from "./openIDSFU";
export class ConnectionManagerData {
private readonly store: Map<
string,
{ connection: Connection; participants: RemoteParticipant[] }
> = new Map();
public constructor(private readonly logger?: Logger) {}
public add(connection: Connection, participants: RemoteParticipant[]): void {
const key = this.getKey(connection.transport);
const existing = this.store.get(key);
if (!existing) {
this.store.set(key, { connection, participants });
} else {
// Transports are deduplicated by URL upstream, so this should never
// happen; if it does, members may be matched against the wrong room.
this.logger?.warn(
`Merging participants from a second connection to ${key}: existing [${existing.participants.map((p) => p.identity).join(", ")}], adding [${participants.map((p) => p.identity).join(", ")}]`,
);
existing.participants.push(...participants);
}
}
private getKey(transport: LivekitTransport): string {
// This is enough as a key because the ConnectionManager is already scoped by room.
// We also do not need to consider the slotId at this point since each `MatrixRTCSession` is already scoped by `slotDescription: {id, application}`.
return transport.livekit_service_url;
}
public getConnections(): Connection[] {
return Array.from(this.store.values()).map(({ connection }) => connection);
}
public getConnectionForTransport(
transport: LivekitTransport,
): Connection | null {
return this.store.get(this.getKey(transport))?.connection ?? null;
}
public getParticipantsForTransport(
transport: LivekitTransport,
): RemoteParticipant[] {
const key = this.getKey(transport);
const existing = this.store.get(key);
if (existing) {
return existing.participants;
}
return [];
}
}
interface Props {
scope: ObservableScope;
connectionFactory: ConnectionFactory;
localTransport$: Observable<LocalTransport>;
remoteTransports$: Behavior<Epoch<LivekitTransport[]>>;
logger: Logger;
ownMembershipIdentity: CallMembershipIdentityParts;
}
// TODO - write test for scopes (do we really need to bind scope)
export interface IConnectionManager {
connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>;
}
/**
* Crete a `ConnectionManager`
* @param props - Configuration object
* @param props.scope - The observable scope used by this object
* @param props.connectionFactory - Used to create new connections
* @param props.localTransport$ - The transport to publish local media on. (deduplicated with remoteTransports$)
* @param props.remoteTransports$ - All other transports. The connection manager will create connections for each transport. (deduplicated with localTransport$)
* @param props.ownMembershipIdentity - The own membership identity to use.
* @param props.logger - The logger to use.
*
* Each of these behaviors can be interpreted as subscribed list of transports.
*
* Using `registerTransports` independent external modules can control what connections
* are created by the ConnectionManager.
*
* The connection manager will remove all duplicate transports in each subscibed list.
*
* See `unregisterAllTransports` and `unregisterTransport` for details on how to unsubscribe.
*/
export function createConnectionManager$({
scope,
connectionFactory,
localTransport$,
remoteTransports$,
logger: parentLogger,
ownMembershipIdentity,
}: Props): IConnectionManager {
const logger = parentLogger.getChild("[ConnectionManager]");
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
const localTransportAsArray$ = localTransport$.pipe(
// LocalMembership already surfaces local transport errors properly in the UI,
// here we can just swallow them
catchError(() => NEVER),
map((transport) => [transport]),
startWith([]),
);
/**
* All transports currently managed by the ConnectionManager.
*
* This list does not include duplicate transports.
*
* It is build based on the list of subscribed transports (`transportsSubscriptions$`).
* externally this is modified via `registerTransports()`.
*/
const localAndRemoteTransports$: Behavior<
Epoch<(LivekitTransport | LocalTransport)[]>
> = scope.behavior(
combineLatest([localTransportAsArray$, remoteTransports$]).pipe(
// Combine local and remote transports into one transport array
// and set the forceOldJwtEndpoint property on the local transport
map(([localTransportAsArray, remoteTransports]) => {
const dedupedRemote = removeDuplicateTransports(remoteTransports.value);
const remoteWithoutLocal = dedupedRemote.filter(
(transport) =>
!localTransportAsArray.find((l) =>
areLivekitTransportsEqual(l.transport, transport),
),
);
logger.debug(
"remoteWithoutLocal",
remoteWithoutLocal,
"localTransportAsArray",
localTransportAsArray,
);
return new Epoch(
[...localTransportAsArray, ...remoteWithoutLocal],
remoteTransports.epoch,
);
}),
),
);
/**
* Connections for each transport in use by one or more session members.
*/
const connections$ = scope.behavior(
localAndRemoteTransports$.pipe(
generateItemsWithEpoch(
"ConnectionManager connections$",
function* (transports) {
for (const transport of transports) {
if (isLocalTransport(transport)) {
// This is the local transport; only the `LocalTransport`
// interface has a `sfuConfig` field.
yield {
keys: [
transport.transport.livekit_service_url,
transport.sfuConfig,
],
data: undefined,
};
} else {
yield {
keys: [
transport.livekit_service_url,
undefined as SFUConfig | undefined,
],
data: undefined,
};
}
}
},
(scope, _data$, serviceUrl, sfuConfig) => {
const connection = connectionFactory.createConnection(
scope,
{
type: "livekit",
livekit_service_url: serviceUrl,
},
ownMembershipIdentity,
logger,
// TODO: This whole optional SFUConfig parameter is not particularly elegant.
// I would like it if connections always fetched the SFUConfig by themselves.
sfuConfig,
);
// Start the connection immediately
// Use connection state to track connection progress
void connection.start();
// TODO subscribe to connection state to retry or log issues?
return connection;
},
),
),
);
const connectionManagerData$ = scope.behavior(
connections$.pipe(
switchMap((connections) => {
const epoch = connections.epoch;
// Map the connections to list of {connection, participants}[]
const listOfConnectionsWithRemoteParticipants = connections.value.map(
(connection) => {
return connection.remoteParticipants$.pipe(
map((participants) => ({
connection,
participants,
})),
);
},
);
// probably not required
if (listOfConnectionsWithRemoteParticipants.length === 0) {
return of(new Epoch(new ConnectionManagerData(), epoch));
}
// combineLatest the several streams into a single stream with the ConnectionManagerData
return combineLatest(listOfConnectionsWithRemoteParticipants).pipe(
map(
(lists) =>
new Epoch(
lists.reduce((data, { connection, participants }) => {
data.add(connection, participants);
return data;
}, new ConnectionManagerData(logger)),
epoch,
),
),
);
}),
),
new Epoch(new ConnectionManagerData(), -1),
);
return { connectionManagerData$ };
}
function removeDuplicateTransports<T extends LivekitTransport>(
transports: T[],
): T[] {
return transports.reduce((acc, transport) => {
if (!acc.some((t) => areLivekitTransportsEqual(t, transport)))
acc.push(transport);
return acc;
}, [] as T[]);
}
/** The transport the local member publishes on, authenticated with. */
export interface LocalTransport {
transport: LivekitTransport;
sfuConfig: SFUConfig;
}
export function isLocalTransport(
obj: LivekitTransport | LocalTransport,
): obj is LocalTransport {
return "transport" in obj && "sfuConfig" in obj;
}
export function areLivekitTransportsEqual<T extends LivekitTransport>(
t1: T | null,
t2: T | null,
): boolean {
if (t1 && t2) return t1.livekit_service_url === t2.livekit_service_url;
return !t1 && !t2;
}
@@ -6,8 +6,8 @@ Please see LICENSE in the repository root for full details.
*/ */
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BehaviorSubject, NEVER } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type RemoteParticipant } from "livekit-client"; import { type RemoteParticipant } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
@@ -17,27 +17,40 @@ import {
ObservableScope, ObservableScope,
} from "../../reactive/ObservableScope"; } from "../../reactive/ObservableScope";
import { import {
createConnectionManager$, createConnectionMap$,
ConnectionManagerData, ConnectionMap,
areLivekitTransportsEqual, keyFor,
} from "./ConnectionManager.ts"; } from "./ConnectionMap.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts"; import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { type Connection } from "./Connection.ts"; import { type Connection } from "./Connection.ts";
import { ownMemberMock, withTestScheduler } from "../../utils/test"; import { ownMemberMock, withTestScheduler } from "../../utils/test";
import { type Behavior } from "../../reactive/Behavior"; import { type Behavior } from "../../reactive/Behavior";
import { type TransportLocator } from "./auth";
// Some test constants const TRANSPORT_1: TransportLocator = {
transport: {
const TRANSPORT_1: LivekitTransport = {
type: "livekit", type: "livekit",
livekit_service_url: "https://lk.example.org", livekit_service_url: "https://lk.example.org",
},
serverName: "example.org",
}; };
const TRANSPORT_2: LivekitTransport = { const TRANSPORT_2: TransportLocator = {
transport: {
type: "livekit", type: "livekit",
livekit_service_url: "https://lk.sample.com", livekit_service_url: "https://lk.sample.com",
},
serverName: "sample.com",
}; };
function findConnection(
transport: TransportLocator,
connections: Connection[],
): Connection | undefined {
const key = keyFor(transport);
return connections.find((c) => keyFor(c) === key);
}
let fakeConnectionFactory: ConnectionFactory; let fakeConnectionFactory: ConnectionFactory;
let testScope: ObservableScope; let testScope: ObservableScope;
@@ -51,9 +64,15 @@ beforeEach(() => {
vi.mocked(fakeConnectionFactory).createConnection = vi vi.mocked(fakeConnectionFactory).createConnection = vi
.fn() .fn()
.mockImplementation( .mockImplementation(
(scope: ObservableScope, transport: LivekitTransport) => { (
scope: ObservableScope,
_role: unknown,
transport: UnstableLivekitTransport,
serverName: string,
) => {
const mockConnection = { const mockConnection = {
transport, transport,
serverName,
remoteParticipants$: new BehaviorSubject([]), remoteParticipants$: new BehaviorSubject([]),
} as unknown as Connection; } as unknown as Connection;
vi.mocked(mockConnection).start = vi.fn(); vi.mocked(mockConnection).start = vi.fn();
@@ -75,10 +94,10 @@ afterEach(() => {
describe("connections$ stream", () => { describe("connections$ stream", () => {
test("Should create and start new connections for each transports", () => { test("Should create and start new connections for each transports", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const { connectionManagerData$ } = createConnectionManager$({ const connectionMap$ = createConnectionMap$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: NEVER, localTransport: TRANSPORT_1,
remoteTransports$: behavior("a", { remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0), a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}), }),
@@ -87,7 +106,7 @@ describe("connections$ stream", () => {
}); });
expectObservable( expectObservable(
connectionManagerData$.pipe(mapEpoch((d) => d.getConnections())), connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("a", { ).toBe("a", {
a: expect.toSatisfy((e: Epoch<Connection[]>) => { a: expect.toSatisfy((e: Epoch<Connection[]>) => {
const connections = e.value; const connections = e.value;
@@ -97,15 +116,11 @@ describe("connections$ stream", () => {
vi.mocked(fakeConnectionFactory).createConnection, vi.mocked(fakeConnectionFactory).createConnection,
).toHaveBeenCalledTimes(2); ).toHaveBeenCalledTimes(2);
const conn1 = connections.find((c) => const conn1 = findConnection(TRANSPORT_1, connections);
areLivekitTransportsEqual(c.transport, TRANSPORT_1),
);
expect(conn1).toBeDefined(); expect(conn1).toBeDefined();
expect(conn1!.start).toHaveBeenCalled(); expect(conn1!.start).toHaveBeenCalled();
const conn2 = connections.find((c) => const conn2 = findConnection(TRANSPORT_2, connections);
areLivekitTransportsEqual(c.transport, TRANSPORT_2),
);
expect(conn2).toBeDefined(); expect(conn2).toBeDefined();
expect(conn2!.start).toHaveBeenCalled(); expect(conn2!.start).toHaveBeenCalled();
return true; return true;
@@ -114,12 +129,126 @@ describe("connections$ stream", () => {
}); });
}); });
test("Should start connection only once", () => { test("Tags the local transport as the publisher and the rest as subscribers", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const { connectionManagerData$ } = createConnectionManager$({ const connectionMap$ = createConnectionMap$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: NEVER, localTransport: TRANSPORT_1,
remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_2], 0),
}),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
expectObservable(connectionMap$).toBe("a", {
a: expect.toSatisfy(() => {
const { createConnection } = vi.mocked(fakeConnectionFactory);
expect(createConnection).toHaveBeenCalledWith(
expect.anything(),
"publisher",
TRANSPORT_1.transport,
TRANSPORT_1.serverName,
ownMemberMock,
expect.anything(),
);
expect(createConnection).toHaveBeenCalledWith(
expect.anything(),
"subscriber",
TRANSPORT_2.transport,
TRANSPORT_2.serverName,
ownMemberMock,
expect.anything(),
);
return true;
}),
});
});
});
test("Shares one connection between homeservers advertising the same JWT service", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const sameServiceElsewhere: TransportLocator = {
transport: TRANSPORT_1.transport,
serverName: "elsewhere.org",
};
const connectionMap$ = createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: TRANSPORT_1,
remoteTransports$: behavior("a", {
a: new Epoch([sameServiceElsewhere], 0),
}),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
expectObservable(
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("a", {
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
expect(e.value.length).toBe(1);
// The JWT service is reached directly, so the server name plays no
// part and the local locator, which came first, is the one used
expect(
vi.mocked(fakeConnectionFactory).createConnection,
).toHaveBeenCalledExactlyOnceWith(
expect.anything(),
"publisher",
TRANSPORT_1.transport,
"example.org",
expect.anything(),
expect.anything(),
);
return true;
}),
});
});
});
test("Keeps one connection per homeserver for transports with a url", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const urlTransport: TransportLocator = {
transport: { type: "livekit", url: "wss://sfu.example.org" },
serverName: "example.org",
};
const sameUrlElsewhere: TransportLocator = {
transport: urlTransport.transport,
serverName: "elsewhere.org",
};
const connectionMap$ = createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: urlTransport,
remoteTransports$: behavior("a", {
a: new Epoch([sameUrlElsewhere], 0),
}),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
expectObservable(
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("a", {
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
// Our homeserver asks the named server for the token, so each
// server name needs its own connection
expect(e.value.length).toBe(2);
expect(findConnection(urlTransport, e.value)).toBeDefined();
expect(findConnection(sameUrlElsewhere, e.value)).toBeDefined();
return true;
}),
});
});
});
test("Should start connection only once", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const connectionMap$ = createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: TRANSPORT_1,
remoteTransports$: behavior("abcdef", { remoteTransports$: behavior("abcdef", {
a: new Epoch([TRANSPORT_1], 0), a: new Epoch([TRANSPORT_1], 0),
b: new Epoch([TRANSPORT_1], 1), b: new Epoch([TRANSPORT_1], 1),
@@ -133,7 +262,7 @@ describe("connections$ stream", () => {
}); });
expectObservable( expectObservable(
connectionManagerData$.pipe(mapEpoch((d) => d.getConnections())), connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("xxxxxa", { ).toBe("xxxxxa", {
x: expect.anything(), x: expect.anything(),
a: expect.toSatisfy((e: Epoch<Connection[]>) => { a: expect.toSatisfy((e: Epoch<Connection[]>) => {
@@ -144,14 +273,10 @@ describe("connections$ stream", () => {
vi.mocked(fakeConnectionFactory).createConnection, vi.mocked(fakeConnectionFactory).createConnection,
).toHaveBeenCalledTimes(2); ).toHaveBeenCalledTimes(2);
const conn2 = connections.find((c) => const conn2 = findConnection(TRANSPORT_2, connections);
areLivekitTransportsEqual(c.transport, TRANSPORT_2),
);
expect(conn2).toBeDefined(); expect(conn2).toBeDefined();
const conn1 = connections.find((c) => const conn1 = findConnection(TRANSPORT_1, connections);
areLivekitTransportsEqual(c.transport, TRANSPORT_1),
);
expect(conn1).toBeDefined(); expect(conn1).toBeDefined();
expect(conn1!.start).toHaveBeenCalledOnce(); expect(conn1!.start).toHaveBeenCalledOnce();
@@ -163,10 +288,10 @@ describe("connections$ stream", () => {
test("Should cleanup connections when not needed anymore", () => { test("Should cleanup connections when not needed anymore", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const { connectionManagerData$ } = createConnectionManager$({ const connectionMap$ = createConnectionMap$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: NEVER, localTransport: TRANSPORT_1,
remoteTransports$: behavior("abc", { remoteTransports$: behavior("abc", {
a: new Epoch([TRANSPORT_1], 0), a: new Epoch([TRANSPORT_1], 0),
b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1), b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1),
@@ -177,7 +302,7 @@ describe("connections$ stream", () => {
}); });
expectObservable( expectObservable(
connectionManagerData$.pipe(mapEpoch((d) => d.getConnections())), connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("xab", { ).toBe("xab", {
x: expect.anything(), x: expect.anything(),
a: expect.toSatisfy((e: Epoch<Connection[]>) => { a: expect.toSatisfy((e: Epoch<Connection[]>) => {
@@ -190,8 +315,9 @@ describe("connections$ stream", () => {
expect(connections.length).toBe(1); expect(connections.length).toBe(1);
// The second connection should have been stopped has it is no longer needed. // The second connection should have been stopped has it is no longer needed.
const connection2 = allCreatedConnections.find((c) => const connection2 = findConnection(
areLivekitTransportsEqual(c.transport, TRANSPORT_2), TRANSPORT_2,
allCreatedConnections,
); );
expect(connection2).toBeDefined(); expect(connection2).toBeDefined();
expect(connection2!.stop).toHaveBeenCalled(); expect(connection2!.stop).toHaveBeenCalled();
@@ -207,40 +333,36 @@ describe("connections$ stream", () => {
}); });
}); });
describe("ConnectionManagerData", () => { describe("ConnectionMap", () => {
test("warns when a second connection to the same URL is merged", () => { test("warns when a second connection to the same URL is merged", () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}); const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const data = new ConnectionManagerData(logger); const data = new ConnectionMap(logger);
const connection = { transport: TRANSPORT_1 } as unknown as Connection; const connection = TRANSPORT_1 as unknown as Connection;
const p = (identity: string): RemoteParticipant => const p = (identity: string): RemoteParticipant =>
({ identity }) as unknown as RemoteParticipant; ({ identity }) as unknown as RemoteParticipant;
data.add(connection, [p("a")]); data.add(connection, [p("a")]);
data.add({ ...connection } as Connection, [p("b")]); data.add({ ...connection } as Connection, [p("b")]);
expect(warn).toHaveBeenCalledWith( expect(warn).toHaveBeenCalledWith(
expect.stringContaining( expect.stringContaining(
"second connection to https://lk.example.org: existing [a], adding [b]", 'second connection to [null,"https://lk.example.org",null]: existing [a], adding [b]',
), ),
); );
expect(data.getParticipantsForTransport(TRANSPORT_1)).toHaveLength(2); expect(data.getParticipantsForTransport(TRANSPORT_1)).toHaveLength(2);
}); });
}); });
describe("connectionManagerData$ stream", () => { describe("connectionMap$", () => {
// Used in test to control fake connections' remoteParticipants$ streams // Used in test to control fake connections' remoteParticipants$ streams
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>; let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
function keyForTransport(transport: LivekitTransport): string {
return `${transport.livekit_service_url}`;
}
beforeEach(() => { beforeEach(() => {
fakeRemoteParticipantsStreams = new Map(); fakeRemoteParticipantsStreams = new Map();
function getRemoteParticipantsFor( function getRemoteParticipantsFor(
transport: LivekitTransport, transport: TransportLocator,
): Behavior<RemoteParticipant[]> { ): Behavior<RemoteParticipant[]> {
return ( return (
fakeRemoteParticipantsStreams.get(keyForTransport(transport)) ?? fakeRemoteParticipantsStreams.get(keyFor(transport)) ??
new BehaviorSubject([]) new BehaviorSubject([])
); );
} }
@@ -249,13 +371,22 @@ describe("connectionManagerData$ stream", () => {
vi.mocked(fakeConnectionFactory).createConnection = vi vi.mocked(fakeConnectionFactory).createConnection = vi
.fn() .fn()
.mockImplementation( .mockImplementation(
(scope: ObservableScope, transport: LivekitTransport) => { (
scope: ObservableScope,
_role: unknown,
transport: UnstableLivekitTransport,
serverName: string,
) => {
const fakeRemoteParticipants$ = new BehaviorSubject< const fakeRemoteParticipants$ = new BehaviorSubject<
RemoteParticipant[] RemoteParticipant[]
>([]); >([]);
const mockConnection = { const mockConnection = {
transport, transport,
remoteParticipants$: getRemoteParticipantsFor(transport), serverName,
remoteParticipants$: getRemoteParticipantsFor({
transport,
serverName,
}),
} as unknown as Connection; } as unknown as Connection;
vi.mocked(mockConnection).start = vi.fn(); vi.mocked(mockConnection).start = vi.fn();
vi.mocked(mockConnection).stop = vi.fn(); vi.mocked(mockConnection).stop = vi.fn();
@@ -265,7 +396,7 @@ describe("connectionManagerData$ stream", () => {
}); });
fakeRemoteParticipantsStreams.set( fakeRemoteParticipantsStreams.set(
keyForTransport(transport), keyFor({ transport, serverName }),
fakeRemoteParticipants$, fakeRemoteParticipants$,
); );
return mockConnection; return mockConnection;
@@ -274,11 +405,11 @@ describe("connectionManagerData$ stream", () => {
}); });
test("Should report connections with the remote participants", () => { test("Should report connections with the remote participants", () => {
withTestScheduler(({ expectObservable, schedule, behavior }) => { withTestScheduler(({ expectObservable, behavior }) => {
// Setup the fake participants streams behavior // Setup the fake participants streams behavior
// ============================== // ==============================
fakeRemoteParticipantsStreams.set( fakeRemoteParticipantsStreams.set(
keyForTransport(TRANSPORT_1), keyFor(TRANSPORT_1),
behavior("oa-b", { behavior("oa-b", {
o: [], o: [],
a: [{ identity: "user1A" } as RemoteParticipant], a: [{ identity: "user1A" } as RemoteParticipant],
@@ -290,7 +421,7 @@ describe("connectionManagerData$ stream", () => {
); );
fakeRemoteParticipantsStreams.set( fakeRemoteParticipantsStreams.set(
keyForTransport(TRANSPORT_2), keyFor(TRANSPORT_2),
behavior("o-a", { behavior("o-a", {
o: [], o: [],
a: [{ identity: "user2A" } as RemoteParticipant], a: [{ identity: "user2A" } as RemoteParticipant],
@@ -298,10 +429,10 @@ describe("connectionManagerData$ stream", () => {
); );
// ============================== // ==============================
const { connectionManagerData$ } = createConnectionManager$({ const connectionMap$ = createConnectionMap$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: NEVER, localTransport: TRANSPORT_1,
remoteTransports$: behavior("a", { remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0), a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}), }),
@@ -309,16 +440,16 @@ describe("connectionManagerData$ stream", () => {
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
}); });
expectObservable(connectionManagerData$).toBe("abcd", { expectObservable(connectionMap$).toBe("abcd", {
a: expect.toSatisfy((e) => { a: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(0); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(0);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
return true; return true;
}), }),
b: expect.toSatisfy((e) => { b: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
@@ -328,7 +459,7 @@ describe("connectionManagerData$ stream", () => {
return true; return true;
}), }),
c: expect.toSatisfy((e) => { c: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
@@ -341,7 +472,7 @@ describe("connectionManagerData$ stream", () => {
return true; return true;
}), }),
d: expect.toSatisfy((e) => { d: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(2); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
@@ -0,0 +1,194 @@
/*
Copyright 2025-2026 Element Creations Ltd.
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type RemoteParticipant } from "livekit-client";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { combineLatest, map, of, switchMap, tap } from "rxjs";
import { type Behavior } from "../../reactive/Behavior";
import {
Epoch,
mapEpoch,
type ObservableScope,
} from "../../reactive/ObservableScope";
import { generateItemsWithEpoch } from "../../reactive/observable";
import { type ConnectionRole, type TransportLocator } from "./auth";
import { type Connection } from "./Connection";
import { type ConnectionFactory } from "./ConnectionFactory";
// Branded, so that only values returned by `keyFor` can be used as keys
export type TransportKey = string & { __transportKey: true };
/**
* The key a locator is identified by in a map or set. Locators with the same
* key share a connection: the server name only takes part in authentication
* for a transport with a `url`, where our homeserver asks that server for the
* token. A JWT service is reached directly, so members of several homeservers
* advertising the same one share a connection rather than each get their own.
*/
export function keyFor({
transport,
serverName,
}: TransportLocator): TransportKey {
const hasUrl = "url" in transport;
// Serialize the fields in a consistent order
return JSON.stringify([
hasUrl ? transport.url : null,
"livekit_service_url" in transport ? transport.livekit_service_url : null,
hasUrl ? serverName : null,
]) as TransportKey;
}
function removeDuplicateTransports(
transports: TransportLocator[],
): TransportLocator[] {
// The first locator wins, so the local one stays first in the list
const map = new Map<TransportKey, TransportLocator>();
for (const transport of transports) {
const key = keyFor(transport);
if (!map.has(key)) map.set(key, transport);
}
return [...map.values()];
}
/** The connections at one moment, with the remote participants seen on each. */
export class ConnectionMap {
private readonly map = new Map<
TransportKey,
{ connection: Connection; participants: RemoteParticipant[] }
>();
public constructor(private readonly logger?: Logger) {}
public add(connection: Connection, participants: RemoteParticipant[]): void {
const key = keyFor(connection);
const existing = this.map.get(key);
if (!existing) {
this.map.set(key, { connection, participants });
} else {
// Transports are deduplicated by key upstream, so this should never
// happen; if it does, members may be matched against the wrong room.
this.logger?.warn(
`Merging participants from a second connection to ${key}: existing [${existing.participants.map((p) => p.identity).join(", ")}], adding [${participants.map((p) => p.identity).join(", ")}]`,
);
existing.participants.push(...participants);
}
}
public getConnections(): Connection[] {
return Array.from(this.map.values()).map(({ connection }) => connection);
}
public getConnectionForTransport(
transport: TransportLocator,
): Connection | null {
return this.map.get(keyFor(transport))?.connection ?? null;
}
public getParticipantsForTransport(
transport: TransportLocator,
): RemoteParticipant[] {
return this.map.get(keyFor(transport))?.participants ?? [];
}
}
interface Props {
scope: ObservableScope;
connectionFactory: ConnectionFactory;
/** The transport we publish on; the one connection with the publisher role. */
localTransport: TransportLocator;
/** Every transport some member is on, deduplicated against the local one. */
remoteTransports$: Behavior<Epoch<TransportLocator[]>>;
logger: Logger;
ownMembershipIdentity: CallMembershipIdentityParts;
}
/**
* One connection per transport in use by one or more session members, with
* the remote participants each one sees. The connections start as they are
* created and stop as their transport drops out of the list.
*/
export function createConnectionMap$({
scope,
connectionFactory,
localTransport,
remoteTransports$,
logger: parentLogger,
ownMembershipIdentity,
}: Props): Behavior<Epoch<ConnectionMap>> {
const logger = parentLogger.getChild("[ConnectionMap]");
const localAndRemoteTransports$: Behavior<Epoch<TransportLocator[]>> =
scope.behavior(
remoteTransports$.pipe(
mapEpoch((remoteTransports) =>
removeDuplicateTransports([localTransport, ...remoteTransports]),
),
tap((transports) =>
logger.debug("localAndRemoteTransports$ = ", transports),
),
),
);
const localTransportKey = keyFor(localTransport);
const connections$ = scope.behavior(
localAndRemoteTransports$.pipe(
generateItemsWithEpoch(
"ConnectionMap connections$",
function* (transports) {
for (const transport of transports) {
const role: ConnectionRole =
keyFor(transport) === localTransportKey
? "publisher"
: "subscriber";
// The locator is an object, so a stable key stands in for it
yield { keys: [role, keyFor(transport)], data: transport };
}
},
(scope, locator$, role, _key) => {
const { transport, serverName } = locator$.value;
const connection = connectionFactory.createConnection(
scope,
role,
transport,
serverName,
ownMembershipIdentity,
logger,
);
// The state carries the progress, errors included
void connection.start();
return connection;
},
),
),
);
return scope.behavior(
connections$.pipe(
switchMap(({ value: connections, epoch }) => {
if (connections.length === 0)
return of(new Epoch(new ConnectionMap(), epoch));
return combineLatest(
connections.map((connection) =>
connection.remoteParticipants$.pipe(
map((participants) => ({ connection, participants })),
),
),
(...list) => {
const map = new ConnectionMap(logger);
for (const item of list)
map.add(item.connection, item.participants);
return new Epoch(map, epoch);
},
);
}),
),
);
}
@@ -6,24 +6,33 @@ Please see LICENSE in the repository root for full details.
*/ */
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { type RemoteParticipant, RoomEvent } from "livekit-client"; import {
ConnectionState as LivekitConnectionState,
type RemoteParticipant,
RoomEvent,
} from "livekit-client";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { Epoch } from "../../reactive/ObservableScope"; import { Epoch } from "../../reactive/ObservableScope";
import { constant } from "../../reactive/Behavior"; import { constant } from "../../reactive/Behavior";
import { type DataPacket } from "../api"; import { type DataPacket } from "../api";
import { type Connection, ConnectionState } from "./Connection"; import { type Connection, type ConnectionState } from "./Connection";
import { ConnectionManagerData } from "./ConnectionManager"; import { ConnectionMap } from "./ConnectionMap";
import { createDataChannel$ } from "./DataChannel"; import { createDataChannel$ } from "./DataChannel";
import { testScope } from "../../utils/test"; import { testScope } from "../../utils/test";
import { exampleTransport } from "./test"; import { exampleTransport } from "./test";
const encode = (text: string): Uint8Array => new TextEncoder().encode(text); const encode = (text: string): Uint8Array => new TextEncoder().encode(text);
const connected: ConnectionState = {
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
};
function fakeConnection( function fakeConnection(
url: string, url: string,
state = ConnectionState.LivekitConnected, state: ConnectionState = connected,
): { ): {
connection: Connection; connection: Connection;
room: EventEmitter; room: EventEmitter;
@@ -33,6 +42,7 @@ function fakeConnection(
const publishData = vi.fn(async () => Promise.resolve()); const publishData = vi.fn(async () => Promise.resolve());
const connection = { const connection = {
transport: { ...exampleTransport, livekit_service_url: url }, transport: { ...exampleTransport, livekit_service_url: url },
serverName: "example.org",
state$: constant(state), state$: constant(state),
livekitRoom: Object.assign(room, { localParticipant: { publishData } }), livekitRoom: Object.assign(room, { localParticipant: { publishData } }),
} as unknown as Connection; } as unknown as Connection;
@@ -41,15 +51,15 @@ function fakeConnection(
function setup(connections: Connection[]): { function setup(connections: Connection[]): {
received: DataPacket[]; received: DataPacket[];
connections$: BehaviorSubject<Epoch<ConnectionManagerData>>; connections$: BehaviorSubject<Epoch<ConnectionMap>>;
sendData: (topic: string, text: string) => Promise<void>; sendData: (topic: string, text: string) => Promise<void>;
} { } {
const data = new ConnectionManagerData(); const data = new ConnectionMap();
for (const connection of connections) data.add(connection, []); for (const connection of connections) data.add(connection, []);
const connections$ = new BehaviorSubject(new Epoch(data)); const connections$ = new BehaviorSubject(new Epoch(data));
const { data$, sendData } = createDataChannel$({ const { data$, sendData } = createDataChannel$({
scope: testScope(), scope: testScope(),
connectionManager: { connectionManagerData$: connections$ }, connectionMap$: connections$,
connection$: constant(connections[0] ?? null), connection$: constant(connections[0] ?? null),
}); });
const received: DataPacket[] = []; const received: DataPacket[] = [];
@@ -89,7 +99,7 @@ describe("data$", () => {
const second = fakeConnection("https://sfu-2"); const second = fakeConnection("https://sfu-2");
const { received, connections$ } = setup([first.connection]); const { received, connections$ } = setup([first.connection]);
const data = new ConnectionManagerData(); const data = new ConnectionMap();
data.add(first.connection, []); data.add(first.connection, []);
data.add(second.connection, []); data.add(second.connection, []);
connections$.next(new Epoch(data, 1)); connections$.next(new Epoch(data, 1));
@@ -122,10 +132,9 @@ describe("sendData", () => {
}); });
it("rejects while the local transport is not connected", async () => { it("rejects while the local transport is not connected", async () => {
const { connection, publishData } = fakeConnection( const { connection, publishData } = fakeConnection("https://sfu", {
"https://sfu", state: "initialized",
ConnectionState.Initialized, });
);
const { sendData } = setup([connection]); const { sendData } = setup([connection]);
await expect(sendData("chat", "hello")).rejects.toThrow("not connected"); await expect(sendData("chat", "hello")).rejects.toThrow("not connected");
+16 -7
View File
@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { import {
ConnectionState as LivekitConnectionState,
type RemoteParticipant, type RemoteParticipant,
type Room as LivekitRoom, type Room as LivekitRoom,
RoomEvent, RoomEvent,
@@ -22,14 +23,17 @@ import {
} from "rxjs"; } from "rxjs";
import { type Behavior } from "../../reactive/Behavior"; import { type Behavior } from "../../reactive/Behavior";
import { type ObservableScope } from "../../reactive/ObservableScope"; import {
type Epoch,
type ObservableScope,
} from "../../reactive/ObservableScope";
import { type DataPacket } from "../api"; import { type DataPacket } from "../api";
import { type Connection, ConnectionState } from "./Connection"; import { type Connection } from "./Connection";
import { type IConnectionManager } from "./ConnectionManager"; import { type ConnectionMap } from "./ConnectionMap";
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
connectionManager: IConnectionManager; connectionMap$: Behavior<Epoch<ConnectionMap>>;
/** The local member's connection, which carries what we send. */ /** The local member's connection, which carries what we send. */
connection$: Behavior<Connection | null>; connection$: Behavior<Connection | null>;
} }
@@ -47,13 +51,13 @@ export interface DataChannel {
*/ */
export function createDataChannel$({ export function createDataChannel$({
scope, scope,
connectionManager, connectionMap$,
connection$, connection$,
}: Props): DataChannel { }: Props): DataChannel {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const rooms$ = connectionManager.connectionManagerData$.pipe( const rooms$ = connectionMap$.pipe(
map(({ value }) => value.getConnections().map((c) => c.livekitRoom)), map(({ value }) => value.getConnections().map((c) => c.livekitRoom)),
distinctUntilChanged( distinctUntilChanged(
(a, b) => a.length === b.length && a.every((room, i) => room === b[i]), (a, b) => a.length === b.length && a.every((room, i) => room === b[i]),
@@ -79,7 +83,12 @@ export function createDataChannel$({
const sendData = async (topic: string, text: string): Promise<void> => { const sendData = async (topic: string, text: string): Promise<void> => {
const connection = connection$.value; const connection = connection$.value;
if (connection?.state$.value !== ConnectionState.LivekitConnected) const state = connection?.state$.value;
if (
connection === null ||
state?.state !== "authenticated" ||
state.livekitState !== LivekitConnectionState.Connected
)
throw new Error("Cannot send data: the local transport is not connected"); throw new Error("Cannot send data: the local transport is not connected");
await connection.livekitRoom.localParticipant.publishData( await connection.livekitRoom.localParticipant.publishData(
encoder.encode(text), encoder.encode(text),
@@ -5,21 +5,52 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import {
ConnectionState as LivekitConnectionState,
type Room as LivekitRoom,
type RemoteParticipant,
} from "livekit-client";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, NEVER } from "rxjs";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { E2eeType } from "../../encryption";
import { MatrixRTCError, NoMatrix2AuthorizationService } from "../../errors";
import { constant } from "../../reactive/Behavior";
import { ObservableScope } from "../../reactive/ObservableScope"; import { ObservableScope } from "../../reactive/ObservableScope";
import { testScope } from "../../utils/test"; import { type RTCMember } from "../../api";
import { defaultSessionTimings } from "../../config";
import {
flushPromises,
mockEmitter,
ownMemberMock,
testScope,
} from "../../utils/test";
import { MediaConnectionState } from "../api"; import { MediaConnectionState } from "../api";
import { ConnectionState } from "./Connection"; import * as auth from "./auth";
import { type TransportLocator } from "./auth";
import { type Connection, type ConnectionState } from "./Connection";
import * as connectionFactory from "./ConnectionFactory";
import { toMediaConnectionState } from "./connectionState"; import { toMediaConnectionState } from "./connectionState";
import { createTransportRegistry, supportsDelegation } from "./LivekitBackend"; import {
import { exampleTransport } from "./test"; createLivekitBackend,
createTransportRegistry,
supportsDelegation,
} from "./LivekitBackend";
import {
exampleServiceUrl,
exampleTransport,
mockRemoteParticipant,
} from "./test";
const otherTransport: LivekitTransport = { const locator = (
transport: UnstableLivekitTransport,
serverName = "example.org",
): TransportLocator => ({ transport, serverName });
const otherTransport: UnstableLivekitTransport = {
type: "livekit", type: "livekit",
livekit_service_url: "https://other.example.org", livekit_service_url: "https://other.example.org",
}; };
@@ -27,31 +58,31 @@ const otherTransport: LivekitTransport = {
describe("createTransportRegistry", () => { describe("createTransportRegistry", () => {
it("connects to a transport once however many members are on it, and drops it with the last", () => { it("connects to a transport once however many members are on it, and drops it with the last", () => {
const registry = createTransportRegistry(testScope()); const registry = createTransportRegistry(testScope());
const urls = (): string[] => const transports = (): UnstableLivekitTransport[] =>
registry.transports$.value.value.map((t) => t.livekit_service_url); registry.transports$.value.value.map((t) => t.transport);
const alice = new ObservableScope(); const alice = new ObservableScope();
const bob = new ObservableScope(); const bob = new ObservableScope();
registry.follow(alice, new BehaviorSubject(exampleTransport)); registry.follow(alice, new BehaviorSubject(locator(exampleTransport)));
registry.follow(bob, new BehaviorSubject(exampleTransport)); registry.follow(bob, new BehaviorSubject(locator(exampleTransport)));
expect(urls()).toEqual([exampleTransport.livekit_service_url]); expect(transports()).toEqual([exampleTransport]);
alice.end(); alice.end();
expect(urls()).toEqual([exampleTransport.livekit_service_url]); expect(transports()).toEqual([exampleTransport]);
bob.end(); bob.end();
expect(urls()).toEqual([]); expect(transports()).toEqual([]);
}); });
it("follows a member that changes transport", () => { it("follows a member that changes transport", () => {
const registry = createTransportRegistry(testScope()); const registry = createTransportRegistry(testScope());
const transport$ = new BehaviorSubject<LivekitTransport | undefined>( const transport$ = new BehaviorSubject<TransportLocator | undefined>(
exampleTransport, locator(exampleTransport),
); );
registry.follow(testScope(), transport$); registry.follow(testScope(), transport$);
transport$.next(otherTransport); transport$.next(locator(otherTransport));
expect(registry.transports$.value.value).toEqual([otherTransport]); expect(registry.transports$.value.value).toEqual([locator(otherTransport)]);
transport$.next(undefined); transport$.next(undefined);
expect(registry.transports$.value.value).toEqual([]); expect(registry.transports$.value.value).toEqual([]);
@@ -61,7 +92,7 @@ describe("createTransportRegistry", () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const registry = createTransportRegistry(scope); const registry = createTransportRegistry(scope);
const member = new ObservableScope(); const member = new ObservableScope();
registry.follow(member, new BehaviorSubject(exampleTransport)); registry.follow(member, new BehaviorSubject(locator(exampleTransport)));
scope.end(); scope.end();
expect(() => member.end()).not.toThrow(); expect(() => member.end()).not.toThrow();
@@ -70,43 +101,327 @@ describe("createTransportRegistry", () => {
describe("supportsDelegation", () => { describe("supportsDelegation", () => {
afterEach(() => fetchMock.reset()); afterEach(() => fetchMock.reset());
const homeserver = "https://matrix.example.org"; const endpoint = `${exampleServiceUrl}/delegate_delayed_leave`;
const homeserverEndpoint = `${homeserver}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`;
const transportEndpoint = `${exampleTransport.livekit_service_url}/delegate_delayed_leave`;
it.each([ it.each([
["the homeserver supports it", 400, 404, true], ["anything but a 404", 401, true],
["only the transport supports it", 404, 400, true], ["a 404", 404, false],
["neither supports it", 404, 404, false], ])("reads %s as the answer", async (_name, status, expected) => {
])("%s", async (_name, homeserverStatus, transportStatus, expected) => { fetchMock.post(endpoint, status);
fetchMock.post(homeserverEndpoint, homeserverStatus);
fetchMock.post(transportEndpoint, transportStatus);
await expect( await expect(
supportsDelegation(homeserver, exampleTransport, logger), supportsDelegation(endpoint, "transport", logger),
).resolves.toBe(expected); ).resolves.toBe(expected);
}); });
it("assumes no support where the probe itself fails", async () => { it("assumes no support where the probe itself fails", async () => {
fetchMock.post(homeserverEndpoint, { throws: new TypeError("CORS") }); fetchMock.post(endpoint, { throws: new TypeError("CORS") });
fetchMock.post(transportEndpoint, { throws: new TypeError("CORS") });
await expect( await expect(
supportsDelegation(homeserver, exampleTransport, logger), supportsDelegation(endpoint, "transport", logger),
).resolves.toBe(false); ).resolves.toBe(false);
}); });
}); });
describe("toMediaConnectionState", () => { describe("toMediaConnectionState", () => {
it("maps every LiveKit state and passes an error through", () => { it("maps every state and passes an error through", () => {
expect(toMediaConnectionState(ConnectionState.FetchingConfig)).toBe( expect(toMediaConnectionState({ state: "authenticating" })).toBe(
MediaConnectionState.Preparing, MediaConnectionState.Preparing,
); );
expect(toMediaConnectionState(ConnectionState.LivekitConnected)).toBe(
MediaConnectionState.Connected,
);
expect( expect(
toMediaConnectionState(ConnectionState.LivekitSignalReconnecting), toMediaConnectionState({
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
}),
).toBe(MediaConnectionState.Connected);
expect(
toMediaConnectionState({
state: "authenticated",
livekitState: LivekitConnectionState.SignalReconnecting,
}),
).toBe(MediaConnectionState.Reconnecting); ).toBe(MediaConnectionState.Reconnecting);
const error = new Error("gone"); expect(toMediaConnectionState({ state: "stopped" })).toBe(
expect(toMediaConnectionState(error)).toBe(error); MediaConnectionState.Stopped,
);
const error = new MatrixRTCError("gone");
expect(toMediaConnectionState({ state: "error", error })).toBe(error);
}); });
}); });
describe("createLivekitBackend", () => {
const homeserver = "https://matrix.example.org";
const homeserverEndpoint = `${homeserver}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`;
const urlTransport: UnstableLivekitTransport = {
type: "livekit",
url: "wss://sfu.example.org",
};
const bothTransport: UnstableLivekitTransport = {
...urlTransport,
...exampleTransport,
};
const client = {
baseUrl: homeserver,
getOpenIdToken: vi.fn(),
_unstable_getLivekitToken: vi.fn(),
_unstable_delegateDelayedLeave: vi.fn(),
};
/** The connections the stubbed factory handed out, with their participants to drive. */
let connections: {
connection: Connection;
participants$: BehaviorSubject<RemoteParticipant[]>;
state$: BehaviorSubject<ConnectionState>;
}[];
const connected: ConnectionState = {
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
};
/** Stands in for the factory: connections whose state and participants the test drives. */
function stubFactory(initialState: ConnectionState): void {
vi.spyOn(connectionFactory, "LivekitConnectionFactory").mockImplementation(
function (this: unknown) {
return {
createConnection: (
scope: ObservableScope,
role: auth.ConnectionRole,
transport: UnstableLivekitTransport,
serverName: string,
): Connection => {
const participants$ = new BehaviorSubject<RemoteParticipant[]>([]);
const state$ = new BehaviorSubject<ConnectionState>(initialState);
const connection = {
role,
transport,
serverName,
state$,
remoteParticipants$: scope.behavior(participants$),
livekitRoom: fakeLivekitRoom(),
start: vi.fn(async () => Promise.resolve()),
stop: vi.fn(async () => Promise.resolve()),
} as unknown as Connection;
connections.push({ connection, participants$, state$ });
return connection;
},
} as unknown as connectionFactory.LivekitConnectionFactory;
},
);
}
beforeEach(() => {
connections = [];
stubFactory(connected);
});
afterEach(() => {
vi.restoreAllMocks();
fetchMock.reset();
});
function backend(): ReturnType<typeof createLivekitBackend> {
return createLivekitBackend(
testScope(),
{
roomId: "!room:example.org",
slotId: "m.call#ROOM",
ownMembershipIdentity: ownMemberMock,
publish: [],
encryptionSystem: { kind: E2eeType.NONE },
mediaKeys$: NEVER,
timings: defaultSessionTimings,
logger,
},
{ client },
);
}
// Delegation goes through whichever service authenticates us as a
// publisher: the JWT service wherever the transport names one, the
// homeserver for a `url` alone
it.each([
["the JWT service", exampleTransport, 404, 401, true],
["the JWT service, and only it", bothTransport, 401, 404, false],
["the homeserver", urlTransport, 401, 404, true],
["neither", exampleTransport, 404, 404, false],
])(
"prepares the local transport and asks %s about delegation",
async (_name, transport, homeserverStatus, serviceStatus, expected) => {
fetchMock.post(homeserverEndpoint, homeserverStatus);
fetchMock.post(
`${exampleServiceUrl}/delegate_delayed_leave`,
serviceStatus,
);
const capabilities = await backend().prepareLocalTransport(transport);
expect(capabilities).toEqual({ canDelegateDelayedLeave: expected });
expect(connections).toHaveLength(1);
expect(connections[0].connection.role).toBe("publisher");
expect(connections[0].connection.transport).toEqual(transport);
expect(connections[0].connection.serverName).toBe("example.org");
},
);
it("waits for the publisher connection to hold its token before the join", async () => {
fetchMock.post(homeserverEndpoint, 404);
fetchMock.post(`${exampleServiceUrl}/delegate_delayed_leave`, 404);
stubFactory({ state: "authenticating" });
let prepared = false;
const preparing = backend()
.prepareLocalTransport(exampleTransport)
.then(() => (prepared = true));
await flushPromises();
expect(prepared).toBe(false);
connections[0].state$.next(connected);
await preparing;
expect(prepared).toBe(true);
});
it("fails the preparation with the publisher connection's error", async () => {
fetchMock.post(homeserverEndpoint, 404);
fetchMock.post(`${exampleServiceUrl}/delegate_delayed_leave`, 404);
stubFactory({ state: "authenticating" });
const preparing = backend().prepareLocalTransport(exampleTransport);
await flushPromises();
const error = new NoMatrix2AuthorizationService(new Error("404"));
connections[0].state$.next({ state: "error", error });
await expect(preparing).rejects.toBe(error);
});
it("delegates the leave through the flow that authenticated the publisher", async () => {
fetchMock.post(homeserverEndpoint, 401);
const delegate = vi.spyOn(auth, "delegateDelayedLeave").mockResolvedValue();
const b = backend();
await expect(b.delegateDelayedLeave("delay-1")).rejects.toThrow(
"No local transport",
);
await b.prepareLocalTransport(urlTransport);
await b.delegateDelayedLeave("delay-1");
expect(delegate).toHaveBeenCalledWith(
expect.objectContaining({
client,
membership: ownMemberMock,
transport: urlTransport,
roomId: "!room:example.org",
slotId: "m.call#ROOM",
delayId: "delay-1",
delayTimeoutMs: defaultSessionTimings.delegatedDelayedLeave.delay_ms,
}),
);
});
describe("mediaFor", () => {
// A member advertising both a `url` and a `livekit_service_url` may have
// authenticated through either flow, so it has two candidate identities
const legacyId = "@bob:example.org:DEV000";
const hashedId = "2qkXfWz1S7jQmU0bJ6yN8aVtR3pLcDgHe9oZxKwMiA4";
function bob(
transport: UnstableLivekitTransport,
backendIdentities: string[],
): RTCMember {
return {
local: false,
rtcBackendIdentity: legacyId,
backendIdentities,
userId: "@bob:example.org",
deviceId: "DEV000",
memberId: legacyId,
transport$: constant({
type: "livekit",
id: JSON.stringify(transport),
raw: transport,
resolved$: constant(undefined),
}),
applicationData$: constant({}),
displayName$: constant("Bob"),
avatarUrl$: constant(undefined),
};
}
async function prepared(): Promise<
ReturnType<typeof createLivekitBackend>
> {
fetchMock.post(homeserverEndpoint, 404);
fetchMock.post(`${exampleServiceUrl}/delegate_delayed_leave`, 404);
const b = backend();
await b.prepareLocalTransport(bothTransport);
return b;
}
it.each([
["legacy", legacyId],
["hashed", hashedId],
])(
"matches a member with two candidate identities publishing under the %s one",
async (_name, identity) => {
const b = await prepared();
const media = b.mediaFor(
testScope(),
bob(bothTransport, [hashedId, legacyId]),
);
await flushPromises();
// Bob is on our transport, so his media arrives on the publisher
// connection
expect(connections).toHaveLength(1);
expect(media.tracks$.value).toBeNull();
connections[0].participants$.next([
mockRemoteParticipant({ identity, trackPublications: new Map() }),
]);
await flushPromises();
expect(media.tracks$.value).toEqual([]);
},
);
it("does not match a member with two candidate identities to anyone else", async () => {
const b = await prepared();
const media = b.mediaFor(
testScope(),
bob(bothTransport, [hashedId, legacyId]),
);
connections[0].participants$.next([
mockRemoteParticipant({
identity: "@stranger:example.org:DEV999",
trackPublications: new Map(),
}),
]);
await flushPromises();
expect(media.tracks$.value).toBeNull();
});
it("connects to a remote member's transport on the member's homeserver", async () => {
const b = await prepared();
const carlsTransport: UnstableLivekitTransport = {
type: "livekit",
url: "wss://sfu.sample.com",
};
b.mediaFor(testScope(), {
...bob(carlsTransport, ["carl-hashed"]),
userId: "@carl:sample.com",
});
await flushPromises();
const remote = connections.find(
(c) => c.connection.role === "subscriber",
);
expect(remote?.connection.transport).toEqual(carlsTransport);
expect(remote?.connection.serverName).toBe("sample.com");
});
});
});
/** The parts of a LiveKit room the local media and the member media read. */
function fakeLivekitRoom(): LivekitRoom {
return {
options: {},
setE2EEEnabled: vi.fn(),
localParticipant: {
...mockEmitter(),
trackPublications: new Map(),
getTrackPublication: () => undefined,
},
remoteParticipants: new Map(),
...mockEmitter(),
} as unknown as LivekitRoom;
}
+160 -122
View File
@@ -8,71 +8,86 @@ Please see LICENSE in the repository root for full details.
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { import {
isLivekitTransport, isUnstableLivekitTransport,
type LivekitTransport,
type Transport, type Transport,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
filter,
firstValueFrom,
map, map,
type Observable, type Observable,
of, of,
ReplaySubject,
switchMap, switchMap,
} from "rxjs"; } from "rxjs";
import { type ResolvedTransport, type RTCMember } from "../../api"; import { type ResolvedTransport, type RTCMember } from "../../api";
import { type MediaQuality } from "../../config"; import { type MediaQuality } from "../../config";
import { MatrixRTCError } from "../../errors"; import { MatrixRTCError } from "../../errors";
import { type Behavior } from "../../reactive/Behavior";
import { type MemberMedia } from "../../media-api"; import { type MemberMedia } from "../../media-api";
import { type Behavior } from "../../reactive/Behavior";
import { import {
type Epoch, Epoch,
type ObservableScope, type ObservableScope,
trackEpoch, trackEpoch,
} from "../../reactive/ObservableScope"; } from "../../reactive/ObservableScope";
import { mapScoped } from "../../utils/mapScoped";
import { serverNameOf } from "../../utils/serverName";
import { import {
type BackendConnection, type BackendConnection,
type MediaBackend, type MediaBackend,
type MediaBackendContext, type MediaBackendContext,
type TransportCapabilities, type TransportCapabilities,
} from "../api"; } from "../api";
import {
chooseAuthFlow,
delegateDelayedLeave,
type TransportLocator,
} from "./auth";
import { type Connection } from "./Connection"; import { type Connection } from "./Connection";
import {
ConnectionMap,
createConnectionMap$,
keyFor,
type TransportKey,
} from "./ConnectionMap";
import { import {
LivekitConnectionFactory, LivekitConnectionFactory,
ResolvedConnection, ResolvedConnection,
} from "./ConnectionFactory"; } from "./ConnectionFactory";
import {
createConnectionManager$,
type LocalTransport,
} from "./ConnectionManager";
import { toMediaConnectionState } from "./connectionState"; import { toMediaConnectionState } from "./connectionState";
import { createDataChannel$ } from "./DataChannel"; import { createDataChannel$ } from "./DataChannel";
import { createKeyProvider } from "./KeyProvider"; import { createKeyProvider } from "./KeyProvider";
import { createLivekitMemberMedia } from "./LivekitMemberMedia"; import { createLivekitMemberMedia } from "./LivekitMemberMedia";
import { createLivekitLocalMedia } from "./LocalMedia"; import { createLivekitLocalMedia } from "./LocalMedia";
import { getSFUConfigWithOpenID, type TokenEndpoint } from "./openIDSFU";
export interface LivekitBackendOptions { export interface LivekitBackendOptions {
/** `getOpenIdToken` for the token exchange, `baseUrl` for the delegation probe and handover. */ /**
client: Pick<MatrixClient, "getOpenIdToken" | "baseUrl">; * `getOpenIdToken` for the JWT service flow, the MSC4195 methods for the
* client-server API flow, `baseUrl` for the delegation probe and handover.
*/
client: Pick<
MatrixClient,
| "getOpenIdToken"
| "_unstable_getLivekitToken"
| "_unstable_delegateDelayedLeave"
| "baseUrl"
>;
mediaQuality?: MediaQuality; mediaQuality?: MediaQuality;
/** Which token service endpoint the local transport uses; remote ones try both. */
tokenEndpoint: TokenEndpoint;
} }
/** /**
* MatrixRTC media over LiveKit SFUs: one room per transport, the local one * MatrixRTC media over LiveKit SFUs: one room per transport, the local one
* published on, every remote one subscribed to, with the token service * published on, every remote one subscribed to, each authenticated for its
* protocol in front of each. * role through the homeserver or the transport's JWT service.
*/ */
export function createLivekitBackend( export function createLivekitBackend(
scope: ObservableScope, scope: ObservableScope,
{ {
roomId, roomId,
slotId,
ownMembershipIdentity, ownMembershipIdentity,
publish, publish,
encryptionSystem, encryptionSystem,
@@ -80,102 +95,133 @@ export function createLivekitBackend(
timings, timings,
logger: parentLogger, logger: parentLogger,
}: MediaBackendContext, }: MediaBackendContext,
{ client, mediaQuality, tokenEndpoint }: LivekitBackendOptions, { client, mediaQuality }: LivekitBackendOptions,
): MediaBackend { ): MediaBackend {
const logger = parentLogger.getChild("[LivekitBackend]"); const logger = parentLogger.getChild("[LivekitBackend]");
const keyProvider = createKeyProvider(encryptionSystem, mediaKeys$, logger); const keyProvider = createKeyProvider(encryptionSystem, mediaKeys$, logger);
const transports = createTransportRegistry(scope); const transports = createTransportRegistry(scope);
const localTransport$ = new ReplaySubject<LocalTransport>(1); const localTransport$ = new BehaviorSubject<TransportLocator | null>(null);
const audioOutputDeviceId$ = new BehaviorSubject<string | undefined>( const audioOutputDeviceId$ = new BehaviorSubject<string | undefined>(
undefined, undefined,
); );
const connectionFactory = new LivekitConnectionFactory(
const connectionManager = createConnectionManager$({
scope,
connectionFactory: new LivekitConnectionFactory(
client, client,
roomId, roomId,
slotId,
publish, publish,
audioOutputDeviceId$, audioOutputDeviceId$,
keyProvider, keyProvider,
mediaQuality, mediaQuality,
), );
localTransport$,
// A new map, in a scope of its own, whenever the local transport changes:
// the publisher role sits on one connection, and which one is fixed per map
const connectionMap$ = scope.behavior<Epoch<ConnectionMap>>(
mapScoped(scope, localTransport$, (mapScope, localTransport) =>
createConnectionMap$({
scope: mapScope,
connectionFactory,
localTransport,
remoteTransports$: transports.transports$, remoteTransports$: transports.transports$,
logger, logger,
ownMembershipIdentity, ownMembershipIdentity,
}),
).pipe(switchMap((map$) => map$ ?? of(new Epoch(new ConnectionMap())))),
);
const localMedia_ = createLivekitLocalMedia({
scope,
connectionMap$,
localTransport$,
publish,
audioOutputDeviceId$,
encryptionSystem,
logger,
}); });
let local: LivekitTransport | undefined; // Probed once: the answer cannot change within a session
const homeserverSupportsDelegation = supportsDelegation(
`${client.baseUrl}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`,
"homeserver",
logger,
);
const prepareLocalTransport = async ( const prepareLocalTransport = async (
transport: Transport, transport: Transport,
): Promise<TransportCapabilities> => { ): Promise<TransportCapabilities> => {
if (!isLivekitTransport(transport)) if (!isUnstableLivekitTransport(transport))
throw new MatrixRTCError(`Not a LiveKit transport: ${transport.type}`); throw new MatrixRTCError(`Not a LiveKit transport: ${transport.type}`);
const [sfuConfig, canDelegateDelayedLeave] = await Promise.all([ const locator: TransportLocator = {
getSFUConfigWithOpenID( transport,
client, serverName: serverNameOf(ownMembershipIdentity.userId),
ownMembershipIdentity, };
transport.livekit_service_url, // Delegation goes through whichever service authenticates us as a
roomId, // publisher, so only that service's support counts
{ tokenEndpoint }, const flow = chooseAuthFlow(transport, "publisher");
const canDelegate =
flow.kind === "openID"
? supportsDelegation(
`${flow.serviceUrl}/delegate_delayed_leave`,
`transport ${flow.serviceUrl}`,
logger, logger,
), )
supportsDelegation(client.baseUrl, transport, logger), : homeserverSupportsDelegation;
localTransport$.next(locator);
// The membership is only sent once the publisher connection holds its
// token, so that the LiveKit room exists before anyone subscribes to it:
// a subscriber lacks the permission to create it and would fail without
// retrying
const [, canDelegateDelayedLeave] = await Promise.all([
authenticated(localMedia_.connection$),
canDelegate,
]); ]);
local = transport;
localTransport$.next({ transport, sfuConfig });
return { canDelegateDelayedLeave }; return { canDelegateDelayedLeave };
}; };
// The token this issues is discarded; the request is what hands the leave over const delegateLeave = async (delayId: string): Promise<void> => {
const delegateDelayedLeave = async (delayId: string): Promise<void> => { const local = localTransport$.value;
if (local === undefined) if (local === null)
throw new MatrixRTCError("No local transport to delegate the leave to"); throw new MatrixRTCError("No local transport to delegate the leave to");
await getSFUConfigWithOpenID( await delegateDelayedLeave({
client, client,
ownMembershipIdentity, membership: ownMembershipIdentity,
local.livekit_service_url, transport: local.transport,
roomId, roomId,
{ slotId,
tokenEndpoint,
delayEndpointBaseUrl: client.baseUrl,
delayId, delayId,
delayTimeoutMs: timings.delegatedDelayedLeave.delay_ms, delayTimeoutMs: timings.delegatedDelayedLeave.delay_ms,
},
logger, logger,
); });
}; };
const mediaFor = ( const mediaFor = (
memberScope: ObservableScope, memberScope: ObservableScope,
member: RTCMember, member: RTCMember,
): MemberMedia => { ): MemberMedia => {
const identity = member.rtcBackendIdentity; const { backendIdentities } = member;
const transport$ = memberScope.behavior( const locator$ = memberScope.behavior(
member.transport$.pipe( member.transport$.pipe(
map((transport) => transport?.raw), map((transport): TransportLocator | undefined => {
map((transport) => const raw = transport?.raw;
isLivekitTransport(transport) ? transport : undefined, return isUnstableLivekitTransport(raw)
), ? { transport: raw, serverName: serverNameOf(member.userId) }
distinctUntilChanged( : undefined;
(a, b) => a?.livekit_service_url === b?.livekit_service_url, }),
), distinctUntilChanged((a, b) => (a && keyFor(a)) === (b && keyFor(b))),
), ),
); );
transports.follow(memberScope, transport$); transports.follow(memberScope, locator$);
const source$ = memberScope.behavior( const source$ = memberScope.behavior(
combineLatest([ combineLatest([locator$, connectionMap$]).pipe(
transport$, map(([locator, { value: connections }]) => {
connectionManager.connectionManagerData$, if (locator === undefined) return null;
]).pipe( const connection = connections.getConnectionForTransport(locator);
map(([transport, { value: connections }]) => { // A member advertising both a `url` and a `livekit_service_url` may
if (transport === undefined) return null; // have authenticated through either flow, under either identity
const connection = connections.getConnectionForTransport(transport);
const participant = connections const participant = connections
.getParticipantsForTransport(transport) .getParticipantsForTransport(locator)
.find((p) => p.identity === identity); .find((p) => backendIdentities.includes(p.identity));
return connection && participant return connection && participant
? { participant, room: connection.livekitRoom } ? { participant, room: connection.livekitRoom }
: null; : null;
@@ -187,14 +233,14 @@ export function createLivekitBackend(
); );
source$.pipe(memberScope.bind()).subscribe((source) => { source$.pipe(memberScope.bind()).subscribe((source) => {
logger.info( logger.info(
`${identity}: LiveKit participant ${source ? `matched (${source.participant.sid})` : "missing"}`, `${member.rtcBackendIdentity}: LiveKit participant ${source ? `matched (${source.participant.sid})` : "missing"} on ${locator$.value ? JSON.stringify(locator$.value.transport) : "no transport"}`,
); );
}); });
return createLivekitMemberMedia(memberScope, source$, encryptionSystem); return createLivekitMemberMedia(memberScope, source$, encryptionSystem);
}; };
const connections$ = scope.behavior<BackendConnection[]>( const connections$ = scope.behavior<BackendConnection[]>(
connectionManager.connectionManagerData$.pipe( connectionMap$.pipe(
switchMap(({ value }) => { switchMap(({ value }) => {
const connections = value.getConnections(); const connections = value.getConnections();
if (connections.length === 0) return of([]); if (connections.length === 0) return of([]);
@@ -203,26 +249,16 @@ export function createLivekitBackend(
), ),
); );
const localMedia_ = createLivekitLocalMedia({
scope,
connectionManager,
localTransport$,
publish,
audioOutputDeviceId$,
encryptionSystem,
logger,
});
const { data$, sendData } = createDataChannel$({ const { data$, sendData } = createDataChannel$({
scope, scope,
connectionManager, connectionMap$,
connection$: localMedia_.connection$, connection$: localMedia_.connection$,
}); });
return { return {
transportType: "livekit", transportType: "livekit",
prepareLocalTransport, prepareLocalTransport,
delegateDelayedLeave, delegateDelayedLeave: delegateLeave,
local: localMedia_, local: localMedia_,
mediaFor, mediaFor,
connections$, connections$,
@@ -232,6 +268,29 @@ export function createLivekitBackend(
}; };
} }
/** Resolves once the connection holds its token; rejects with its error otherwise. */
async function authenticated(
connection$: Behavior<Connection | null>,
): Promise<void> {
const state = await firstValueFrom(
connection$.pipe(
switchMap((connection) => connection?.state$ ?? of(null)),
filter(
(state) =>
state !== null &&
(state.state === "authenticated" ||
state.state === "error" ||
state.state === "stopped"),
),
),
);
if (state?.state === "error") throw state.error;
if (state?.state === "stopped")
throw new MatrixRTCError(
"The local connection stopped before it was authenticated",
);
}
function backendConnection$( function backendConnection$(
connection: Connection, connection: Connection,
): Observable<BackendConnection> { ): Observable<BackendConnection> {
@@ -252,10 +311,10 @@ export interface TransportRegistry {
/** Keeps a member's transport registered for as long as its scope lives. */ /** Keeps a member's transport registered for as long as its scope lives. */
follow( follow(
scope: ObservableScope, scope: ObservableScope,
transport$: Behavior<LivekitTransport | undefined>, locator$: Behavior<TransportLocator | undefined>,
): void; ): void;
/** Every transport some member is on, for the connection manager. */ /** Every transport some member is on, for the connection map. */
transports$: Behavior<Epoch<LivekitTransport[]>>; transports$: Behavior<Epoch<TransportLocator[]>>;
} }
/** /**
@@ -267,34 +326,34 @@ export function createTransportRegistry(
scope: ObservableScope, scope: ObservableScope,
): TransportRegistry { ): TransportRegistry {
const counts = new Map< const counts = new Map<
string, TransportKey,
{ transport: LivekitTransport; count: number } { locator: TransportLocator; count: number }
>(); >();
const transports = new BehaviorSubject<LivekitTransport[]>([]); const transports = new BehaviorSubject<TransportLocator[]>([]);
const publish = (): void => const publish = (): void =>
transports.next([...counts.values()].map(({ transport }) => transport)); transports.next([...counts.values()].map(({ locator }) => locator));
const register = (transport: LivekitTransport): (() => void) => { const register = (locator: TransportLocator): (() => void) => {
const url = transport.livekit_service_url; const key = keyFor(locator);
const entry = counts.get(url) ?? { transport, count: 0 }; const entry = counts.get(key) ?? { locator, count: 0 };
entry.count++; entry.count++;
if (entry.count === 1) { if (entry.count === 1) {
counts.set(url, entry); counts.set(key, entry);
publish(); publish();
} }
return () => { return () => {
if (--entry.count > 0) return; if (--entry.count > 0) return;
counts.delete(url); counts.delete(key);
publish(); publish();
}; };
}; };
return { return {
follow: (memberScope, transport$) => { follow: (memberScope, locator$) => {
let release: (() => void) | undefined; let release: (() => void) | undefined;
transport$.pipe(memberScope.bind()).subscribe((transport) => { locator$.pipe(memberScope.bind()).subscribe((locator) => {
release?.(); release?.();
release = transport && register(transport); release = locator && register(locator);
}); });
memberScope.onEnd(() => release?.()); memberScope.onEnd(() => release?.());
}, },
@@ -303,33 +362,12 @@ export function createTransportRegistry(
} }
/** /**
* Whether the SFU can take over restarting the delayed leave. Either the * Whether a service can take over restarting the delayed leave. The endpoint
* homeserver or the transport has to support it. Each endpoint is hit without * is hit without credentials and read for a 404, not retried: many servers
* credentials and read for a 404, not retried: many servers predate the * predate the endpoint altogether and answer with a CORS failure that a retry
* endpoint altogether and answer with a CORS failure that a retry loop would * loop would only repeat.
* only repeat.
*/ */
export async function supportsDelegation( export async function supportsDelegation(
homeserverUrl: string,
transport: LivekitTransport,
logger: Logger,
): Promise<boolean> {
const [homeserver, sfu] = await Promise.all([
endpointExists(
`${homeserverUrl}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`,
"homeserver",
logger,
),
endpointExists(
`${transport.livekit_service_url}/delegate_delayed_leave`,
`transport ${transport.livekit_service_url}`,
logger,
),
]);
return homeserver || sfu;
}
async function endpointExists(
url: string, url: string,
serviceName: string, serviceName: string,
logger: Logger, logger: Logger,
+16 -20
View File
@@ -35,13 +35,14 @@ import {
import { type EncryptionSystem } from "../../encryption"; import { type EncryptionSystem } from "../../encryption";
import { FailToStartLivekitConnection, toMatrixRTCError } from "../../errors"; import { FailToStartLivekitConnection, toMatrixRTCError } from "../../errors";
import { type Behavior } from "../../reactive/Behavior"; import { type Behavior } from "../../reactive/Behavior";
import { type ObservableScope } from "../../reactive/ObservableScope";
import { type LocalMediaBackend, MediaConnectionState } from "../api";
import { type Connection } from "./Connection";
import { import {
type IConnectionManager, type Epoch,
type LocalTransport, type ObservableScope,
} from "./ConnectionManager"; } from "../../reactive/ObservableScope";
import { type LocalMediaBackend, MediaConnectionState } from "../api";
import { type TransportLocator } from "./auth";
import { type Connection } from "./Connection";
import { type ConnectionMap } from "./ConnectionMap";
import { createLocalLivekitMemberMedia } from "./LivekitMemberMedia"; import { createLocalLivekitMemberMedia } from "./LivekitMemberMedia";
import { livekitSources } from "./LivekitMediaTrack"; import { livekitSources } from "./LivekitMediaTrack";
import { type DesiredMedia, Publisher } from "./Publisher"; import { type DesiredMedia, Publisher } from "./Publisher";
@@ -49,8 +50,9 @@ import { toMediaConnectionState } from "./connectionState";
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
connectionManager: IConnectionManager; connectionMap$: Behavior<Epoch<ConnectionMap>>;
localTransport$: Observable<LocalTransport>; /** The transport we publish on; null until the session has found it. */
localTransport$: Behavior<TransportLocator | null>;
publish: PublishRequest[]; publish: PublishRequest[];
/** Read by the connection factory for the rooms it creates from now on. */ /** Read by the connection factory for the rooms it creates from now on. */
audioOutputDeviceId$: BehaviorSubject<string | undefined>; audioOutputDeviceId$: BehaviorSubject<string | undefined>;
@@ -71,7 +73,7 @@ export interface LivekitLocalMedia extends LocalMediaBackend {
*/ */
export function createLivekitLocalMedia({ export function createLivekitLocalMedia({
scope, scope,
connectionManager, connectionMap$,
localTransport$, localTransport$,
publish: initialRequests, publish: initialRequests,
audioOutputDeviceId$, audioOutputDeviceId$,
@@ -81,15 +83,11 @@ export function createLivekitLocalMedia({
const logger = parentLogger.getChild("[LocalMedia]"); const logger = parentLogger.getChild("[LocalMedia]");
const connection$ = scope.behavior( const connection$ = scope.behavior(
combineLatest([ combineLatest(
connectionManager.connectionManagerData$, [connectionMap$, localTransport$],
localTransport$, ({ value: connections }, transport) =>
]).pipe( transport && connections.getConnectionForTransport(transport),
map(([{ value: connections }, { transport }]) =>
connections.getConnectionForTransport(transport),
), ),
),
null,
); );
const connectionState$ = scope.behavior<MediaConnectionState | Error>( const connectionState$ = scope.behavior<MediaConnectionState | Error>(
@@ -117,9 +115,7 @@ export function createLivekitLocalMedia({
const publisher = new Publisher( const publisher = new Publisher(
connection.livekitRoom, connection.livekitRoom,
desired, desired,
logger.getChild( logger.getChild(`[Publisher ${JSON.stringify(connection.transport)}]`),
`[Publisher ${connection.transport.livekit_service_url}]`,
),
); );
publisher$.next(publisher); publisher$.next(publisher);
return Promise.resolve(async (): Promise<void> => { return Promise.resolve(async (): Promise<void> => {
+102
View File
@@ -0,0 +1,102 @@
/*
Copyright 2026 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 { ConnectionError, HTTPError, type MatrixClient } from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { doNetworkOperationWithRetry } from "../../../utils/network";
import { extractFullConfigFromToken, type SFUConfig } from "./types";
/**
* In widget mode the host may refuse an action before any request is made,
* which surfaces as a plain error. Asking again cannot change its answer, so
* only errors from the network are worth retrying.
*/
function isNetworkError(e: unknown): boolean {
return e instanceof HTTPError || e instanceof ConnectionError;
}
export type ClientGetTokenParts = Pick<
MatrixClient,
"_unstable_getLivekitToken"
>;
export interface GetSFUConfigParams {
client: ClientGetTokenParts;
/** Our own membership identity; the member id is what the token is issued for. */
membership: CallMembershipIdentityParts;
/** The WebSocket URL of the SFU to get a token for. */
url: string;
/** The homeserver the SFU belongs to. */
serverName: string;
roomId: string;
slotId: string;
}
/** Gets an SFU token through the homeserver's MSC4195 `get_token` endpoint. */
export async function getSFUConfig({
client,
membership,
url,
serverName,
roomId,
slotId,
}: GetSFUConfigParams): Promise<SFUConfig> {
const res = await doNetworkOperationWithRetry(
async () =>
client._unstable_getLivekitToken({
url,
server_name: serverName,
room_id: roomId,
slot_id: slotId,
member_id: membership.memberId,
}),
isNetworkError,
);
return extractFullConfigFromToken({ url, jwt: res.jwt });
}
export type ClientDelegationParts = Pick<
MatrixClient,
"_unstable_delegateDelayedLeave" | "baseUrl"
>;
export interface DelegateDelayedLeaveParams {
client: ClientDelegationParts;
membership: CallMembershipIdentityParts;
/** The WebSocket URL of the SFU we publish on. */
url: string;
roomId: string;
slotId: string;
/** The delay ID of the leave event to hand over. */
delayId: string;
}
/**
* Hands the delayed leave to the homeserver through the MSC4195
* `delegate_delayed_leave` endpoint, which sends it once we drop off the SFU.
*/
export async function delegateDelayedLeave({
client,
membership,
url,
roomId,
slotId,
delayId,
}: DelegateDelayedLeaveParams): Promise<void> {
await doNetworkOperationWithRetry(
async () =>
client._unstable_delegateDelayedLeave({
url,
room_id: roomId,
slot_id: slotId,
member_id: membership.memberId,
delay_id: delayId,
}),
isNetworkError,
);
}
@@ -0,0 +1,407 @@
/*
Copyright 2026 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 { afterEach, describe, expect, it, type Mocked, vi } from "vitest";
import fetchMock from "fetch-mock";
import { ConnectionError, MatrixError } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { NoMatrix2AuthorizationService } from "../../../errors";
import {
aliceDeviceId,
aliceUserId,
createJWTToken,
} from "../../../utils/test-fixtures";
import type * as csApi from "./csApi";
import type * as openID from "./openID";
import { chooseAuthFlow, delegateDelayedLeave, getSFUConfig } from ".";
const aliceId = `${aliceUserId}:${aliceDeviceId}`;
const csApiJWTToken = createJWTToken(aliceId, "via CS API");
const legacyJWTToken = createJWTToken(aliceId, "via legacy endpoint");
const defaultJWTToken = createJWTToken(aliceId, "via default endpoint");
const client: Mocked<
csApi.ClientGetTokenParts &
csApi.ClientDelegationParts &
openID.ClientOpenIDParts
> = {
baseUrl: "https://matrix.example.org",
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: csApiJWTToken }),
_unstable_delegateDelayedLeave: vi.fn().mockResolvedValue({}),
getOpenIdToken: vi
.fn()
.mockResolvedValue({ access_token: "mock-openID-token" }),
};
const membership: CallMembershipIdentityParts = {
userId: aliceUserId,
deviceId: aliceDeviceId,
memberId: "123",
};
const roomId = "!room:example.org";
const slotId = "m.call#ROOM";
const unsupportedError = new MatrixError({ errcode: "M_UNSUPPORTED" }, 404);
const urlTransport: UnstableLivekitTransport = {
type: "livekit",
url: "wss://livekit.example.org",
};
const serviceUrl = "https://livekit-jwt.example.org";
const serviceUrlTransport: UnstableLivekitTransport = {
type: "livekit",
livekit_service_url: serviceUrl,
};
const urlAndServiceUrlTransport: UnstableLivekitTransport = {
...urlTransport,
...serviceUrlTransport,
};
/** The JWT service, answering each endpoint with a token that names it. */
function mockJWTService(): void {
fetchMock.post(`${serviceUrl}/sfu/get`, {
status: 200,
body: { url: "wss://sfu", jwt: legacyJWTToken },
});
fetchMock.post(`${serviceUrl}/get_token`, {
status: 200,
body: { url: "wss://sfu", jwt: defaultJWTToken },
});
}
/** Whether the JWT service was asked at all, and which endpoint. */
function jwtServiceCalls(): string[] {
return fetchMock.calls().map(([url]) => url.replace(serviceUrl, ""));
}
afterEach(() => {
vi.clearAllMocks();
fetchMock.reset();
});
describe("getSFUConfig", () => {
const defaultParams = {
client,
membership,
serverName: "example.org",
role: "subscriber" as const,
roomId,
slotId,
logger,
};
it("uses CS API with url transport", async () => {
mockJWTService();
expect(
await getSFUConfig({ ...defaultParams, transport: urlTransport }),
).toEqual(
expect.objectContaining({
jwt: csApiJWTToken,
livekitAlias: "via CS API",
}),
);
expect(client._unstable_getLivekitToken).toHaveBeenCalledWith({
url: urlTransport.url,
server_name: "example.org",
room_id: roomId,
slot_id: slotId,
member_id: "123",
});
expect(jwtServiceCalls()).toEqual([]);
});
it("does not fall back to OpenID with url transport", async () => {
mockJWTService();
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
await expect(
getSFUConfig({ ...defaultParams, transport: urlTransport }),
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual([]);
});
it("uses OpenID with livekit_service_url transport", async () => {
mockJWTService();
expect(
await getSFUConfig({ ...defaultParams, transport: serviceUrlTransport }),
).toEqual(
expect.objectContaining({
jwt: defaultJWTToken,
livekitAlias: "via default endpoint",
}),
);
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual(["/get_token"]);
});
it("uses CS API when subscribing with url + livekit_service_url transport", async () => {
mockJWTService();
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
}),
).toEqual(
expect.objectContaining({
jwt: csApiJWTToken,
livekitAlias: "via CS API",
}),
);
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual([]);
});
it("falls back to OpenID with url + livekit_service_url transport", async () => {
mockJWTService();
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
}),
).toEqual(
expect.objectContaining({
jwt: defaultJWTToken,
livekitAlias: "via default endpoint",
}),
);
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual(["/get_token"]);
});
it("falls back to OpenID when the widget host refuses the CS API", async () => {
mockJWTService();
// In widget mode a host without MSC4533 rejects the request before it
// reaches the homeserver, with a plain error rather than a Matrix error
client._unstable_getLivekitToken.mockRejectedValueOnce(
new Error(
"The rtc_livekit_get_token action is not supported by the client.",
),
);
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
}),
).toEqual(expect.objectContaining({ jwt: defaultJWTToken }));
expect(jwtServiceCalls()).toEqual(["/get_token"]);
});
it("surfaces the widget host's refusal with url transport", async () => {
mockJWTService();
const error = new Error(
"The rtc_livekit_get_token action is not supported by the client.",
);
client._unstable_getLivekitToken.mockRejectedValueOnce(error);
await expect(
getSFUConfig({ ...defaultParams, transport: urlTransport }),
).rejects.toThrow(error);
expect(jwtServiceCalls()).toEqual([]);
});
it("uses CS API when publishing with url transport", async () => {
mockJWTService();
expect(
await getSFUConfig({
...defaultParams,
transport: urlTransport,
role: "publisher",
}),
).toEqual(expect.objectContaining({ jwt: csApiJWTToken }));
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual([]);
});
it("does not fall back to OpenID when publishing", async () => {
mockJWTService();
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
await expect(
getSFUConfig({
...defaultParams,
transport: urlTransport,
role: "publisher",
}),
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual([]);
});
it("uses the legacy OpenID endpoint when publishing with url + livekit_service_url transport", async () => {
mockJWTService();
// Subscribers predating MSC4195 expect a legacy participant identity from
// anyone advertising a membership, which only the JWT service assigns
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
role: "publisher",
}),
).toEqual(
expect.objectContaining({
jwt: legacyJWTToken,
livekitAlias: "via legacy endpoint",
}),
);
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual(["/sfu/get"]);
});
it("does not fall back to OpenID on connection error", async () => {
vi.useFakeTimers();
try {
mockJWTService();
const client_ = {
...client,
_unstable_getLivekitToken: vi
.fn()
.mockRejectedValue(new ConnectionError("No connection")),
};
await Promise.all([
expect(
getSFUConfig({
...defaultParams,
client: client_,
transport: urlAndServiceUrlTransport,
}),
).rejects.toThrow(expect.any(ConnectionError)),
vi.runAllTimersAsync(), // Allow request to be retried
]);
expect(client_._unstable_getLivekitToken).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual([]);
} finally {
vi.useRealTimers();
}
});
});
describe("delegateDelayedLeave", () => {
const defaultParams = {
client,
membership,
roomId,
slotId,
delayId: "delay123",
delayTimeoutMs: 3_600_000,
logger,
};
/** The delay fields the JWT service was handed, if any. */
function delegationViaJWTService(): object | undefined {
const call = fetchMock.calls().find(([url]) => url.endsWith("/sfu/get"));
if (call === undefined) return undefined;
const { delay_id, delay_timeout, delay_cs_api_url } = JSON.parse(
call[1]?.body as string,
);
return { delay_id, delay_timeout, delay_cs_api_url };
}
it("uses CS API with url transport", async () => {
mockJWTService();
await delegateDelayedLeave({ ...defaultParams, transport: urlTransport });
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalledWith({
url: urlTransport.url,
room_id: roomId,
slot_id: slotId,
member_id: "123",
delay_id: "delay123",
});
expect(jwtServiceCalls()).toEqual([]);
});
it("does not fall back to OpenID with url transport", async () => {
mockJWTService();
client._unstable_delegateDelayedLeave.mockRejectedValueOnce(
unsupportedError,
);
await expect(
delegateDelayedLeave({ ...defaultParams, transport: urlTransport }),
).rejects.toThrow();
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual([]);
});
it("uses OpenID with livekit_service_url transport", async () => {
mockJWTService();
await delegateDelayedLeave({
...defaultParams,
transport: serviceUrlTransport,
});
expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled();
expect(delegationViaJWTService()).toEqual({
delay_id: "delay123",
delay_timeout: 3_600_000,
delay_cs_api_url: client.baseUrl,
});
});
it("uses OpenID with url + livekit_service_url transport", async () => {
mockJWTService();
// Must match the flow that got the token, so the identities line up
await delegateDelayedLeave({
...defaultParams,
transport: urlAndServiceUrlTransport,
});
expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled();
expect(delegationViaJWTService()).toEqual({
delay_id: "delay123",
delay_timeout: 3_600_000,
delay_cs_api_url: client.baseUrl,
});
});
});
describe("chooseAuthFlow", () => {
it.each([
[
"url",
"publisher",
urlTransport,
{ kind: "csApi", url: urlTransport.url },
],
[
"url",
"subscriber",
urlTransport,
{ kind: "csApi", url: urlTransport.url },
],
[
"livekit_service_url",
"publisher",
serviceUrlTransport,
{ kind: "openID", serviceUrl },
],
[
"livekit_service_url",
"subscriber",
serviceUrlTransport,
{ kind: "openID", serviceUrl },
],
[
"url + livekit_service_url",
"publisher",
urlAndServiceUrlTransport,
{ kind: "openID", serviceUrl },
],
[
"url + livekit_service_url",
"subscriber",
urlAndServiceUrlTransport,
{ kind: "csApi", url: urlTransport.url },
],
] as const)(
"chooses the flow for a %s transport as %s",
(_shape, role, transport, flow) => {
expect(chooseAuthFlow(transport, role)).toEqual(flow);
},
);
});
+212
View File
@@ -0,0 +1,212 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* Authentication with a LiveKit transport: the MSC4195 client-server API
* through our homeserver for a transport with an SFU `url`, the OpenID flow
* against the JWT service for one with a `livekit_service_url`, and the
* choice between them where a transport carries both.
*/
import { ConnectionError, MatrixError } from "matrix-js-sdk";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { NoMatrix2AuthorizationService } from "../../../errors";
import * as csApi from "./csApi";
import * as openID from "./openID";
import { type ConnectionRole, type SFUConfig } from "./types";
export type { ClientOpenIDParts } from "./openID";
export type { ClientGetTokenParts, ClientDelegationParts } from "./csApi";
export type * from "./types";
/**
* Whether an error means the homeserver does not implement the MSC4195
* endpoints. The MSC says `M_NOT_FOUND`; a homeserver without support answers
* with whatever it uses for unknown routes (Synapse: 404 with
* `M_UNRECOGNIZED`), so any 404 counts.
*/
function isEndpointUnsupported(e: unknown): boolean {
return (
e instanceof MatrixError &&
(e.httpStatus === 404 ||
e.errcode === "M_NOT_FOUND" ||
e.errcode === "M_UNRECOGNIZED")
);
}
/**
* Whether an error means the MSC4195 client-server API cannot be used from
* here: the homeserver does not implement it, or the request never reached
* the homeserver because a widget host does not expose the endpoint
* (MSC4533), which the widget API reports as a plain error. A connection
* error is neither: the endpoint may well exist.
*/
function isCsApiUnavailable(e: unknown): boolean {
if (isEndpointUnsupported(e)) return true;
return !(e instanceof MatrixError) && !(e instanceof ConnectionError);
}
export interface GetSFUConfigParams {
client: csApi.ClientGetTokenParts & openID.ClientOpenIDParts;
membership: CallMembershipIdentityParts;
transport: UnstableLivekitTransport;
/** The homeserver the transport belongs to. */
serverName: string;
roomId: string;
slotId: string;
role: ConnectionRole;
logger: Logger;
}
/** Authenticates with a transport, through whichever flow suits it and our role. */
export async function getSFUConfig({
client,
membership,
transport,
serverName,
roomId,
slotId,
role,
logger,
}: GetSFUConfigParams): Promise<SFUConfig> {
const viaOpenID = async (serviceUrl: string): Promise<SFUConfig> =>
await openID.getSFUConfig({
client,
membership,
serviceUrl,
roomId,
slotId,
role,
logger,
});
const flow = chooseAuthFlow(transport, role);
if (flow.kind === "openID") return await viaOpenID(flow.serviceUrl);
try {
return await csApi.getSFUConfig({
client,
membership,
url: flow.url,
serverName,
roomId,
slotId,
});
} catch (e) {
// A publisher never falls back: a homeserver that advertises a `url` but
// does not implement MSC4195 is misconfigured, and the admin should hear
// about it before `livekit_service_url` is deprecated
const mayFallBack =
role === "subscriber" && "livekit_service_url" in transport;
if (mayFallBack && isCsApiUnavailable(e)) {
logger.warn(
`Cannot use the MSC4195 get_token endpoint (${e}), falling back to the OpenID flow with service ${transport.livekit_service_url}`,
);
return await viaOpenID(transport.livekit_service_url);
}
if (isEndpointUnsupported(e)) {
logger.error(
`Homeserver does not support the MSC4195 get_token endpoint, cannot get token for transport ${flow.url}`,
);
throw new NoMatrix2AuthorizationService(e as Error);
}
throw e;
}
}
export interface DelegateDelayedLeaveParams {
client: csApi.ClientDelegationParts & openID.ClientOpenIDParts;
membership: CallMembershipIdentityParts;
/** The transport we publish on. */
transport: UnstableLivekitTransport;
roomId: string;
slotId: string;
delayId: string;
/** How long the service waits before sending the leave, for the JWT service flow. */
delayTimeoutMs: number;
logger: Logger;
}
/**
* Hands the delayed leave to the service that authenticated us as a
* publisher, so that the identity it watches for on the SFU is the one we
* publish under.
*/
export async function delegateDelayedLeave({
client,
membership,
transport,
roomId,
slotId,
delayId,
delayTimeoutMs,
logger,
}: DelegateDelayedLeaveParams): Promise<void> {
const flow = chooseAuthFlow(transport, "publisher");
if (flow.kind === "csApi") {
try {
await csApi.delegateDelayedLeave({
client,
membership,
url: flow.url,
roomId,
slotId,
delayId,
});
} catch (e) {
if (isEndpointUnsupported(e))
logger.error(
"Server misconfigured: transport contains a 'url' field but server does not support the MSC4195 delegate_delayed_leave endpoint",
);
throw e;
}
} else {
// The token this issues is discarded; the request is what hands the leave over
await openID.getSFUConfig({
client,
membership,
serviceUrl: flow.serviceUrl,
roomId,
slotId,
role: "publisher",
delayEndpointBaseUrl: client.baseUrl,
delayId,
delayTimeoutMs,
logger,
});
}
}
/** The flow with which the local member authenticates against a transport. */
export type AuthFlow =
| { kind: "csApi"; url: string }
| { kind: "openID"; serviceUrl: string };
/**
* Chooses between the MSC4195 client-server API and the OpenID flow against
* the transport's JWT service.
*
* Only the JWT service's `/sfu/get` endpoint assigns the legacy participant
* identity that clients predating MSC4195 expect from anyone advertising a
* membership, so a publisher keeps using the JWT service wherever the
* transport still offers one, even next to a `url`. A subscriber may use the
* client-server API regardless, as nobody matches its identity.
*/
export function chooseAuthFlow(
transport: UnstableLivekitTransport,
role: ConnectionRole,
): AuthFlow {
if ("url" in transport) {
const useCsApi =
!("livekit_service_url" in transport) || role === "subscriber";
if (useCsApi) return { kind: "csApi", url: transport.url };
}
return { kind: "openID", serviceUrl: transport.livekit_service_url };
}
@@ -0,0 +1,229 @@
/*
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,
afterEach,
describe,
expect,
it,
type MockedObject,
vitest,
} from "vitest";
import fetchMock from "fetch-mock";
import { MatrixError } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import {
getSFUConfig,
type GetSFUConfigParams,
type ClientOpenIDParts,
} from "./openID";
import { testJWTToken } from "../../../utils/test-fixtures";
import { ownMemberMock } from "../../../utils/test";
import { FailToGetOpenIdToken } from "../../../errors";
const serviceUrl = "https://sfu.example.org";
const delayParams = {
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
delayId: "mock_delay_id",
delayTimeoutMs: 3_600_000,
};
describe("getSFUConfig", () => {
let matrixClient: MockedObject<ClientOpenIDParts>;
let params: GetSFUConfigParams;
beforeEach(() => {
fetchMock.catch(404);
matrixClient = { getOpenIdToken: vitest.fn() };
params = {
client: matrixClient,
membership: ownMemberMock,
serviceUrl: "https://sfu.example.org",
roomId: "!example_room_id",
slotId: "m.call#ROOM",
role: "publisher",
logger,
};
});
afterEach(async () => {
void (await fetchMock.flush());
vitest.clearAllMocks();
fetchMock.reset();
});
it("should handle fetching a token", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfig(params);
expect(config).toEqual({
jwt: testJWTToken,
url: serviceUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
});
it("should fail if the SFU errors", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
try {
await getSFUConfig(params);
} catch (ex: unknown) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
const mxError = (ex as Error).cause as MatrixError;
expect(mxError.message).toEqual(
"MatrixError: [500] Failed to look up user info from homeserver",
);
return;
}
expect.fail("Expected test to throw;");
});
it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => {
let callCount = 0;
fetchMock.post(
"https://sfu.example.org/sfu/get",
(url, opts) => {
callCount++;
const body = JSON.parse(opts.body as string);
// First call: check if it has delay parts and return 400
if (callCount === 1) {
expect(body).toHaveProperty("delay_id", "mock_delay_id");
return {
status: 400,
body: { errcode: "M_BAD_JSON", error: "Unsupported parameters" },
};
}
// Second call: check if delay parts were stripped and return success
expect(body).not.toHaveProperty("delay_id");
expect(body).not.toHaveProperty("delay_timeout");
expect(body).not.toHaveProperty("delay_cs_api_url");
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
},
{ overwriteRoutes: true },
);
const config = await getSFUConfig({ ...params, ...delayParams });
expect(config.jwt).toBe(testJWTToken);
expect(callCount).toBe(2);
});
it("should successfully send delay parameters to the JWT service legacy endpoint", async () => {
fetchMock.post(
"https://sfu.example.org/sfu/get",
(url, opts) => {
const body = JSON.parse(opts.body as string);
// Verify, that the request contains the expected delay parameters
if (
body.delay_id === "mock_delay_id" &&
body.delay_timeout === 3600000 &&
body.delay_cs_api_url === "https://matrix.homeserverserver.org"
) {
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
}
return {
status: 400,
body: { error: "Missing expected delay params" },
};
},
{ overwriteRoutes: true },
);
const config = await getSFUConfig({ ...params, ...delayParams });
expect(config).toMatchObject({
jwt: testJWTToken,
url: serviceUrl,
});
});
it("should use the default endpoint when subscribing", async () => {
fetchMock.post("https://sfu.example.org/get_token", (url, opts) => {
const body = JSON.parse(opts.body as string);
expect(body).toMatchObject({
room_id: "!example_room_id",
slot_id: "m.call#ROOM",
member: {
id: ownMemberMock.memberId,
claimed_user_id: ownMemberMock.userId,
claimed_device_id: ownMemberMock.deviceId,
},
});
return { status: 200, body: { url: serviceUrl, jwt: testJWTToken } };
});
const config = await getSFUConfig({ ...params, role: "subscriber" });
expect(config.jwt).toBe(testJWTToken);
expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false);
});
it("should fail when subscribing if the default endpoint is missing", async () => {
// Nothing but the catch-all 404 answers the default endpoint. Subscribers
// must not fall back to the legacy endpoint, which would hand them a
// legacy identity.
await expect(
getSFUConfig({ ...params, role: "subscriber" }),
).rejects.toThrow(FailToGetOpenIdToken);
expect(fetchMock.called("https://sfu.example.org/get_token")).toBe(true);
expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false);
});
it("should retry fetching the openid token", async () => {
let count = 0;
matrixClient.getOpenIdToken.mockImplementation(async () => {
count++;
if (count < 2) {
throw Error("Test failure");
}
return Promise.resolve({
token_type: "Bearer",
access_token: "foobar",
matrix_server_name: "example.org",
expires_in: 30,
});
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfig({ ...params, ...delayParams });
expect(config).toEqual({
jwt: testJWTToken,
url: serviceUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
});
});
@@ -0,0 +1,203 @@
/*
Copyright 2023, 2024 New Vector Ltd.
Copyright 2026 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 {
type IOpenIDToken,
type MatrixClient,
parseErrorResponse,
} from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { FailToGetOpenIdToken } from "../../../errors";
import { doNetworkOperationWithRetry } from "../../../utils/network";
import {
type ConnectionRole,
extractFullConfigFromToken,
type SFUConfig,
} from "./types";
export type ClientOpenIDParts = Pick<MatrixClient, "getOpenIdToken">;
export interface GetSFUConfigParams {
client: ClientOpenIDParts;
/** Our own membership identity, as the JWT service wants it. */
membership: CallMembershipIdentityParts;
/** The base URL of the LiveKit JWT service. */
serviceUrl: string;
/** The Matrix room the session is in; the JWT service maps it to a LiveKit room. */
roomId: string;
/** The MatrixRTC slot the session is in, e.g. `m.call#ROOM`. */
slotId: string;
role: ConnectionRole;
/** The base URL of the Matrix homeserver, for delayed leave delegation. */
delayEndpointBaseUrl?: string;
/** The delay ID of the leave event to delegate to the JWT service. */
delayId?: string;
/** How long the SFU waits before sending the delegated leave. Needed with `delayId`. */
delayTimeoutMs?: number;
logger: Logger;
}
/**
* Authenticates with a LiveKit JWT service through the OpenID flow: an OpenID
* token from the homeserver, exchanged at the service for an SFU token.
*
* A publisher uses the legacy `/sfu/get` endpoint, the only one that assigns
* the `<user_id>:<device_id>` identity that clients predating MSC4195 match a
* membership against. A subscriber uses the `/get_token` endpoint, which
* hashes the identity: nobody matches a subscriber.
* @throws {@link FailToGetOpenIdToken}
*/
export async function getSFUConfig({
client,
membership,
serviceUrl,
roomId,
slotId,
role,
delayEndpointBaseUrl,
delayId,
delayTimeoutMs,
logger,
}: GetSFUConfigParams): Promise<SFUConfig> {
let openIdToken: IOpenIDToken;
try {
openIdToken = await doNetworkOperationWithRetry(async () =>
client.getOpenIdToken(),
);
} catch (error) {
throw new FailToGetOpenIdToken(
error instanceof Error ? error : new Error("Unknown error"),
);
}
logger.debug("Got openID token", openIdToken);
const delay: DelayParams =
// Also check for empty string
delayId && delayEndpointBaseUrl
? {
delay_id: delayId,
delay_timeout: delayTimeoutMs,
delay_cs_api_url: delayEndpointBaseUrl,
}
: {};
const endpoint = role === "publisher" ? "legacy" : "default";
try {
logger.info(
`Trying to get JWT with ${endpoint} endpoint for focus ${serviceUrl}...`,
);
const sfuConfig = await (
endpoint === "legacy" ? getLiveKitJWTLegacy : getLiveKitJWT
)(membership, serviceUrl, roomId, slotId, openIdToken, delay);
logger.info(`Got JWT from call's active focus URL.`);
return extractFullConfigFromToken(sfuConfig);
} catch (e) {
logger.error(`Failed fetching jwt with ${endpoint} endpoint:`, e);
throw new FailToGetOpenIdToken(
e instanceof Error ? e : new Error(`Unknown error ${e}`),
);
}
}
interface DelayParams {
delay_id?: string;
delay_timeout?: number;
delay_cs_api_url?: string;
}
/** The legacy `/sfu/get` endpoint, which assigns the `<user_id>:<device_id>` identity. */
async function getLiveKitJWTLegacy(
membership: CallMembershipIdentityParts,
livekitServiceURL: string,
matrixRoomId: string,
_slotId: string, // The legacy endpoint predates slots
openIDToken: IOpenIDToken,
delay: DelayParams,
): Promise<{ url: string; jwt: string }> {
const makeRequest = async (delayParts: DelayParams): Promise<Response> =>
await fetch(livekitServiceURL + "/sfu/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
// The legacy JWT endpoint uses only the matrix room id to calculate
// the livekit room alias, which comes back in the JWT payload
room: matrixRoomId,
openid_token: openIDToken,
device_id: membership.deviceId,
...delayParts,
}),
});
const res = await doNetworkOperationWithRetry(async () => {
let response = await makeRequest(delay);
// A service from before delegation rejects the delay fields as bad JSON;
// it still issues a token without them
if (response.status === 400 && Object.keys(delay).length > 0) {
try {
const errorBody = await response.json();
if (errorBody.errcode === "M_BAD_JSON")
response = await makeRequest({});
} catch {
// Not a JSON error body, so a real error
}
}
return response;
});
if (!res.ok) throw parseErrorResponse(res, await res.text());
return await res.json();
}
class NotSupportedError extends Error {
public constructor(message: string) {
super(message);
this.name = "NotSupported";
}
}
/** The `/get_token` endpoint, which assigns a hashed identity. */
async function getLiveKitJWT(
membership: CallMembershipIdentityParts,
livekitServiceURL: string,
matrixRoomId: string,
slotId: string,
openIDToken: IOpenIDToken,
delay: DelayParams,
): Promise<{ url: string; jwt: string }> {
const { userId, deviceId, memberId } = membership;
const res = await doNetworkOperationWithRetry(
async () =>
await fetch(livekitServiceURL + "/get_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
room_id: matrixRoomId,
slot_id: slotId,
openid_token: openIDToken,
member: {
id: memberId,
claimed_user_id: userId,
claimed_device_id: deviceId,
},
...delay,
}),
}),
);
if (!res.ok) {
const msg = "SFU Config fetch failed with status code " + res.status;
if (res.status === 404) throw new NotSupportedError(msg);
throw parseErrorResponse(res, await res.text());
}
return await res.json();
}
@@ -0,0 +1,58 @@
/*
Copyright 2026 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 { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
/**
* A transport and the homeserver it belongs to. The server name is what our
* homeserver asks for a token for a transport with a `url`, over federation
* where the server is not ours; a JWT service is reached directly.
*/
export interface TransportLocator {
transport: UnstableLivekitTransport;
serverName: string;
}
/** Whether we publish on a transport or only subscribe to it. */
export type ConnectionRole = "publisher" | "subscriber";
/** What authenticating with a transport yields: where to connect and with what. */
export interface SFUConfig {
/**
* The WebSocket URL of the SFU, as opposed to the JWT service URL in the
* transport. The legacy JWT service returns it; the client-server API flow
* has it from the transport.
*/
url: string;
jwt: string;
livekitAlias: string;
livekitIdentity: string;
}
/** The parts of the JWT payload the backend reads. */
interface SFUJWTPayload {
/** The subject: the participant identity the token carries. */
sub: string;
video: {
room: string;
};
}
/** Completes the SFU websocket url and the JWT with what the JWT payload says. */
export function extractFullConfigFromToken(sfuConfig: {
url: string;
jwt: string;
}): SFUConfig {
const [, payloadStr] = sfuConfig.jwt.split(".");
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
return {
jwt: sfuConfig.jwt,
url: sfuConfig.url,
livekitAlias: payload.video.room,
livekitIdentity: payload.sub,
};
}
@@ -5,24 +5,34 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { MediaConnectionState } from "../api"; import { ConnectionState as LivekitConnectionState } from "livekit-client";
import { ConnectionState } from "./Connection";
const states: Record<ConnectionState, MediaConnectionState> = { import { MediaConnectionState } from "../api";
[ConnectionState.Initialized]: MediaConnectionState.Initialized, import { type ConnectionState } from "./Connection";
[ConnectionState.FetchingConfig]: MediaConnectionState.Preparing,
[ConnectionState.Stopped]: MediaConnectionState.Stopped, const livekitStates: Record<LivekitConnectionState, MediaConnectionState> = {
[ConnectionState.LivekitDisconnected]: MediaConnectionState.Disconnected, [LivekitConnectionState.Disconnected]: MediaConnectionState.Disconnected,
[ConnectionState.LivekitConnecting]: MediaConnectionState.Connecting, [LivekitConnectionState.Connecting]: MediaConnectionState.Connecting,
[ConnectionState.LivekitConnected]: MediaConnectionState.Connected, [LivekitConnectionState.Connected]: MediaConnectionState.Connected,
[ConnectionState.LivekitReconnecting]: MediaConnectionState.Reconnecting, [LivekitConnectionState.Reconnecting]: MediaConnectionState.Reconnecting,
[ConnectionState.LivekitSignalReconnecting]: [LivekitConnectionState.SignalReconnecting]:
MediaConnectionState.Reconnecting, MediaConnectionState.Reconnecting,
}; };
/** A connection's state as the session sees it; an error passes through. */ /** A connection's state as the session sees it; an error passes through. */
export function toMediaConnectionState( export function toMediaConnectionState(
state: ConnectionState | Error, state: ConnectionState,
): MediaConnectionState | Error { ): MediaConnectionState | Error {
return state instanceof Error ? state : states[state]; switch (state.state) {
case "initialized":
return MediaConnectionState.Initialized;
case "authenticating":
return MediaConnectionState.Preparing;
case "authenticated":
return livekitStates[state.livekitState];
case "error":
return state.error;
case "stopped":
return MediaConnectionState.Stopped;
}
} }
@@ -1,332 +0,0 @@
/*
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,
afterEach,
describe,
expect,
it,
type MockedObject,
vitest,
} from "vitest";
import fetchMock from "fetch-mock";
import { MatrixError } from "matrix-js-sdk";
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
import { testJWTToken } from "../../utils/test-fixtures";
import { ownMemberMock } from "../../utils/test";
import { FailToGetOpenIdToken } from "../../errors";
const sfuUrl = "https://sfu.example.org";
describe("getSFUConfigWithOpenID", () => {
let matrixClient: MockedObject<OpenIDClientParts>;
beforeEach(() => {
fetchMock.catch(404);
matrixClient = {
getOpenIdToken: vitest.fn(),
};
});
afterEach(() => {
vitest.clearAllMocks();
fetchMock.reset();
});
it("should handle fetching a token", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
);
expect(config).toEqual({
jwt: testJWTToken,
url: sfuUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
void (await fetchMock.flush());
});
it("should fail if the SFU errors", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
try {
await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
);
} catch (ex: unknown) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
const mxError = (ex as Error).cause as MatrixError;
expect(mxError.message).toEqual(
"MatrixError: [500] Failed to look up user info from homeserver",
);
void (await fetchMock.flush());
return;
}
expect.fail("Expected test to throw;");
});
it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => {
let callCount = 0;
fetchMock.post(
"https://sfu.example.org/sfu/get",
(url, opts) => {
callCount++;
const body = JSON.parse(opts.body as string);
// First call: check if it has delay parts and return 400
if (callCount === 1) {
expect(body).toHaveProperty("delay_id", "mock_delay_id");
return {
status: 400,
body: { errcode: "M_BAD_JSON", error: "Unsupported parameters" },
};
}
// Second call: check if delay parts were stripped and return success
expect(body).not.toHaveProperty("delay_id");
expect(body).not.toHaveProperty("delay_timeout");
expect(body).not.toHaveProperty("delay_cs_api_url");
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
},
{ overwriteRoutes: true },
);
// Note: Assuming getSFUConfigWithOpenID eventually calls getLiveKitJWT
const config = await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
{
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
delayId: "mock_delay_id",
delayTimeoutMs: 3_600_000,
},
);
expect(config.jwt).toBe(testJWTToken);
expect(callCount).toBe(2);
void (await fetchMock.flush());
});
it("should successfully send delay parameters to the JWT service legacy endpoint", async () => {
fetchMock.post(
"https://sfu.example.org/sfu/get",
(url, opts) => {
const body = JSON.parse(opts.body as string);
// Verify, that the request contains the expected delay parameters
if (
body.delay_id === "mock_delay_id" &&
body.delay_timeout === 3600000 &&
body.delay_cs_api_url === "https://homeserverserver.org/cs_api"
) {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
}
return {
status: 400,
body: { error: "Missing expected delay params" },
};
},
{ overwriteRoutes: true },
);
const config = await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
{
delayEndpointBaseUrl: "https://homeserverserver.org/cs_api",
delayId: "mock_delay_id",
delayTimeoutMs: 3_600_000,
},
);
// Prüfe das Ergebnis
expect(config).toMatchObject({
jwt: testJWTToken,
url: sfuUrl,
});
void (await fetchMock.flush());
});
it("should try legacy and then new endpoint with delay delegation", async () => {
fetchMock.post("https://sfu.example.org/get_token", () => {
return {
status: 500,
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
try {
await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
{
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
delayId: "mock_delay_id",
delayTimeoutMs: 3_600_000,
},
);
} catch (ex) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
const mxError = (ex as Error).cause as MatrixError;
expect(mxError.message).toEqual(
"MatrixError: [500] Failed to look up user info from homeserver",
);
void (await fetchMock.flush());
}
const calls = fetchMock.calls();
expect(calls.length).toBe(2);
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
expect(calls[0][1]).toStrictEqual({
// check if it uses correct delayID!
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":3600000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
expect(calls[1][0]).toStrictEqual("https://sfu.example.org/sfu/get");
expect(calls[1][1]).toStrictEqual({
body: '{"room":"!example_room_id","device_id":"DEVICE","delay_id":"mock_delay_id","delay_timeout":3600000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
});
it("dont try legacy if endpoint with delay delegation is sucessful", async () => {
fetchMock.post("https://sfu.example.org/get_token", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: { error: "Test failure" },
};
});
try {
await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
{
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
delayId: "mock_delay_id",
delayTimeoutMs: 3_600_000,
},
);
} catch (ex) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toEqual(
new Error("SFU Config fetch failed with status code 500"),
);
void (await fetchMock.flush());
}
const calls = fetchMock.calls();
expect(calls.length).toBe(1);
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
expect(calls[0][1]).toStrictEqual({
// check if it uses correct delayID!
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":3600000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
});
it("should retry fetching the openid token", async () => {
let count = 0;
matrixClient.getOpenIdToken.mockImplementation(async () => {
count++;
if (count < 2) {
throw Error("Test failure");
}
return Promise.resolve({
token_type: "Bearer",
access_token: "foobar",
matrix_server_name: "example.org",
expires_in: 30,
});
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
);
expect(config).toEqual({
jwt: testJWTToken,
url: sfuUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
void (await fetchMock.flush());
});
});
-321
View File
@@ -1,321 +0,0 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type IOpenIDToken,
type MatrixClient,
parseErrorResponse,
} from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
FailToGetOpenIdToken,
NoMatrix2AuthorizationService,
} from "../../errors";
import { doNetworkOperationWithRetry } from "../../utils/network";
/**
* Configuration and access tokens provided by the SFU on successful authentication.
*/
export interface SFUConfig {
url: string;
jwt: string;
livekitAlias: string;
// NOTE: Currently unused.
livekitIdentity: string;
}
/**
* Decoded details from the JWT.
*/
interface SFUJWTPayload {
/**
* Expiration time for the JWT.
* Note: This value is in seconds since Unix epoch.
*/
exp: number;
/**
* Name of the instance which authored the JWT
*/
iss: string;
/**
* Time at which the JWT can start to be used.
* Note: This value is in seconds since Unix epoch.
*/
nbf: number;
/**
* Subject. The Livekit alias in this context.
*/
sub: string;
/**
* The set of permissions for the user.
*/
video: {
canPublish: boolean;
canSubscribe: boolean;
room: string;
roomJoin: boolean;
};
}
// The bits we need from MatrixClient
export type OpenIDClientParts = Pick<MatrixClient, "getOpenIdToken">;
/**
* Which token service endpoint to use: the MSC4195 one, or the legacy one
* with the old identity format. Undefined tries the MSC4195 one and falls
* back to the legacy one, for transports we only subscribe on.
*/
export type TokenEndpoint = "msc4195" | "legacy";
/**
* Gets a bearer token from the homeserver and then use it to authenticate
* to the matrix RTC backend in order to get acces to the SFU.
* It has built-in retry for calls to the homeserver with a backoff policy.
* @param client The Matrix client
* @param membership Our own membership identity parts used to send to jwt service.
* @param serviceUrl The URL of the livekit SFU service
* @param roomId The room id used in the jwt request. This is NOT the livekit_alias. The jwt service will provide the alias. It maps matrix room ids <-> Livekit aliases.
* @param opts Additional options to modify which endpoint with which data will be used to acquire the jwt token.
* @param opts.tokenEndpoint Which version of the JWT endpoint to use, which affects whether the
* RTC backend identity is based on string concatenation (legacy) or a hash (MSC4195).
* This function by default uses whatever is possible with the current jwt service installed next to the SFU.
* For remote connections this does not matter, since we will not publish there we can rely on the newest option.
* @param opts.delayEndpointBaseUrl The URL of the matrix homeserver.
* @param opts.delayId The delay id used for the jwt service to manage.
* @param logger optional logger.
* @returns Object containing the token information
* @throws FailToGetOpenIdToken
*/
export async function getSFUConfigWithOpenID(
client: OpenIDClientParts,
membership: CallMembershipIdentityParts,
serviceUrl: string,
roomId: string,
opts?: {
tokenEndpoint?: TokenEndpoint;
delayEndpointBaseUrl?: string;
delayId?: string;
/** How long the SFU waits before sending the delegated leave. Needed with `delayId`. */
delayTimeoutMs?: number;
},
logger?: Logger,
): Promise<SFUConfig> {
let openIdToken: IOpenIDToken;
try {
openIdToken = await doNetworkOperationWithRetry(async () =>
client.getOpenIdToken(),
);
} catch (error) {
throw new FailToGetOpenIdToken(
error instanceof Error ? error : new Error("Unknown error"),
);
}
logger?.debug("Got openID token", openIdToken);
let sfuConfig: { url: string; jwt: string } | undefined;
const tryBothJwtEndpoints = opts?.tokenEndpoint === undefined; // This is for SFUs where we do not publish.
const forceMatrix2Jwt = opts?.tokenEndpoint === "msc4195";
// We want to start using the new endpoint (with optional delay delegation)
// if we can use both or if we are forced to use the new one.
if (tryBothJwtEndpoints || forceMatrix2Jwt) {
try {
logger?.info(
`Trying to get JWT via default endpoint for focus ${serviceUrl}...`,
);
const sfuConfig = await getLiveKitJWT(
membership,
serviceUrl,
roomId,
openIdToken,
opts?.delayEndpointBaseUrl,
opts?.delayId,
opts?.delayTimeoutMs,
);
return extractFullConfigFromToken(sfuConfig);
} catch (e) {
logger?.debug(`Failed fetching jwt with matrix 2.0 endpoint:`, e);
// Make this throw a hard error in case we force the matrix2.0 endpoint.
if (forceMatrix2Jwt) {
throw new NoMatrix2AuthorizationService(e as Error);
}
}
}
// DEPRECATED
// here we either have a sfuConfig or we already exited because of `if (forceMatrix2) throw ...`
// The only case we can get into this condition is, if `forceMatrix2` is `false`
try {
logger?.info(
`Trying to get JWT with legacy endpoint for focus ${serviceUrl}...`,
);
sfuConfig = await getLiveKitJWTLegacy(
membership.deviceId,
serviceUrl,
roomId,
openIdToken,
opts?.delayEndpointBaseUrl,
opts?.delayId,
opts?.delayTimeoutMs,
);
logger?.info(`Got JWT from call's active focus URL.`);
return extractFullConfigFromToken(sfuConfig);
} catch (ex) {
throw new FailToGetOpenIdToken(
ex instanceof Error ? ex : new Error(`Unknown error ${ex}`),
);
}
}
function extractFullConfigFromToken(sfuConfig: {
url: string;
jwt: string;
}): SFUConfig {
const [, payloadStr] = sfuConfig.jwt.split(".");
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
return {
jwt: sfuConfig.jwt,
url: sfuConfig.url,
livekitAlias: payload.video.room,
// NOTE: Currently unused.
// Probably also not helpful since we now compute the backendIdentity on joining the call so we can use it for the encryption manager.
// The only reason for us to know it locally is to connect the right users with the lk world. (and to set our own keys)
livekitIdentity: payload.sub,
};
}
async function getLiveKitJWTLegacy(
deviceId: string,
livekitServiceURL: string,
matrixRoomId: string,
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: string,
delayTimeoutMs?: number,
): Promise<{ url: string; jwt: string }> {
interface IDelayParams {
delay_id?: string;
delay_timeout?: number;
delay_cs_api_url?: string;
}
let bodyDalayParts: IDelayParams = {};
// Also check for empty string
if (delayId && delayEndpointBaseUrl) {
bodyDalayParts = {
delay_id: delayId,
delay_timeout: delayTimeoutMs,
delay_cs_api_url: delayEndpointBaseUrl,
};
}
const makeRequest = async (delayParts: IDelayParams): Promise<Response> => {
return await fetch(livekitServiceURL + "/sfu/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
// The legacy JWT endpoint uses only the matrix room id to calculate the livekit room alias.
// However, the livekit room alias is provided as part of the JWT payload.
room: matrixRoomId,
openid_token: openIDToken,
device_id: deviceId,
...delayParts,
}),
});
};
const res = await doNetworkOperationWithRetry(async () => {
let response = await makeRequest(bodyDalayParts);
// Old service compatibility check
const oldServiceDoesNotSupportDelayParts =
response.status === 400 && Object.keys(bodyDalayParts).length > 0;
// If http status 400 with M_BAD_JSON and we sent delay parts, retry without them
if (oldServiceDoesNotSupportDelayParts) {
try {
const errorBody = await response.json();
if (errorBody.errcode === "M_BAD_JSON") {
response = await makeRequest({});
}
} catch {
// If we can't parse the error, treat as real error
}
}
return response;
});
if (!res.ok) {
throw parseErrorResponse(res, await res.text());
}
return await res.json();
}
class NotSupportedError extends Error {
public constructor(message: string) {
super(message);
this.name = "NotSupported";
}
}
export async function getLiveKitJWT(
membership: CallMembershipIdentityParts,
livekitServiceURL: string,
matrixRoomId: string,
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: string,
delayTimeoutMs?: number,
): Promise<{ url: string; jwt: string }> {
const { userId, deviceId, memberId } = membership;
const body = {
room_id: matrixRoomId,
slot_id: "m.call#ROOM",
openid_token: openIDToken,
member: {
id: memberId,
claimed_user_id: userId,
claimed_device_id: deviceId,
},
};
let bodyDalayParts = {};
// Also check for empty string
if (delayId && delayEndpointBaseUrl) {
bodyDalayParts = {
delay_id: delayId,
delay_timeout: delayTimeoutMs,
delay_cs_api_url: delayEndpointBaseUrl,
};
}
const res = await doNetworkOperationWithRetry(async () => {
return await fetch(livekitServiceURL + "/get_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ...body, ...bodyDalayParts }),
});
});
if (!res.ok) {
const msg = "SFU Config fetch failed with status code " + res.status;
if (res.status === 404) {
throw new NotSupportedError(msg);
} else {
throw parseErrorResponse(res, await res.text());
}
}
return await res.json();
}
+5 -3
View File
@@ -11,13 +11,15 @@ import {
type RemoteParticipant, type RemoteParticipant,
type RemoteTrackPublication, type RemoteTrackPublication,
} from "livekit-client"; } from "livekit-client";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { mockEmitter } from "../../utils/test"; import { mockEmitter } from "../../utils/test";
export const exampleTransport: LivekitTransport = { export const exampleServiceUrl = "https://lk.example.org";
export const exampleTransport: UnstableLivekitTransport = {
type: "livekit", type: "livekit",
livekit_service_url: "https://lk.example.org", livekit_service_url: exampleServiceUrl,
}; };
export function mockRemoteParticipant( export function mockRemoteParticipant(
+7 -1
View File
@@ -12,9 +12,13 @@ import { sleep } from "matrix-js-sdk/lib/utils";
* Performs a network operation with retries on connection errors, honouring * Performs a network operation with retries on connection errors, honouring
* the homeserver's rate limits. Rethrows once the error is not retryable or * the homeserver's rate limits. Rethrows once the error is not retryable or
* the retries are used up. * the retries are used up.
* @param operation The operation to perform.
* @param shouldRetry Rules out errors that retrying cannot fix, on top of the
* standard backoff rule. Retries everything by default.
*/ */
export async function doNetworkOperationWithRetry<T>( export async function doNetworkOperationWithRetry<T>(
operation: () => Promise<T>, operation: () => Promise<T>,
shouldRetry: (e: unknown) => boolean = () => true,
): Promise<T> { ): Promise<T> {
let currentRetryCount = 0; let currentRetryCount = 0;
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
@@ -23,7 +27,9 @@ export async function doNetworkOperationWithRetry<T>(
return await operation(); return await operation();
} catch (e) { } catch (e) {
currentRetryCount++; currentRetryCount++;
const backoff = calculateRetryBackoff(e, currentRetryCount, true); const backoff = shouldRetry(e)
? calculateRetryBackoff(e, currentRetryCount, true)
: -1;
if (backoff < 0) throw e; if (backoff < 0) throw e;
await sleep(backoff); await sleep(backoff);
} }
+11
View File
@@ -0,0 +1,11 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/** The homeserver a Matrix user id belongs to: everything after the first colon. */
export function serverNameOf(userId: string): string {
return userId.replace(/^.*?:/, "");
}
+11 -3
View File
@@ -48,16 +48,24 @@ export const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
rawDisplayName: "\u202eevaD", rawDisplayName: "\u202eevaD",
}); });
export const testJWTToken = [ /** An unsigned JWT with the given subject and LiveKit room, as a token service would issue it. */
export function createJWTToken(sub: string, room: string): string {
return [
{}, // header {}, // header
{ {
// payload // payload
sub: "@me:example.org:ABCDEF", sub,
video: { video: {
room: "!example_room_id", room,
}, },
}, },
{}, // signature {}, // signature
] ]
.map((d) => global.btoa(JSON.stringify(d))) .map((d) => global.btoa(JSON.stringify(d)))
.join("."); .join(".");
}
export const testJWTToken = createJWTToken(
"@me:example.org:ABCDEF",
"!example_room_id",
);