mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
[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:
+4
-4
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
),
|
),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
+190
-59
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(/^.*?:/, "");
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user