mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
This is the mode in which we sent membership events with the 'oldest membership' transport selection algorithm, which stopped being the default back in version 0.21.0. Users will no longer be able to select this mode in developer settings, and admins will no longer be able to select legacy mode through the config either. The app will still continue to support *receiving* membership events with the 'oldest membership' transport selection algorithm from others, however.
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
/*
|
|
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 {
|
|
connectedParticipantsObserver,
|
|
connectionStateObserver,
|
|
} from "@livekit/components-core";
|
|
import {
|
|
ConnectionError,
|
|
ConnectionErrorReason,
|
|
type RemoteParticipant,
|
|
type Room as LivekitRoom,
|
|
} from "livekit-client";
|
|
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
|
import { BehaviorSubject, map } from "rxjs";
|
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
|
|
|
import {
|
|
getSFUConfigWithOpenID,
|
|
type OpenIDClientParts,
|
|
type SFUConfig,
|
|
} from "../../../livekit/openIDSFU.ts";
|
|
import { type Behavior } from "../../Behavior.ts";
|
|
import { type ObservableScope } from "../../ObservableScope.ts";
|
|
import {
|
|
ElementCallError,
|
|
InsufficientCapacityError,
|
|
LivekitConnectionError,
|
|
PeerConnectionTimeoutError,
|
|
SFURoomCreationRestrictedError,
|
|
UnknownCallError,
|
|
} from "../../../utils/errors.ts";
|
|
|
|
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 */
|
|
ownMembershipIdentity: CallMembershipIdentityParts;
|
|
/** The media transport to connect to. */
|
|
transport: LivekitTransportConfig;
|
|
/** The Matrix client to use for OpenID and SFU config requests. */
|
|
client: OpenIDClientParts;
|
|
/** The room ID this connection is associated with. */
|
|
roomId: string;
|
|
/** The observable scope to use for this connection. */
|
|
scope: ObservableScope;
|
|
|
|
/** Optional factory to create the LiveKit room, mainly for testing purposes. */
|
|
livekitRoomFactory: () => LivekitRoom;
|
|
}
|
|
export class FailedToStartError extends Error {
|
|
public constructor(message: string) {
|
|
super(message);
|
|
this.name = "FailedToStartError";
|
|
}
|
|
}
|
|
|
|
export enum ConnectionState {
|
|
/** The start state of a connection. It has been created but nothing has loaded yet. */
|
|
Initialized = "Initialized",
|
|
/** `start` has been called on the connection. It aquires the jwt info to conenct to the LK Room */
|
|
FetchingConfig = "FetchingConfig",
|
|
Stopped = "Stopped",
|
|
/** The same as ConnectionState.Disconnected from `livekit-client` */
|
|
LivekitDisconnected = "disconnected",
|
|
/** The same as ConnectionState.Connecting from `livekit-client` */
|
|
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.
|
|
*
|
|
* Expose observables for participants and connection state.
|
|
*/
|
|
export class Connection {
|
|
// Private Behavior
|
|
private readonly _state$ = new BehaviorSubject<
|
|
ConnectionState | ElementCallError
|
|
>(ConnectionState.Initialized);
|
|
|
|
/**
|
|
* The current state of the connection to the media transport.
|
|
*/
|
|
public readonly state$: Behavior<ConnectionState | Error> = this._state$;
|
|
|
|
/**
|
|
* The media transport to connect to.
|
|
*/
|
|
public readonly transport: LivekitTransportConfig;
|
|
|
|
public readonly livekitRoom: LivekitRoom;
|
|
|
|
private scope: ObservableScope;
|
|
|
|
/**
|
|
* The remote LiveKit participants that are visible on this connection.
|
|
*
|
|
* Note that this may include participants that are connected only to
|
|
* subscribe, or publishers that are otherwise unattested in MatrixRTC state.
|
|
* It is therefore more low-level than what should be presented to the user.
|
|
*/
|
|
public readonly remoteParticipants$: Behavior<RemoteParticipant[]>;
|
|
|
|
/**
|
|
* The alias of the LiveKit room.
|
|
*/
|
|
public get livekitAlias(): string | undefined {
|
|
return this._livekitAlias;
|
|
}
|
|
private _livekitAlias?: string;
|
|
|
|
/**
|
|
* Whether the connection has been stopped.
|
|
* @see Connection.stop
|
|
* */
|
|
protected stopped = false;
|
|
|
|
// TODO: can we just keep the ConnectionOpts object instead of spreading?
|
|
private readonly client: OpenIDClientParts;
|
|
private readonly roomId: string;
|
|
private readonly logger: Logger;
|
|
private readonly ownMembershipIdentity: CallMembershipIdentityParts;
|
|
private readonly existingSFUConfig?: SFUConfig;
|
|
/**
|
|
* Creates a new connection to a matrix RTC LiveKit backend.
|
|
*
|
|
* @param opts - Connection options {@link ConnectionOpts}.
|
|
*
|
|
* @param logger - The logger to use.
|
|
*/
|
|
public constructor(opts: ConnectionOpts, logger: Logger) {
|
|
this.ownMembershipIdentity = opts.ownMembershipIdentity;
|
|
this.existingSFUConfig = opts.existingSFUConfig;
|
|
this.roomId = opts.roomId;
|
|
this.logger = logger.getChild(
|
|
"[Connection " + opts.transport.livekit_service_url + "]",
|
|
);
|
|
this.logger.info(
|
|
`constructor: ${opts.transport.livekit_service_url} roomId: ${this.roomId} withSfuConfig?: ${opts.existingSFUConfig ? JSON.stringify(opts.existingSFUConfig) : "undefined"}`,
|
|
);
|
|
const { transport, client, scope } = opts;
|
|
|
|
this.scope = scope;
|
|
this.livekitRoom = opts.livekitRoomFactory();
|
|
this.transport = transport;
|
|
this.client = client;
|
|
|
|
this.remoteParticipants$ = scope.behavior(
|
|
// Only tracks remote participants
|
|
connectedParticipantsObserver(this.livekitRoom),
|
|
);
|
|
|
|
scope.onEnd(() => {
|
|
this.logger.info(`Connection scope ended, stopping connection`);
|
|
void this.stop();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Starts the connection.
|
|
*
|
|
* This will:
|
|
* 1. Request an OpenId token `request_token` (allows matrix users to verify their identity with a third-party service.)
|
|
* 2. Use this token to request the SFU config to the MatrixRtc authentication service.
|
|
* 3. Connect to the configured LiveKit room.
|
|
*
|
|
* 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.
|
|
* @throws {InsufficientCapacityError} if the LiveKit server indicates that it has insufficient capacity to accept the connection.
|
|
* @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created.
|
|
*/
|
|
// TODO consider an autostart pattern...
|
|
public async start(): Promise<void> {
|
|
this.logger.debug("Starting Connection");
|
|
this.stopped = false;
|
|
try {
|
|
this._state$.next(ConnectionState.FetchingConfig);
|
|
// We should already have this information after creating the localTransport.
|
|
// 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(
|
|
"Starting Connection to: ",
|
|
this.transport.livekit_service_url,
|
|
"jwt: ",
|
|
jwt,
|
|
"wss: ",
|
|
url,
|
|
"livekitAlias: ",
|
|
livekitAlias,
|
|
);
|
|
this._livekitAlias = livekitAlias;
|
|
// If we were stopped while fetching the config, don't proceed to connect
|
|
if (this.stopped) return;
|
|
|
|
// Setup observer once we are done with getSFUConfigWithOpenID
|
|
connectionStateObserver(this.livekitRoom)
|
|
.pipe(
|
|
this.scope.bind(),
|
|
map((s) => s as unknown as ConnectionState),
|
|
)
|
|
.subscribe((lkState) => {
|
|
// It is save to cast lkState to ConnectionState as they are fully overlapping.
|
|
this._state$.next(lkState);
|
|
});
|
|
|
|
try {
|
|
this.logger.info(`livekitRoom.connect ${url}`);
|
|
await this.livekitRoom.connect(url, jwt);
|
|
this.logger.info(`livekitRoom.connect SUCCESS ${url}`);
|
|
} catch (e) {
|
|
this.logger.info(`livekitRoom.connect FAILED ${url}`, e);
|
|
// LiveKit uses 503 to indicate that the server has hit its track limits.
|
|
// https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
|
|
// It also errors with a status code of 200 (yes, really) for room
|
|
// participant limits.
|
|
// LiveKit Cloud uses 429 for connection limits.
|
|
// Either way, all these errors can be explained as "insufficient capacity".
|
|
if (e instanceof ConnectionError) {
|
|
if (e.status === 503 || e.status === 200 || e.status === 429) {
|
|
throw new InsufficientCapacityError();
|
|
}
|
|
if (e.status === 404) {
|
|
// error msg is "Failed to create call"
|
|
// error description is "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists."
|
|
// The room does not exist. There are two different modes of operation for the SFU:
|
|
// - the room is created on the fly when connecting (livekit `auto_create` option)
|
|
// - Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service)
|
|
// In the first case there will not be a 404, so we are in the second case.
|
|
throw new SFURoomCreationRestrictedError();
|
|
}
|
|
|
|
if (e.reason === ConnectionErrorReason.Timeout) {
|
|
// Unabled to establish peer connection within the timeout
|
|
throw new PeerConnectionTimeoutError();
|
|
}
|
|
|
|
throw new LivekitConnectionError(e);
|
|
}
|
|
throw e;
|
|
}
|
|
// If we were stopped while connecting, don't proceed to update state.
|
|
if (this.stopped) return;
|
|
} catch (error) {
|
|
this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
|
|
this._state$.next(
|
|
error instanceof ElementCallError
|
|
? error
|
|
: error instanceof Error
|
|
? new UnknownCallError(error)
|
|
: new UnknownCallError(new Error(`${error}`)),
|
|
);
|
|
// Its okay to ignore the throw. The error is part of the state.
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
protected async getSFUConfigForRemoteConnection(): Promise<SFUConfig> {
|
|
// This will only be called for sfu's where we do not publish ourselves.
|
|
// For the local connection we will use the existingJwtTokenData
|
|
return await getSFUConfigWithOpenID(
|
|
this.client,
|
|
this.ownMembershipIdentity,
|
|
this.transport.livekit_service_url,
|
|
this.roomId,
|
|
// dont pass any custom opts for the subscribe only connections
|
|
{},
|
|
this.logger,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stops the connection.
|
|
*
|
|
* This will disconnect from the LiveKit room.
|
|
* If the connection is already stopped, this is a no-op.
|
|
*/
|
|
public async stop(): Promise<void> {
|
|
this.logger.debug(
|
|
`stop: disconnecing from lk room ${this.transport.livekit_service_url}`,
|
|
);
|
|
if (this.stopped) return;
|
|
await this.livekitRoom.disconnect();
|
|
this._state$.next(ConnectionState.Stopped);
|
|
this.stopped = true;
|
|
this.logger.debug(
|
|
`stop: DONE disconnecing from lk room ${this.transport.livekit_service_url}`,
|
|
);
|
|
}
|
|
}
|