Merge pull request #3734 from element-hq/robin/local-transport
Logically separate the advertised transport from the active transport
This commit is contained in:
@@ -60,6 +60,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
accumulate,
|
accumulate,
|
||||||
filterBehavior,
|
filterBehavior,
|
||||||
|
generateItem,
|
||||||
generateItems,
|
generateItems,
|
||||||
pauseWhen,
|
pauseWhen,
|
||||||
} from "../../utils/observable";
|
} from "../../utils/observable";
|
||||||
@@ -444,35 +445,38 @@ export function createCallViewModel$(
|
|||||||
memberId: uuidv4(),
|
memberId: uuidv4(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const localTransport$ = createLocalTransport$({
|
const localTransport$ = scope.behavior(
|
||||||
scope: scope,
|
matrixRTCMode$.pipe(
|
||||||
memberships$: memberships$,
|
generateItem(
|
||||||
ownMembershipIdentity,
|
"CallViewModel localTransport$",
|
||||||
client,
|
// Re-create LocalTransport whenever the mode changes
|
||||||
delayId$: scope.behavior(
|
(mode) => ({ keys: [mode], data: undefined }),
|
||||||
(
|
(scope, _data$, mode) =>
|
||||||
fromEvent(
|
createLocalTransport$({
|
||||||
matrixRTCSession,
|
scope: scope,
|
||||||
MembershipManagerEvent.DelayIdChanged,
|
memberships$: memberships$,
|
||||||
// The type of reemitted event includes the original emitted as the second arg.
|
ownMembershipIdentity,
|
||||||
) as Observable<[string | undefined, IMembershipManager]>
|
client,
|
||||||
).pipe(map(([delayId]) => delayId ?? null)),
|
delayId$: scope.behavior(
|
||||||
matrixRTCSession.delayId ?? null,
|
(
|
||||||
),
|
fromEvent(
|
||||||
roomId: matrixRoom.roomId,
|
matrixRTCSession,
|
||||||
forceJwtEndpoint$: scope.behavior(
|
MembershipManagerEvent.DelayIdChanged,
|
||||||
matrixRTCMode$.pipe(
|
// The type of reemitted event includes the original emitted as the second arg.
|
||||||
map((v) =>
|
) as Observable<[string | undefined, IMembershipManager]>
|
||||||
v === MatrixRTCMode.Matrix_2_0
|
).pipe(map(([delayId]) => delayId ?? null)),
|
||||||
? JwtEndpointVersion.Matrix_2_0
|
matrixRTCSession.delayId ?? null,
|
||||||
: JwtEndpointVersion.Legacy,
|
),
|
||||||
),
|
roomId: matrixRoom.roomId,
|
||||||
|
forceJwtEndpoint:
|
||||||
|
mode === MatrixRTCMode.Matrix_2_0
|
||||||
|
? JwtEndpointVersion.Matrix_2_0
|
||||||
|
: JwtEndpointVersion.Legacy,
|
||||||
|
useOldestMember: mode === MatrixRTCMode.Legacy,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
useOldestMember$: scope.behavior(
|
);
|
||||||
matrixRTCMode$.pipe(map((v) => v === MatrixRTCMode.Legacy)),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const connectionFactory = new ECConnectionFactory(
|
const connectionFactory = new ECConnectionFactory(
|
||||||
client,
|
client,
|
||||||
@@ -491,6 +495,7 @@ export function createCallViewModel$(
|
|||||||
connectionFactory: connectionFactory,
|
connectionFactory: connectionFactory,
|
||||||
localTransport$: scope.behavior(
|
localTransport$: scope.behavior(
|
||||||
localTransport$.pipe(
|
localTransport$.pipe(
|
||||||
|
switchMap((t) => t.active$),
|
||||||
catchError((e: unknown) => {
|
catchError((e: unknown) => {
|
||||||
logger.info(
|
logger.info(
|
||||||
"could not pass local transport to createConnectionManager$. localTransport$ threw an error",
|
"could not pass local transport to createConnectionManager$. localTransport$ threw an error",
|
||||||
@@ -524,13 +529,13 @@ export function createCallViewModel$(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const localMembership = createLocalMembership$({
|
const localMembership = createLocalMembership$({
|
||||||
scope: scope,
|
scope,
|
||||||
homeserverConnected: createHomeserverConnected$(
|
homeserverConnected: createHomeserverConnected$(
|
||||||
scope,
|
scope,
|
||||||
client,
|
client,
|
||||||
matrixRTCSession,
|
matrixRTCSession,
|
||||||
),
|
),
|
||||||
muteStates: muteStates,
|
muteStates,
|
||||||
joinMatrixRTC: (transport: LivekitTransportConfig) => {
|
joinMatrixRTC: (transport: LivekitTransportConfig) => {
|
||||||
return enterRTCSession(
|
return enterRTCSession(
|
||||||
matrixRTCSession,
|
matrixRTCSession,
|
||||||
@@ -550,9 +555,11 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
connectionManager: connectionManager,
|
connectionManager,
|
||||||
matrixRTCSession: matrixRTCSession,
|
matrixRTCSession,
|
||||||
localTransport$: localTransport$,
|
localTransport$: scope.behavior(
|
||||||
|
localTransport$.pipe(switchMap((t) => t.advertised$)),
|
||||||
|
),
|
||||||
logger: logger.getChild(`[${Date.now()}]`),
|
logger: logger.getChild(`[${Date.now()}]`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import { constant } from "../../Behavior";
|
|||||||
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
|
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
|
||||||
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
|
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
|
||||||
import { type Publisher } from "./Publisher";
|
import { type Publisher } from "./Publisher";
|
||||||
import { type LocalTransportWithSFUConfig } from "./LocalTransport";
|
|
||||||
import { initializeWidget } from "../../../widget";
|
import { initializeWidget } from "../../../widget";
|
||||||
|
|
||||||
initializeWidget();
|
initializeWidget();
|
||||||
@@ -216,11 +215,10 @@ describe("LocalMembership", () => {
|
|||||||
|
|
||||||
it("throws error on missing RTC config error", () => {
|
it("throws error on missing RTC config error", () => {
|
||||||
withTestScheduler(({ scope, hot, expectObservable }) => {
|
withTestScheduler(({ scope, hot, expectObservable }) => {
|
||||||
const localTransport$ =
|
const localTransport$ = scope.behavior<null | LivekitTransportConfig>(
|
||||||
scope.behavior<null | LocalTransportWithSFUConfig>(
|
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
|
||||||
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
|
null,
|
||||||
null,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// we do not need any connection data since we want to fail before reaching that.
|
// we do not need any connection data since we want to fail before reaching that.
|
||||||
const mockConnectionManager = {
|
const mockConnectionManager = {
|
||||||
@@ -279,23 +277,11 @@ describe("LocalMembership", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const aTransport = {
|
const aTransport = {
|
||||||
transport: {
|
livekit_service_url: "a",
|
||||||
livekit_service_url: "a",
|
} as LivekitTransportConfig;
|
||||||
} as LivekitTransportConfig,
|
|
||||||
sfuConfig: {
|
|
||||||
url: "sfu-url",
|
|
||||||
jwt: "sfu-token",
|
|
||||||
},
|
|
||||||
} as LocalTransportWithSFUConfig;
|
|
||||||
const bTransport = {
|
const bTransport = {
|
||||||
transport: {
|
livekit_service_url: "b",
|
||||||
livekit_service_url: "b",
|
} as LivekitTransportConfig;
|
||||||
} as LivekitTransportConfig,
|
|
||||||
sfuConfig: {
|
|
||||||
url: "sfu-url",
|
|
||||||
jwt: "sfu-token",
|
|
||||||
},
|
|
||||||
} as LocalTransportWithSFUConfig;
|
|
||||||
|
|
||||||
const connectionTransportAConnected = {
|
const connectionTransportAConnected = {
|
||||||
livekitRoom: mockLivekitRoom({
|
livekitRoom: mockLivekitRoom({
|
||||||
@@ -305,7 +291,7 @@ describe("LocalMembership", () => {
|
|||||||
} as unknown as LocalParticipant,
|
} as unknown as LocalParticipant,
|
||||||
}),
|
}),
|
||||||
state$: constant(ConnectionState.LivekitConnected),
|
state$: constant(ConnectionState.LivekitConnected),
|
||||||
transport: aTransport.transport,
|
transport: aTransport,
|
||||||
} as unknown as Connection;
|
} as unknown as Connection;
|
||||||
const connectionTransportAConnecting = {
|
const connectionTransportAConnecting = {
|
||||||
...connectionTransportAConnected,
|
...connectionTransportAConnected,
|
||||||
@@ -314,7 +300,7 @@ describe("LocalMembership", () => {
|
|||||||
} as unknown as Connection;
|
} as unknown as Connection;
|
||||||
const connectionTransportBConnected = {
|
const connectionTransportBConnected = {
|
||||||
state$: constant(ConnectionState.LivekitConnected),
|
state$: constant(ConnectionState.LivekitConnected),
|
||||||
transport: bTransport.transport,
|
transport: bTransport,
|
||||||
livekitRoom: mockLivekitRoom({}),
|
livekitRoom: mockLivekitRoom({}),
|
||||||
} as unknown as Connection;
|
} as unknown as Connection;
|
||||||
|
|
||||||
@@ -368,12 +354,8 @@ describe("LocalMembership", () => {
|
|||||||
// stop the first Publisher and let the second one life.
|
// stop the first Publisher and let the second one life.
|
||||||
expect(publishers[0].destroy).toHaveBeenCalled();
|
expect(publishers[0].destroy).toHaveBeenCalled();
|
||||||
expect(publishers[1].destroy).not.toHaveBeenCalled();
|
expect(publishers[1].destroy).not.toHaveBeenCalled();
|
||||||
expect(publisherFactory.mock.calls[0][0].transport).toBe(
|
expect(publisherFactory.mock.calls[0][0].transport).toBe(aTransport);
|
||||||
aTransport.transport,
|
expect(publisherFactory.mock.calls[1][0].transport).toBe(bTransport);
|
||||||
);
|
|
||||||
expect(publisherFactory.mock.calls[1][0].transport).toBe(
|
|
||||||
bTransport.transport,
|
|
||||||
);
|
|
||||||
scope.end();
|
scope.end();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
// stop all tracks after ending scopes
|
// stop all tracks after ending scopes
|
||||||
@@ -446,8 +428,9 @@ describe("LocalMembership", () => {
|
|||||||
const scope = new ObservableScope();
|
const scope = new ObservableScope();
|
||||||
|
|
||||||
const connectionManagerData = new ConnectionManagerData();
|
const connectionManagerData = new ConnectionManagerData();
|
||||||
const localTransport$ =
|
const localTransport$ = new BehaviorSubject<null | LivekitTransportConfig>(
|
||||||
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
|
null,
|
||||||
|
);
|
||||||
const connectionManagerData$ = new BehaviorSubject(
|
const connectionManagerData$ = new BehaviorSubject(
|
||||||
new Epoch(connectionManagerData),
|
new Epoch(connectionManagerData),
|
||||||
);
|
);
|
||||||
@@ -519,7 +502,7 @@ describe("LocalMembership", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
(
|
(
|
||||||
connectionManagerData2.getConnectionForTransport(aTransport.transport)!
|
connectionManagerData2.getConnectionForTransport(aTransport)!
|
||||||
.state$ as BehaviorSubject<ConnectionState>
|
.state$ as BehaviorSubject<ConnectionState>
|
||||||
).next(ConnectionState.LivekitConnected);
|
).next(ConnectionState.LivekitConnected);
|
||||||
expect(localMembership.localMemberState$.value).toStrictEqual({
|
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ import {
|
|||||||
} from "../remoteMembers/Connection.ts";
|
} from "../remoteMembers/Connection.ts";
|
||||||
import { type HomeserverConnected } from "./HomeserverConnected.ts";
|
import { type HomeserverConnected } from "./HomeserverConnected.ts";
|
||||||
import { and$ } from "../../../utils/observable.ts";
|
import { and$ } from "../../../utils/observable.ts";
|
||||||
import { type LocalTransportWithSFUConfig } from "./LocalTransport.ts";
|
|
||||||
|
|
||||||
export enum TransportState {
|
export enum TransportState {
|
||||||
/** Not even a transport is available to the LocalMembership */
|
/** Not even a transport is available to the LocalMembership */
|
||||||
@@ -128,7 +127,7 @@ interface Props {
|
|||||||
createPublisherFactory: (connection: Connection) => Publisher;
|
createPublisherFactory: (connection: Connection) => Publisher;
|
||||||
joinMatrixRTC: (transport: LivekitTransportConfig) => void;
|
joinMatrixRTC: (transport: LivekitTransportConfig) => void;
|
||||||
homeserverConnected: HomeserverConnected;
|
homeserverConnected: HomeserverConnected;
|
||||||
localTransport$: Behavior<LocalTransportWithSFUConfig | null>;
|
localTransport$: Behavior<LivekitTransportConfig | null>;
|
||||||
matrixRTCSession: Pick<
|
matrixRTCSession: Pick<
|
||||||
MatrixRTCSession,
|
MatrixRTCSession,
|
||||||
"updateCallIntent" | "leaveRoomSession"
|
"updateCallIntent" | "leaveRoomSession"
|
||||||
@@ -147,7 +146,7 @@ interface Props {
|
|||||||
* @param props.createPublisherFactory Factory to create a publisher once we have a connection.
|
* @param props.createPublisherFactory Factory to create a publisher once we have a connection.
|
||||||
* @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport.
|
* @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport.
|
||||||
* @param props.homeserverConnected The homeserver connected state.
|
* @param props.homeserverConnected The homeserver connected state.
|
||||||
* @param props.localTransport$ The local transport to use for publishing.
|
* @param props.localTransport$ The transport to advertise in our membership.
|
||||||
* @param props.logger The logger to use.
|
* @param props.logger The logger to use.
|
||||||
* @param props.muteStates The mute states for video and audio.
|
* @param props.muteStates The mute states for video and audio.
|
||||||
* @param props.matrixRTCSession The matrix RTC session to join.
|
* @param props.matrixRTCSession The matrix RTC session to join.
|
||||||
@@ -237,9 +236,7 @@ export const createLocalMembership$ = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return connectionData.getConnectionForTransport(
|
return connectionData.getConnectionForTransport(localTransport);
|
||||||
localTransport.transport,
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
tap((connection) => {
|
tap((connection) => {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -549,7 +546,7 @@ export const createLocalMembership$ = ({
|
|||||||
if (!shouldConnect) return;
|
if (!shouldConnect) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
joinMatrixRTC(transport.transport);
|
joinMatrixRTC(transport);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error entering RTC session", error);
|
logger.error("Error entering RTC session", error);
|
||||||
if (error instanceof Error)
|
if (error instanceof Error)
|
||||||
|
|||||||
@@ -13,15 +13,24 @@ import {
|
|||||||
it,
|
it,
|
||||||
type MockedObject,
|
type MockedObject,
|
||||||
vi,
|
vi,
|
||||||
|
type MockInstance,
|
||||||
} from "vitest";
|
} from "vitest";
|
||||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
import {
|
||||||
|
type CallMembership,
|
||||||
|
type LivekitTransportConfig,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { BehaviorSubject, lastValueFrom } from "rxjs";
|
import { BehaviorSubject, lastValueFrom } from "rxjs";
|
||||||
import fetchMock from "fetch-mock";
|
import fetchMock from "fetch-mock";
|
||||||
|
|
||||||
import { mockConfig, flushPromises, ownMemberMock } from "../../../utils/test";
|
import {
|
||||||
|
mockConfig,
|
||||||
|
flushPromises,
|
||||||
|
ownMemberMock,
|
||||||
|
mockRtcMembership,
|
||||||
|
} from "../../../utils/test";
|
||||||
import { createLocalTransport$, JwtEndpointVersion } from "./LocalTransport";
|
import { createLocalTransport$, JwtEndpointVersion } from "./LocalTransport";
|
||||||
import { constant } from "../../Behavior";
|
import { constant } from "../../Behavior";
|
||||||
import { Epoch, ObservableScope } from "../../ObservableScope";
|
import { Epoch, ObservableScope, trackEpoch } from "../../ObservableScope";
|
||||||
import {
|
import {
|
||||||
MatrixRTCTransportMissingError,
|
MatrixRTCTransportMissingError,
|
||||||
FailToGetOpenIdToken,
|
FailToGetOpenIdToken,
|
||||||
@@ -43,10 +52,10 @@ describe("LocalTransport", () => {
|
|||||||
afterEach(() => scope.end());
|
afterEach(() => scope.end());
|
||||||
|
|
||||||
it("throws if config is missing", async () => {
|
it("throws if config is missing", async () => {
|
||||||
const localTransport$ = createLocalTransport$({
|
const { advertised$, active$ } = createLocalTransport$({
|
||||||
scope,
|
scope,
|
||||||
roomId: "!room:example.org",
|
roomId: "!room:example.org",
|
||||||
useOldestMember$: constant(false),
|
useOldestMember: false,
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
client: {
|
client: {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
@@ -58,14 +67,15 @@ describe("LocalTransport", () => {
|
|||||||
getDeviceId: vi.fn(),
|
getDeviceId: vi.fn(),
|
||||||
},
|
},
|
||||||
ownMembershipIdentity: ownMemberMock,
|
ownMembershipIdentity: ownMemberMock,
|
||||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||||
delayId$: constant("delay_id_mock"),
|
delayId$: constant("delay_id_mock"),
|
||||||
});
|
});
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(() => localTransport$.value).toThrow(
|
expect(() => advertised$.value).toThrow(
|
||||||
new MatrixRTCTransportMissingError(""),
|
new MatrixRTCTransportMissingError(""),
|
||||||
);
|
);
|
||||||
|
expect(() => active$.value).toThrow(new MatrixRTCTransportMissingError(""));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
|
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
|
||||||
@@ -83,10 +93,10 @@ describe("LocalTransport", () => {
|
|||||||
);
|
);
|
||||||
const observations: unknown[] = [];
|
const observations: unknown[] = [];
|
||||||
const errors: Error[] = [];
|
const errors: Error[] = [];
|
||||||
const localTransport$ = createLocalTransport$({
|
const { advertised$, active$ } = createLocalTransport$({
|
||||||
scope,
|
scope,
|
||||||
roomId: "!example_room_id",
|
roomId: "!example_room_id",
|
||||||
useOldestMember$: constant(false),
|
useOldestMember: false,
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
client: {
|
client: {
|
||||||
baseUrl: "https://lk.example.org",
|
baseUrl: "https://lk.example.org",
|
||||||
@@ -98,10 +108,10 @@ describe("LocalTransport", () => {
|
|||||||
getDeviceId: vi.fn(),
|
getDeviceId: vi.fn(),
|
||||||
},
|
},
|
||||||
ownMembershipIdentity: ownMemberMock,
|
ownMembershipIdentity: ownMemberMock,
|
||||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||||
delayId$: constant("delay_id_mock"),
|
delayId$: constant("delay_id_mock"),
|
||||||
});
|
});
|
||||||
localTransport$.subscribe(
|
active$.subscribe(
|
||||||
(o) => observations.push(o),
|
(o) => observations.push(o),
|
||||||
(e) => errors.push(e),
|
(e) => errors.push(e),
|
||||||
);
|
);
|
||||||
@@ -111,7 +121,8 @@ describe("LocalTransport", () => {
|
|||||||
const expectedError = new FailToGetOpenIdToken(new Error("no openid"));
|
const expectedError = new FailToGetOpenIdToken(new Error("no openid"));
|
||||||
expect(observations).toStrictEqual([null]);
|
expect(observations).toStrictEqual([null]);
|
||||||
expect(errors).toStrictEqual([expectedError]);
|
expect(errors).toStrictEqual([expectedError]);
|
||||||
expect(() => localTransport$.value).toThrow(expectedError);
|
expect(() => advertised$.value).toThrow(expectedError);
|
||||||
|
expect(() => active$.value).toThrow(expectedError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits preferred transport after OpenID resolves", async () => {
|
it("emits preferred transport after OpenID resolves", async () => {
|
||||||
@@ -126,10 +137,10 @@ describe("LocalTransport", () => {
|
|||||||
openIdResolver.promise,
|
openIdResolver.promise,
|
||||||
);
|
);
|
||||||
|
|
||||||
const localTransport$ = createLocalTransport$({
|
const { advertised$, active$ } = createLocalTransport$({
|
||||||
scope,
|
scope,
|
||||||
roomId: "!room:example.org",
|
roomId: "!room:example.org",
|
||||||
useOldestMember$: constant(false),
|
useOldestMember: false,
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
client: {
|
client: {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
@@ -140,7 +151,7 @@ describe("LocalTransport", () => {
|
|||||||
baseUrl: "https://lk.example.org",
|
baseUrl: "https://lk.example.org",
|
||||||
},
|
},
|
||||||
ownMembershipIdentity: ownMemberMock,
|
ownMembershipIdentity: ownMemberMock,
|
||||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||||
delayId$: constant("delay_id_mock"),
|
delayId$: constant("delay_id_mock"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,14 +161,17 @@ describe("LocalTransport", () => {
|
|||||||
livekitAlias: "Akph4alDMhen",
|
livekitAlias: "Akph4alDMhen",
|
||||||
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
|
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
|
||||||
});
|
});
|
||||||
expect(localTransport$.value).toBe(null);
|
expect(advertised$.value).toBe(null);
|
||||||
|
expect(active$.value).toBe(null);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
// final
|
// final
|
||||||
expect(localTransport$.value).toStrictEqual({
|
const expectedTransport = {
|
||||||
transport: {
|
livekit_service_url: "https://lk.example.org",
|
||||||
livekit_service_url: "https://lk.example.org",
|
type: "livekit",
|
||||||
type: "livekit",
|
};
|
||||||
},
|
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||||
|
expect(active$.value).toStrictEqual({
|
||||||
|
transport: expectedTransport,
|
||||||
sfuConfig: {
|
sfuConfig: {
|
||||||
jwt: "jwt",
|
jwt: "jwt",
|
||||||
livekitAlias: "Akph4alDMhen",
|
livekitAlias: "Akph4alDMhen",
|
||||||
@@ -167,51 +181,122 @@ describe("LocalTransport", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates local transport when oldest member changes", async () => {
|
describe("oldest member mode", () => {
|
||||||
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
|
const aliceTransport: LivekitTransportConfig = {
|
||||||
mockConfig({
|
type: "livekit",
|
||||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
livekit_service_url: "https://alice.example.org",
|
||||||
|
};
|
||||||
|
const bobTransport: LivekitTransportConfig = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "https://bob.example.org",
|
||||||
|
};
|
||||||
|
const aliceMembership = mockRtcMembership("@alice:example.org", "AAA", {
|
||||||
|
fociPreferred: [aliceTransport],
|
||||||
});
|
});
|
||||||
const memberships$ = new BehaviorSubject(new Epoch([]));
|
const bobMembership = mockRtcMembership("@bob:example.org", "BBB", {
|
||||||
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
|
fociPreferred: [bobTransport],
|
||||||
|
|
||||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
|
|
||||||
openIdResolver.promise,
|
|
||||||
);
|
|
||||||
|
|
||||||
const localTransport$ = createLocalTransport$({
|
|
||||||
scope,
|
|
||||||
roomId: "!example_room_id",
|
|
||||||
useOldestMember$: constant(true),
|
|
||||||
memberships$,
|
|
||||||
client: {
|
|
||||||
getDomain: () => "",
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
|
||||||
getOpenIdToken: vi.fn(),
|
|
||||||
getDeviceId: vi.fn(),
|
|
||||||
baseUrl: "https://lk.example.org",
|
|
||||||
},
|
|
||||||
ownMembershipIdentity: ownMemberMock,
|
|
||||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
|
||||||
delayId$: constant("delay_id_mock"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
openIdResolver.resolve?.(openIdResponse);
|
let openIdSpy: MockInstance<(typeof openIDSFU)["getSFUConfigWithOpenID"]>;
|
||||||
expect(localTransport$.value).toBe(null);
|
beforeEach(() => {
|
||||||
await flushPromises();
|
openIdSpy = vi
|
||||||
// final
|
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
|
||||||
expect(localTransport$.value).toStrictEqual({
|
.mockResolvedValue(openIdResponse);
|
||||||
transport: {
|
});
|
||||||
livekit_service_url: "https://lk.example.org",
|
|
||||||
type: "livekit",
|
it("updates active transport when oldest member changes", async () => {
|
||||||
},
|
// Initially, Alice is the only member
|
||||||
sfuConfig: {
|
const memberships$ = new BehaviorSubject([aliceMembership]);
|
||||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
|
||||||
livekitAlias: "Akph4alDMhen",
|
const { advertised$, active$ } = createLocalTransport$({
|
||||||
livekitIdentity: "@lk_user:ABCDEF",
|
scope,
|
||||||
url: "https://lk.example.org",
|
roomId: "!example_room_id",
|
||||||
},
|
useOldestMember: true,
|
||||||
|
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
|
||||||
|
client: {
|
||||||
|
getDomain: () => "",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||||
|
getOpenIdToken: vi.fn(),
|
||||||
|
getDeviceId: vi.fn(),
|
||||||
|
baseUrl: "https://lk.example.org",
|
||||||
|
},
|
||||||
|
ownMembershipIdentity: ownMemberMock,
|
||||||
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||||
|
delayId$: constant("delay_id_mock"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(active$.value).toBe(null);
|
||||||
|
await flushPromises();
|
||||||
|
// SFU config should've been fetched
|
||||||
|
expect(openIdSpy).toHaveBeenCalled();
|
||||||
|
// Alice's transport should be active and advertised
|
||||||
|
expect(active$.value?.transport).toStrictEqual(aliceTransport);
|
||||||
|
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||||
|
|
||||||
|
// Now Bob joins the call, but Alice is still the oldest member
|
||||||
|
openIdSpy.mockClear();
|
||||||
|
memberships$.next([aliceMembership, bobMembership]);
|
||||||
|
await flushPromises();
|
||||||
|
// No new SFU config should've been fetched
|
||||||
|
expect(openIdSpy).not.toHaveBeenCalled();
|
||||||
|
// Alice's transport should still be active and advertised
|
||||||
|
expect(active$.value?.transport).toStrictEqual(aliceTransport);
|
||||||
|
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||||
|
|
||||||
|
// Now Bob takes Alice's place as the oldest member
|
||||||
|
openIdSpy.mockClear();
|
||||||
|
memberships$.next([bobMembership, aliceMembership]);
|
||||||
|
// Active transport should reset to null until we have Bob's SFU config
|
||||||
|
expect(active$.value).toStrictEqual(null);
|
||||||
|
await flushPromises();
|
||||||
|
// Bob's SFU config should've been fetched
|
||||||
|
expect(openIdSpy).toHaveBeenCalled();
|
||||||
|
// Bob's transport should be active, but Alice's should remain advertised
|
||||||
|
// (since we don't want the change in oldest member to cause a wave of new
|
||||||
|
// state events)
|
||||||
|
expect(active$.value?.transport).toStrictEqual(bobTransport);
|
||||||
|
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advertises preferred transport when no other member exists", async () => {
|
||||||
|
// Initially, there are no members
|
||||||
|
const memberships$ = new BehaviorSubject<CallMembership[]>([]);
|
||||||
|
|
||||||
|
const { advertised$, active$ } = createLocalTransport$({
|
||||||
|
scope,
|
||||||
|
roomId: "!example_room_id",
|
||||||
|
useOldestMember: true,
|
||||||
|
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
|
||||||
|
client: {
|
||||||
|
getDomain: () => "",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
_unstable_getRTCTransports: async () =>
|
||||||
|
Promise.resolve([aliceTransport]),
|
||||||
|
getOpenIdToken: vi.fn(),
|
||||||
|
getDeviceId: vi.fn(),
|
||||||
|
baseUrl: "https://lk.example.org",
|
||||||
|
},
|
||||||
|
ownMembershipIdentity: ownMemberMock,
|
||||||
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||||
|
delayId$: constant("delay_id_mock"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(active$.value).toBe(null);
|
||||||
|
await flushPromises();
|
||||||
|
// Our own preferred transport should be advertised
|
||||||
|
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||||
|
// No transport should be active however (there is still no oldest member)
|
||||||
|
expect(active$.value).toBe(null);
|
||||||
|
|
||||||
|
// Now Bob joins the call and becomes the oldest member
|
||||||
|
memberships$.next([bobMembership]);
|
||||||
|
await flushPromises();
|
||||||
|
// We should still advertise our own preferred transport (to avoid
|
||||||
|
// unnecessary state changes)
|
||||||
|
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||||
|
// Bob's transport should become active
|
||||||
|
expect(active$.value?.transport).toBe(bobTransport);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,8 +314,8 @@ describe("LocalTransport", () => {
|
|||||||
ownMembershipIdentity: ownMemberMock,
|
ownMembershipIdentity: ownMemberMock,
|
||||||
scope,
|
scope,
|
||||||
roomId: "!example_room_id",
|
roomId: "!example_room_id",
|
||||||
useOldestMember$: constant(false),
|
useOldestMember: false,
|
||||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||||
delayId$: constant(null),
|
delayId$: constant(null),
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
client: {
|
client: {
|
||||||
@@ -256,15 +341,19 @@ describe("LocalTransport", () => {
|
|||||||
mockConfig({
|
mockConfig({
|
||||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||||
});
|
});
|
||||||
const localTransport$ = createLocalTransport$(localTransportOpts);
|
const { advertised$, active$ } =
|
||||||
|
createLocalTransport$(localTransportOpts);
|
||||||
openIdResolver.resolve?.(openIdResponse);
|
openIdResolver.resolve?.(openIdResponse);
|
||||||
expect(localTransport$.value).toBe(null);
|
expect(advertised$.value).toBe(null);
|
||||||
|
expect(active$.value).toBe(null);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(localTransport$.value).toStrictEqual({
|
const expectedTransport = {
|
||||||
transport: {
|
livekit_service_url: "https://lk.example.org",
|
||||||
livekit_service_url: "https://lk.example.org",
|
type: "livekit",
|
||||||
type: "livekit",
|
};
|
||||||
},
|
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||||
|
expect(active$.value).toStrictEqual({
|
||||||
|
transport: expectedTransport,
|
||||||
sfuConfig: {
|
sfuConfig: {
|
||||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||||
livekitAlias: "Akph4alDMhen",
|
livekitAlias: "Akph4alDMhen",
|
||||||
@@ -273,13 +362,15 @@ describe("LocalTransport", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports getting transport via user settings", async () => {
|
it("supports getting transport via user settings", async () => {
|
||||||
customLivekitUrl.setValue("https://lk.example.org");
|
customLivekitUrl.setValue("https://lk.example.org");
|
||||||
const localTransport$ = createLocalTransport$(localTransportOpts);
|
const { advertised$, active$ } =
|
||||||
|
createLocalTransport$(localTransportOpts);
|
||||||
openIdResolver.resolve?.(openIdResponse);
|
openIdResolver.resolve?.(openIdResponse);
|
||||||
expect(localTransport$.value).toBe(null);
|
expect(advertised$.value).toBe(null);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(localTransport$.value).toStrictEqual({
|
expect(active$.value).toStrictEqual({
|
||||||
transport: {
|
transport: {
|
||||||
livekit_service_url: "https://lk.example.org",
|
livekit_service_url: "https://lk.example.org",
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
@@ -292,19 +383,24 @@ describe("LocalTransport", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports getting transport via backend", async () => {
|
it("supports getting transport via backend", async () => {
|
||||||
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
||||||
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||||
]);
|
]);
|
||||||
const localTransport$ = createLocalTransport$(localTransportOpts);
|
const { advertised$, active$ } =
|
||||||
|
createLocalTransport$(localTransportOpts);
|
||||||
openIdResolver.resolve?.(openIdResponse);
|
openIdResolver.resolve?.(openIdResponse);
|
||||||
expect(localTransport$.value).toBe(null);
|
expect(advertised$.value).toBe(null);
|
||||||
|
expect(active$.value).toBe(null);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(localTransport$.value).toStrictEqual({
|
const expectedTransport = {
|
||||||
transport: {
|
livekit_service_url: "https://lk.example.org",
|
||||||
livekit_service_url: "https://lk.example.org",
|
type: "livekit",
|
||||||
type: "livekit",
|
};
|
||||||
},
|
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||||
|
expect(active$.value).toStrictEqual({
|
||||||
|
transport: expectedTransport,
|
||||||
sfuConfig: {
|
sfuConfig: {
|
||||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||||
livekitAlias: "Akph4alDMhen",
|
livekitAlias: "Akph4alDMhen",
|
||||||
@@ -313,6 +409,7 @@ describe("LocalTransport", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails fast if the openID request fails for backend config", async () => {
|
it("fails fast if the openID request fails for backend config", async () => {
|
||||||
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
||||||
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||||
@@ -320,13 +417,11 @@ describe("LocalTransport", () => {
|
|||||||
openIdResolver.reject(
|
openIdResolver.reject(
|
||||||
new FailToGetOpenIdToken(new Error("Test driven error")),
|
new FailToGetOpenIdToken(new Error("Test driven error")),
|
||||||
);
|
);
|
||||||
try {
|
await expect(async () =>
|
||||||
await lastValueFrom(createLocalTransport$(localTransportOpts));
|
lastValueFrom(createLocalTransport$(localTransportOpts).active$),
|
||||||
throw Error("Expected test to throw");
|
).rejects.toThrow(expect.any(FailToGetOpenIdToken));
|
||||||
} catch (ex) {
|
|
||||||
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports getting transport via well-known", async () => {
|
it("supports getting transport via well-known", async () => {
|
||||||
localTransportOpts.client.getDomain.mockReturnValue("example.org");
|
localTransportOpts.client.getDomain.mockReturnValue("example.org");
|
||||||
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
|
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
|
||||||
@@ -334,15 +429,19 @@ describe("LocalTransport", () => {
|
|||||||
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const localTransport$ = createLocalTransport$(localTransportOpts);
|
const { advertised$, active$ } =
|
||||||
|
createLocalTransport$(localTransportOpts);
|
||||||
openIdResolver.resolve?.(openIdResponse);
|
openIdResolver.resolve?.(openIdResponse);
|
||||||
expect(localTransport$.value).toBe(null);
|
expect(advertised$.value).toBe(null);
|
||||||
|
expect(active$.value).toBe(null);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(localTransport$.value).toStrictEqual({
|
const expectedTransport = {
|
||||||
transport: {
|
livekit_service_url: "https://lk.example.org",
|
||||||
livekit_service_url: "https://lk.example.org",
|
type: "livekit",
|
||||||
type: "livekit",
|
};
|
||||||
},
|
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||||
|
expect(active$.value).toStrictEqual({
|
||||||
|
transport: expectedTransport,
|
||||||
sfuConfig: {
|
sfuConfig: {
|
||||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||||
livekitAlias: "Akph4alDMhen",
|
livekitAlias: "Akph4alDMhen",
|
||||||
@@ -352,6 +451,7 @@ describe("LocalTransport", () => {
|
|||||||
});
|
});
|
||||||
expect(fetchMock.done()).toEqual(true);
|
expect(fetchMock.done()).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails fast if the openId request fails for the well-known config", async () => {
|
it("fails fast if the openId request fails for the well-known config", async () => {
|
||||||
localTransportOpts.client.getDomain.mockReturnValue("example.org");
|
localTransportOpts.client.getDomain.mockReturnValue("example.org");
|
||||||
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
|
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
|
||||||
@@ -362,20 +462,18 @@ describe("LocalTransport", () => {
|
|||||||
openIdResolver.reject(
|
openIdResolver.reject(
|
||||||
new FailToGetOpenIdToken(new Error("Test driven error")),
|
new FailToGetOpenIdToken(new Error("Test driven error")),
|
||||||
);
|
);
|
||||||
try {
|
await expect(async () =>
|
||||||
await lastValueFrom(createLocalTransport$(localTransportOpts));
|
lastValueFrom(createLocalTransport$(localTransportOpts).active$),
|
||||||
throw Error("Expected test to throw");
|
).rejects.toThrow(expect.any(FailToGetOpenIdToken));
|
||||||
} catch (ex) {
|
|
||||||
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws if no options are available", async () => {
|
it("throws if no options are available", async () => {
|
||||||
const localTransport$ = createLocalTransport$({
|
const { advertised$, active$ } = createLocalTransport$({
|
||||||
scope,
|
scope,
|
||||||
ownMembershipIdentity: ownMemberMock,
|
ownMembershipIdentity: ownMemberMock,
|
||||||
roomId: "!example_room_id",
|
roomId: "!example_room_id",
|
||||||
useOldestMember$: constant(false),
|
useOldestMember: false,
|
||||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||||
delayId$: constant(null),
|
delayId$: constant(null),
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
client: {
|
client: {
|
||||||
@@ -390,7 +488,10 @@ describe("LocalTransport", () => {
|
|||||||
});
|
});
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(() => localTransport$.value).toThrow(
|
expect(() => advertised$.value).toThrow(
|
||||||
|
new MatrixRTCTransportMissingError(""),
|
||||||
|
);
|
||||||
|
expect(() => active$.value).toThrow(
|
||||||
new MatrixRTCTransportMissingError(""),
|
new MatrixRTCTransportMissingError(""),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ import {
|
|||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { MatrixError, type MatrixClient } from "matrix-js-sdk";
|
import { MatrixError, type MatrixClient } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
combineLatest,
|
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
|
first,
|
||||||
from,
|
from,
|
||||||
map,
|
map,
|
||||||
|
merge,
|
||||||
of,
|
of,
|
||||||
|
startWith,
|
||||||
switchMap,
|
switchMap,
|
||||||
|
tap,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
||||||
@@ -58,8 +61,8 @@ interface Props {
|
|||||||
OpenIDClientParts;
|
OpenIDClientParts;
|
||||||
// Used by the jwt service to create the livekit room and compute the livekit alias.
|
// Used by the jwt service to create the livekit room and compute the livekit alias.
|
||||||
roomId: string;
|
roomId: string;
|
||||||
useOldestMember$: Behavior<boolean>;
|
useOldestMember: boolean;
|
||||||
forceJwtEndpoint$: Behavior<JwtEndpointVersion>;
|
forceJwtEndpoint: JwtEndpointVersion;
|
||||||
delayId$: Behavior<string | null>;
|
delayId$: Behavior<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,23 +96,35 @@ export interface LocalTransportWithSFUConfig {
|
|||||||
transport: LivekitTransportConfig;
|
transport: LivekitTransportConfig;
|
||||||
sfuConfig: SFUConfig;
|
sfuConfig: SFUConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLocalTransportWithSFUConfig(
|
export function isLocalTransportWithSFUConfig(
|
||||||
obj: LivekitTransportConfig | LocalTransportWithSFUConfig,
|
obj: LivekitTransportConfig | LocalTransportWithSFUConfig,
|
||||||
): obj is LocalTransportWithSFUConfig {
|
): obj is LocalTransportWithSFUConfig {
|
||||||
return "transport" in obj && "sfuConfig" in obj;
|
return "transport" in obj && "sfuConfig" in obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LocalTransport {
|
||||||
|
/**
|
||||||
|
* The transport to be advertised in our MatrixRTC membership. `null` when not
|
||||||
|
* yet fetched/validated.
|
||||||
|
*/
|
||||||
|
advertised$: Behavior<LivekitTransportConfig | null>;
|
||||||
|
/**
|
||||||
|
* The transport to connect to and publish media on. `null` when not yet known
|
||||||
|
* or available.
|
||||||
|
*/
|
||||||
|
active$: Behavior<LocalTransportWithSFUConfig | null>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is responsible for managing the local transport.
|
* Connects to the JWT service and determines the transports that the local member should use.
|
||||||
* "Which transport is the local member going to use"
|
|
||||||
*
|
*
|
||||||
* @prop useOldestMember Whether to use the same transport as the oldest member.
|
* @prop useOldestMember Whether to use the same transport as the oldest member.
|
||||||
* This will only update once the first oldest member appears. Will not recompute if the oldest member leaves.
|
* This will only update once the first oldest member appears. Will not recompute if the oldest member leaves.
|
||||||
*
|
* @prop useOldJwtEndpoint Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
|
||||||
* @prop useOldJwtEndpoint$ Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
|
|
||||||
* This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity.
|
* This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity.
|
||||||
* (which is expected for non sticky event based rtc member events)
|
* (which is expected for non sticky event based rtc member events)
|
||||||
* @returns The local transport. It will be created using the correct sfu endpoint based on the useOldJwtEndpoint$ value.
|
* @returns The transport to advertise in the local MatrixRTC membership, along with the transport to actively publish media to.
|
||||||
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
||||||
*/
|
*/
|
||||||
export const createLocalTransport$ = ({
|
export const createLocalTransport$ = ({
|
||||||
@@ -118,114 +133,156 @@ export const createLocalTransport$ = ({
|
|||||||
ownMembershipIdentity,
|
ownMembershipIdentity,
|
||||||
client,
|
client,
|
||||||
roomId,
|
roomId,
|
||||||
useOldestMember$,
|
useOldestMember,
|
||||||
forceJwtEndpoint$,
|
forceJwtEndpoint,
|
||||||
delayId$,
|
delayId$,
|
||||||
}: Props): Behavior<LocalTransportWithSFUConfig | null> => {
|
}: Props): LocalTransport => {
|
||||||
/**
|
/**
|
||||||
* The transport over which we should be actively publishing our media.
|
* The LiveKit transport in use by the oldest RTC membership. `null` when the
|
||||||
* undefined when not joined.
|
* oldest member has no such transport.
|
||||||
*/
|
*/
|
||||||
const oldestMemberTransport$ =
|
const oldestMemberTransport$ = scope.behavior<LivekitTransportConfig | null>(
|
||||||
scope.behavior<LocalTransportWithSFUConfig | null>(
|
memberships$.pipe(
|
||||||
combineLatest([memberships$, useOldestMember$]).pipe(
|
map((memberships) => {
|
||||||
map(([memberships, useOldestMember]) => {
|
const oldestMember = memberships.value[0];
|
||||||
if (!useOldestMember) return null; // No need to do any prefetching if not using oldest member
|
if (oldestMember === undefined) {
|
||||||
const oldestMember = memberships.value[0];
|
logger.info("Oldest member: not found");
|
||||||
const transport = oldestMember?.getTransport(oldestMember);
|
return null;
|
||||||
if (!transport) return null;
|
}
|
||||||
return transport;
|
const transport = oldestMember.getTransport(oldestMember);
|
||||||
}),
|
if (transport === undefined) {
|
||||||
switchMap((transport) => {
|
logger.warn(
|
||||||
if (transport !== null && isLivekitTransportConfig(transport)) {
|
`Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has no transport`,
|
||||||
// Get the open jwt token to connect to the sfu
|
);
|
||||||
const computeLocalTransportWithSFUConfig =
|
return null;
|
||||||
async (): Promise<LocalTransportWithSFUConfig> => {
|
}
|
||||||
return {
|
if (!isLivekitTransportConfig(transport)) {
|
||||||
transport,
|
logger.warn(
|
||||||
sfuConfig: await getSFUConfigWithOpenID(
|
`Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has invalid transport`,
|
||||||
client,
|
);
|
||||||
ownMembershipIdentity,
|
return null;
|
||||||
transport.livekit_service_url,
|
}
|
||||||
roomId,
|
logger.info(
|
||||||
{ forceJwtEndpoint: JwtEndpointVersion.Legacy },
|
"Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has valid transport",
|
||||||
logger,
|
);
|
||||||
),
|
return transport;
|
||||||
};
|
}),
|
||||||
};
|
distinctUntilChanged(areLivekitTransportsEqual),
|
||||||
return from(computeLocalTransportWithSFUConfig());
|
),
|
||||||
}
|
);
|
||||||
return of(null);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The transport that we would personally prefer to publish on (if not for the
|
* The transport that we would personally prefer to publish on (if not for the
|
||||||
* transport preferences of others, perhaps).
|
* transport preferences of others, perhaps). `null` until fetched and
|
||||||
|
* validated.
|
||||||
*
|
*
|
||||||
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
||||||
*/
|
*/
|
||||||
const preferredTransport$ = scope.behavior(
|
const preferredTransport$ =
|
||||||
// preferredTransport$ (used for multi sfu) needs to know if we are using the old or new
|
scope.behavior<LocalTransportWithSFUConfig | null>(
|
||||||
// jwt endpoint (`get_token` vs `sfu/get`) based on that the jwt endpoint will compute the rtcBackendIdentity
|
// preferredTransport$ (used for multi sfu) needs to know if we are using the old or new
|
||||||
// differently. (sha(`${userId}|${deviceId}|${memberId}`) vs `${userId}|${deviceId}|${memberId}`)
|
// jwt endpoint (`get_token` vs `sfu/get`) based on that the jwt endpoint will compute the rtcBackendIdentity
|
||||||
// When using sticky events (we need to use the new endpoint).
|
// differently. (sha(`${userId}|${deviceId}|${memberId}`) vs `${userId}|${deviceId}|${memberId}`)
|
||||||
combineLatest([customLivekitUrl.value$, delayId$, forceJwtEndpoint$]).pipe(
|
// When using sticky events (we need to use the new endpoint).
|
||||||
switchMap(([customUrl, delayId, forceEndpoint]) => {
|
customLivekitUrl.value$.pipe(
|
||||||
logger.info(
|
switchMap((customUrl) =>
|
||||||
"Creating preferred transport based on: ",
|
startWith<LocalTransportWithSFUConfig | null>(null)(
|
||||||
"customUrl: ",
|
// Fetch the SFU config, and repeat this asynchronously for every
|
||||||
customUrl,
|
// change in delay ID.
|
||||||
"delayId: ",
|
delayId$.pipe(
|
||||||
delayId,
|
switchMap(async (delayId) => {
|
||||||
"forceEndpoint: ",
|
logger.info(
|
||||||
forceEndpoint,
|
"Creating preferred transport based on: ",
|
||||||
);
|
"customUrl: ",
|
||||||
return from(
|
customUrl,
|
||||||
makeTransport(
|
"delayId: ",
|
||||||
client,
|
delayId,
|
||||||
ownMembershipIdentity,
|
"forceJwtEndpoint: ",
|
||||||
roomId,
|
forceJwtEndpoint,
|
||||||
customUrl,
|
);
|
||||||
forceEndpoint,
|
return makeTransport(
|
||||||
delayId ?? undefined,
|
client,
|
||||||
|
ownMembershipIdentity,
|
||||||
|
roomId,
|
||||||
|
customUrl,
|
||||||
|
forceJwtEndpoint,
|
||||||
|
delayId ?? undefined,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
// We deliberately hide any changes to the SFU config because we
|
||||||
|
// do not actually want the app to reconnect whenever the JWT
|
||||||
|
// token changes due to us delegating a new delayed event. The
|
||||||
|
// initial SFU config for the transport is all the app needs.
|
||||||
|
distinctUntilChanged((prev, next) =>
|
||||||
|
areLivekitTransportsEqual(prev.transport, next.transport),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}),
|
),
|
||||||
),
|
);
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
if (useOldestMember) {
|
||||||
* The chosen transport we should advertise in our MatrixRTC membership.
|
// --- Oldest member mode ---
|
||||||
*/
|
return {
|
||||||
return scope.behavior(
|
// Never update the transport that we advertise in our membership. Just
|
||||||
combineLatest([
|
// take the first valid oldest member or preferred transport that we learn
|
||||||
useOldestMember$,
|
// about, and stick with that. This avoids unnecessary SFU hops and room
|
||||||
oldestMemberTransport$,
|
// state changes.
|
||||||
preferredTransport$,
|
advertised$: scope.behavior(
|
||||||
]).pipe(
|
merge(
|
||||||
map(([useOldestMember, oldestMemberTransport, preferredTransport]) => {
|
oldestMemberTransport$,
|
||||||
return useOldestMember
|
preferredTransport$.pipe(map((t) => t?.transport ?? null)),
|
||||||
? (oldestMemberTransport ?? preferredTransport)
|
).pipe(
|
||||||
: preferredTransport;
|
first((t) => t !== null),
|
||||||
}),
|
tap((t) =>
|
||||||
distinctUntilChanged((t1, t2) => {
|
logger.info(`Advertise transport: ${t.livekit_service_url}`),
|
||||||
logger.info(
|
),
|
||||||
"Local Transport Update from:",
|
),
|
||||||
t1?.transport.livekit_service_url,
|
null,
|
||||||
" to ",
|
),
|
||||||
t2?.transport.livekit_service_url,
|
// Publish on the transport used by the oldest member.
|
||||||
);
|
active$: scope.behavior(
|
||||||
return areLivekitTransportsEqual(
|
oldestMemberTransport$.pipe(
|
||||||
t1?.transport ?? null,
|
switchMap((transport) => {
|
||||||
t2?.transport ?? null,
|
// Oldest member not available (or invalid SFU config).
|
||||||
);
|
if (transport === null) return of(null);
|
||||||
}),
|
// Oldest member available: fetch the SFU config.
|
||||||
|
const fetchOldestMemberTransport =
|
||||||
|
async (): Promise<LocalTransportWithSFUConfig> => ({
|
||||||
|
transport,
|
||||||
|
sfuConfig: await getSFUConfigWithOpenID(
|
||||||
|
client,
|
||||||
|
ownMembershipIdentity,
|
||||||
|
transport.livekit_service_url,
|
||||||
|
roomId,
|
||||||
|
{ forceJwtEndpoint: JwtEndpointVersion.Legacy },
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return from(fetchOldestMemberTransport()).pipe(startWith(null));
|
||||||
|
}),
|
||||||
|
tap((t) =>
|
||||||
|
logger.info(
|
||||||
|
`Publish on transport: ${t?.transport.livekit_service_url}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Multi-SFU mode ---
|
||||||
|
// Always publish on and advertise the preferred transport.
|
||||||
|
return {
|
||||||
|
advertised$: scope.behavior(
|
||||||
|
preferredTransport$.pipe(
|
||||||
|
map((t) => t?.transport ?? null),
|
||||||
|
distinctUntilChanged(areLivekitTransportsEqual),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
active$: preferredTransport$,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
|
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export interface IConnectionManager {
|
|||||||
* @param props - Configuration object
|
* @param props - Configuration object
|
||||||
* @param props.scope - The observable scope used by this object
|
* @param props.scope - The observable scope used by this object
|
||||||
* @param props.connectionFactory - Used to create new connections
|
* @param props.connectionFactory - Used to create new connections
|
||||||
* @param props.localTransport$ - The local transport to use. (deduplicated with remoteTransports$)
|
* @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.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.ownMembershipIdentity - The own membership identity to use.
|
||||||
* @param props.logger - The logger to use.
|
* @param props.logger - The logger to use.
|
||||||
@@ -164,21 +164,21 @@ export function createConnectionManager$({
|
|||||||
generateItemsWithEpoch(
|
generateItemsWithEpoch(
|
||||||
"ConnectionManager connections$",
|
"ConnectionManager connections$",
|
||||||
function* (transports) {
|
function* (transports) {
|
||||||
for (const transportWithOrWithoutSfuConfig of transports) {
|
for (const transport of transports) {
|
||||||
if (
|
if (isLocalTransportWithSFUConfig(transport)) {
|
||||||
isLocalTransportWithSFUConfig(transportWithOrWithoutSfuConfig)
|
// This is the local transport; only the `LocalTransportWithSFUConfig` has a `sfuConfig` field.
|
||||||
) {
|
|
||||||
// This is the local transport only the `LocalTransportWithSFUConfig` has a `sfuConfig` field
|
|
||||||
const { transport, sfuConfig } = transportWithOrWithoutSfuConfig;
|
|
||||||
yield {
|
yield {
|
||||||
keys: [transport.livekit_service_url, sfuConfig],
|
keys: [
|
||||||
|
transport.transport.livekit_service_url,
|
||||||
|
transport.sfuConfig,
|
||||||
|
],
|
||||||
data: undefined,
|
data: undefined,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
yield {
|
yield {
|
||||||
keys: [
|
keys: [
|
||||||
transportWithOrWithoutSfuConfig.livekit_service_url,
|
transport.livekit_service_url,
|
||||||
undefined as undefined | SFUConfig,
|
undefined as SFUConfig | undefined,
|
||||||
],
|
],
|
||||||
data: undefined,
|
data: undefined,
|
||||||
};
|
};
|
||||||
@@ -194,6 +194,8 @@ export function createConnectionManager$({
|
|||||||
},
|
},
|
||||||
ownMembershipIdentity,
|
ownMembershipIdentity,
|
||||||
logger,
|
logger,
|
||||||
|
// TODO: This whole optional SFUConfig parameter is not particularly elegant.
|
||||||
|
// I would like it if connections always fetched the SFUConfig by themselves.
|
||||||
sfuConfig,
|
sfuConfig,
|
||||||
);
|
);
|
||||||
// Start the connection immediately
|
// Start the connection immediately
|
||||||
|
|||||||
@@ -213,6 +213,38 @@ export function filterBehavior<T, S extends T>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a changing input value to an item whose lifetime is tied to a certain
|
||||||
|
* computed key. The item may capture some dynamic data from the input.
|
||||||
|
*/
|
||||||
|
export function generateItem<
|
||||||
|
Input,
|
||||||
|
Keys extends [unknown, ...unknown[]],
|
||||||
|
Data,
|
||||||
|
Item,
|
||||||
|
>(
|
||||||
|
name: string,
|
||||||
|
generator: (input: Input) => { keys: readonly [...Keys]; data: Data },
|
||||||
|
factory: (
|
||||||
|
scope: ObservableScope,
|
||||||
|
data$: Behavior<Data>,
|
||||||
|
...keys: Keys
|
||||||
|
) => Item,
|
||||||
|
): OperatorFunction<Input, Item> {
|
||||||
|
return (input$) =>
|
||||||
|
input$.pipe(
|
||||||
|
generateItemsInternal(
|
||||||
|
name,
|
||||||
|
function* (input) {
|
||||||
|
yield generator(input);
|
||||||
|
},
|
||||||
|
factory,
|
||||||
|
(items) => items,
|
||||||
|
),
|
||||||
|
map(([item]) => item),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function generateItemsInternal<
|
function generateItemsInternal<
|
||||||
Input,
|
Input,
|
||||||
Keys extends [unknown, ...unknown[]],
|
Keys extends [unknown, ...unknown[]],
|
||||||
|
|||||||
Reference in New Issue
Block a user