mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
830 lines
48 KiB
Markdown
830 lines
48 KiB
Markdown
# MatrixRTC SDK architecture
|
|
|
|
`@element-hq/matrixrtc-sdk` is the model under Element Call, on its own: one
|
|
`MatrixRTCClient` per room that joins and leaves the MatrixRTC session, discovers
|
|
and connects to the media transports, publishes the local media, distributes the
|
|
encryption keys, and hands out every member with its media as observables. It has
|
|
no UI, reads no configuration and no settings, and never names LiveKit in its
|
|
interface.
|
|
|
|
Element Call becomes one consumer of it: `CallViewModel` keeps what makes a session
|
|
a _call_ (ringing, auto-leave, layout, sounds, reactions, settings, the host
|
|
bridge) and takes everything else from the client. The dependency direction is
|
|
therefore `Element Call → sdk`, never the reverse. What moved out of Element
|
|
Call's `src/` to get there is listed in [`SdkMigration.md`](./SdkMigration.md).
|
|
|
|
## The architecture
|
|
|
|
```
|
|
host application (Element Call, a whiteboard, watch-together, …)
|
|
│
|
|
│ createMatrixRTCClient(scope, matrixClient, room, options)
|
|
▼
|
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
│ MatrixRTCClient │
|
|
│ │
|
|
│ public API, all Behaviors: │
|
|
│ join() leave() state$ │
|
|
│ localMember$ remoteMembers$ memberCount$ sendData() data$ │
|
|
│ keyRotationSuppressed$ connectedTransports$ │
|
|
│ │
|
|
│ per member RTCMember { id userId deviceId membership$ displayName$ │
|
|
│ avatarUrl$ transport$ tracks$ encryptionError$ }│
|
|
│ per track MediaTrack { source kind muted$ encrypted stats$ attach() } │
|
|
│ │
|
|
│ ┌───────────────────────────┐ MediaBackend ┌───────────────────────────┐ │
|
|
│ │ session: MatrixRTC │ media-backend/ │ media backend │ │
|
|
│ │ │ api.ts │ media-backend/livekit/ │ │
|
|
│ │ js-sdk MatrixRTCSession, │ │ today, or options.backend │ │
|
|
│ │ later the rust-rtc │ prepareLocal │ │ │
|
|
│ │ crate behind a bridge │ Transport ───▶│ auth: MSC4195 CS API or │ │
|
|
│ │ memberships from room │ delegateDelayed │ JWT service, by shape │ │
|
|
│ │ state and sticky events │ Leave ───────▶│ and role; delegation │ │
|
|
│ │ own membership, delayed │ │ ConnectionMap: one │ │
|
|
│ │ leave, retries │ mediaKeys$ ────▶│ LiveKit room per │ │
|
|
│ │ discovery of the local │ mediaFor$ ─────▶│ transport locator, │ │
|
|
│ │ transport (homeserver) │ ◀─ tracks$, │ local published, │ │
|
|
│ │ membership state machine │ encryptionError$│ remote subscribed │ │
|
|
│ │ (LocalMembership) │ local.publish, │ Publisher: local tracks, │ │
|
|
│ │ transport registry │ setPublishing▶│ upstream paused until │ │
|
|
│ │ (TransportMetadata) │ ◀─ connections$ │ joined │ │
|
|
│ │ data packet → member │ ◀─ data$ │ LivekitMemberMedia: │ │
|
|
│ │ │ │ participant → tracks │ │
|
|
│ └───────────────────────────┘ └───────────────────────────┘ │
|
|
│ │
|
|
│ RTCMember = membership + mediaFor$(membership): the backend matches the │
|
|
│ member's backendIdentities to the identity it sees on the transport │
|
|
└──────────────────────────────────────────────────────────────────────────────┘
|
|
│ │
|
|
▼ ▼
|
|
MatrixClient (sync, room state, to-device, OpenID) LiveKit SFU
|
|
```
|
|
|
|
The left box is MatrixRTC: who is in the session, with which transport, the
|
|
keys, the delayed leave, and the membership state machine. The right box is a
|
|
media backend: whatever carries the media, behind the `MediaBackend` interface in
|
|
`sdk/src/media-backend/api.ts`. The client is the join of the two: a member exists
|
|
once its membership does, and gets its media once the backend has something
|
|
for that membership on its transport; `state$` is the membership state combined
|
|
with the local media connection. LiveKit is the one backend today; a
|
|
cascading SFU or full mesh is another folder under `sdk/src/media-backend/`, handed
|
|
in through `options.backend`, and nothing in the left box changes.
|
|
|
|
### How Element Call uses it
|
|
|
|
```
|
|
Element Call, as app, widget or component
|
|
┌────────────────────────────────────────────────────────────────┐
|
|
│ views: InCallView, tiles, header, footer, settings │
|
|
│ ▲ Behaviors │
|
|
│ CallViewModel │
|
|
│ layout, ringing and notifications, auto-leave, sounds, │
|
|
│ reactions, hand raise, settings, audio routing, host bridge │
|
|
│ ▲ state$, localMember$, remoteMembers$, tracks$ │
|
|
│ MatrixRTCClient @element-hq/matrixrtc-sdk │
|
|
└────────────────────────────────────────────────────────────────┘
|
|
│ MatrixClient + Room │ livekit-client
|
|
▼ ▼
|
|
homeserver SFU
|
|
```
|
|
|
|
All three modes already hold a `MatrixClient` and a `Room` by the time a call
|
|
starts: standalone after `useLoadGroupCall`, the component from the host's `client`
|
|
prop, the widget from `createRoomWidgetClient`. The view model passes them on and
|
|
builds its media view models from `RTCMember` and its `tracks$` instead of from
|
|
LiveKit participants.
|
|
|
|
### Joining, in order
|
|
|
|
```
|
|
host MatrixRTCClient / backend homeserver / JWT service LiveKit SFU
|
|
│ create ───▶│ │ │
|
|
│ │ GET rtc/transports (session) ────────▶│ │
|
|
│ │◀── first transport of the backend's type │
|
|
│ │ (none, LiveKit only: a member's SFU │ │
|
|
│ │ that lets us publish, get_token) ─▶│ │
|
|
│ │ prepareLocalTransport (backend): │ │
|
|
│ │ publisher token: CS API get_token, │ │
|
|
│ │ or OpenID token then /sfu/get ─────▶│ │
|
|
│ │◀── sfu url, jwt, alias, identity ─────│ = resolved$ of the │
|
|
│ │ probe delegate_delayed_leave ──────▶│ local transport │
|
|
│ │ connect(url, jwt) ───────────────────────────────────────────▶│
|
|
│ join() ───▶│ (once the publisher holds its token)│ │
|
|
│ │ membership state event + delayed leave ──▶│ │
|
|
│ │ delegateDelayedLeave(delayId) (backend) ─▶│ │
|
|
│ │ create tracks, resume upstream ──────────────────────────────▶│
|
|
│ │ media key to every other member (to-device) ──▶│ │
|
|
│ │◀── other memberships (sync) ──────────│◀── participants ────────│
|
|
│◀─ state$ connected, localMember$, remoteMembers$ with tracks$ ───────────────│
|
|
```
|
|
|
|
The session discovers the transport from the homeserver and the backend prepares
|
|
it, both before `join()`, so that the membership can name it and the first frames
|
|
go out as soon as the state event is sent. Preparing is the backend's own
|
|
business: for LiveKit the token exchange for the publisher connection, and the
|
|
probe that decides whether the service that issued the token can take over the
|
|
delayed leave, which picks the delayed leave timings the join uses. The join
|
|
waits for the publisher to hold its token, so that the LiveKit room exists
|
|
before a subscriber, who lacks the permission to create it, asks for a token
|
|
of its own. Until `join()` the tracks exist but their upstream is paused: a host
|
|
can show a preview without anyone hearing it. The upstream flows exactly while the
|
|
membership is `joined`: a sync outage pauses it, and `leave()` pauses it for good
|
|
and sends the leave.
|
|
|
|
## Vocabulary
|
|
|
|
The words the SDK is written in. They name the shape after the slot and
|
|
participation split in
|
|
[`client-into-slot-session-split-plan.md`](./client-into-slot-session-split-plan.md);
|
|
the sections below still describe `MatrixRTCClient` until that lands.
|
|
|
|
| Word | Meaning here | Elsewhere |
|
|
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
|
| slot | an `RTCSlot`: the namespace in a room that memberships join into, `application` plus `id`. It exists while the room does, with nobody in it too. Holds `members$`, its open or closed `status$`, and `join()` | the js-sdk `MatrixRTCSession` is one per slot; the MSC4143 slot event |
|
|
| participation | an `RTCParticipation`: the span between our `join()` of a slot and our `leave()`. Holds the connection and its `state$`, `localMember$`, `remoteMembers$`, the connected transports and the data channel. Made by `slot.join()`, never reused | "session" is not used here, because in the js-sdk it means the slot |
|
|
| member | an `RTCMember`: one membership in a slot with what the SDK derives from it, `rtcBackendIdentity`, `userId`, `deviceId`, `memberId`, `displayName$`, `avatarUrl$`, `transport$` and `applicationData$`. No media. What the slot hands out in `members$` | the js-sdk `CallMembership` behind it, which never leaves the SDK; Element Call's old "matrixLivekitMember" |
|
|
| local member, remote member | `LocalRTCMember` and `RemoteRTCMember`: an `RTCMember` plus its `MemberMedia`, which only a participation can carry. The local one's tracks carry the controls. What the participation hands out in `localMember$` and `remoteMembers$` | LiveKit `LocalParticipant` and `RemoteParticipant`, which never leave the SDK |
|
|
| media | a `MemberMedia`: what a member sends once it has arrived on its transport, `tracks$`, null while waiting, and `encryptionError$` beside it. The media backend supplies exactly these two fields per member | Element Call's `MediaViewModel` is a view of it |
|
|
| track | a `MediaTrack`: one publication of a member, audio or video, with `source`, `kind`, `id` and the behaviors on it. `LocalAudioMediaTrack` and `LocalVideoMediaTrack` add the controls over our own | LiveKit `TrackPublication` and `Track`, which never leave the SDK |
|
|
| publish request | a `PublishRequest`: a source, where to capture it from and how to encode it. Given at the join in `publish`, or later to `publish` on the participation | |
|
|
| attach, detach | handing a `<video>` or `<audio>` element to a track and taking it back. The SDK owns the stream and the observers on the element | LiveKit `Track.attach` |
|
|
| transport | an `RTCTransport`: where media is exchanged, a LiveKit service url today. Carries the raw js-sdk `Transport` from the membership and, in `resolved$`, what the backend fetched to connect to it | MSC4143 "focus" |
|
|
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation, chosen with `backend` in the join options; LiveKit under `sdk/src/media-backend/livekit/` is the one that exists | "SFU", a LiveKit room, a peer connection |
|
|
|
|
Names use `RTC` in capitals, as the js-sdk does: `RTCSlot`, `RTCParticipation`,
|
|
`RTCMember`, `matrixRTCMode`.
|
|
|
|
## Reactive primitives
|
|
|
|
The whole public API is made of `Behavior`s: an rxjs observable that always holds a
|
|
current value (`.value`) and replays it on subscription. A consumer subscribes to
|
|
what it renders and reads `.value` where it needs the present state. Everything is
|
|
bound to an `ObservableScope` the host creates; ending the scope ends the client,
|
|
its connections and its tracks. Both come from the SDK (`Behavior`, `constant`,
|
|
`ObservableScope`); the host needs rxjs and nothing else.
|
|
|
|
## What a host must provide
|
|
|
|
Found the hard way while building the harness; each one fails in a way that looks
|
|
like an SDK bug.
|
|
|
|
- **The room the sync loop maintains.** `client.getRoom(roomId)` once the join has
|
|
synced. `joinRoom` returns a detached copy for a room joined just now, and a
|
|
client built on it never sees a member.
|
|
- **Power to send the membership event.** Every member needs to write
|
|
`org.matrix.msc3401.call.member` (and the sticky event in Matrix 2.0 mode). A
|
|
plain room only lets its creator send state; Element Call's rooms set the level
|
|
to 0.
|
|
- **An encrypted room for per-participant keys.** Media keys travel in encrypted
|
|
to-device messages, which the crypto only delivers to devices it tracks, which
|
|
are the members of encrypted rooms. In an unencrypted room the key never arrives
|
|
and every remote track decodes nothing.
|
|
- **A logged-in, syncing client with crypto set up**, and the transport advertised
|
|
by the homeserver (`rtc/transports`). The SDK has no fallback url of its own.
|
|
|
|
## The client: `MatrixRTCClient`
|
|
|
|
Rule of thumb for what belongs here: if it would be the same for a different
|
|
application on top of the same MatrixRTC session, it is in the client. If it is
|
|
specific to calls, or depends on the window, a setting, a platform, or a React
|
|
context, it is in the view model.
|
|
|
|
### Creating one
|
|
|
|
```ts
|
|
import { type MatrixClient, type Room } from "matrix-js-sdk";
|
|
import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc";
|
|
|
|
export interface MatrixRTCClientOptions {
|
|
encryptionSystem: EncryptionSystem;
|
|
/** Resolved by the host; the SDK reads neither config.json nor settings. */
|
|
matrixRTCMode: MatrixRTCMode;
|
|
/** Published at the join; `publish` on the local member adds to it from then on. */
|
|
publish: PublishRequest[];
|
|
/**
|
|
* MSC4075 notification sent with the join. A parameter of the MatrixRTC
|
|
* join itself, so it is here even though it is named after calls; reacting
|
|
* to a notification (ringing, timeouts, declines) is the application's job.
|
|
*/
|
|
sendNotificationType?: RTCNotificationType;
|
|
/** The application the session belongs to, as named in the membership. Default `m.call`. */
|
|
application?: string;
|
|
/** The application's slot in the room. Default `ROOM`. */
|
|
slot?: string;
|
|
/**
|
|
* Whatever the application wants to say about itself in the membership.
|
|
* Opaque to the SDK. Today the js-sdk carries one key, `m.call.intent`
|
|
* (`"audio"` or `"video"`); anything else is dropped until it can.
|
|
*/
|
|
applicationData?: Record<string, unknown>;
|
|
/** Session timings the host has configured; the defaults otherwise. */
|
|
timings?: Partial<SessionTimings>;
|
|
/** Limits on what is published; LiveKit's defaults otherwise. */
|
|
mediaQuality?: MediaQuality;
|
|
/** Use this transport instead of asking the homeserver. */
|
|
transport?: Transport;
|
|
/** Use this transport when the homeserver advertises none of the backend's type. */
|
|
fallbackTransport?: Transport;
|
|
}
|
|
|
|
/**
|
|
* One thing to publish: a source, where to capture it from and how to encode
|
|
* it. Where the device and the settings are left out, the browser's and
|
|
* LiveKit's defaults apply.
|
|
*/
|
|
export type PublishRequest =
|
|
| { source: "microphone"; deviceId?: string; capture?: AudioCaptureSettings }
|
|
| {
|
|
source: "camera";
|
|
deviceId?: string;
|
|
/** Background blur and the like. */
|
|
processor?: VideoProcessor;
|
|
capture?: VideoCaptureSettings;
|
|
}
|
|
| {
|
|
source: "screenShare";
|
|
/** Whether to capture the screen's audio too. Default true. */
|
|
audio?: boolean;
|
|
capture?: VideoCaptureSettings;
|
|
};
|
|
```
|
|
|
|
Device enumeration, permission prompts and the lobby preview stay with the
|
|
host: it hands over publish requests naming the devices it chose.
|
|
|
|
```ts
|
|
/**
|
|
* Takes the whole MatrixClient, not a narrow pick, and finds the MatrixRTC
|
|
* session itself. The js-sdk is the MatrixRTC implementation today; when the
|
|
* rust-rtc crate replaces it, the SDK has to bridge the crate to the js-sdk
|
|
* client (events, state, to-device, delayed events, OpenID), and only the SDK
|
|
* knows what that bridge needs. Holding the client keeps that change inside
|
|
* the SDK. The js-sdk session is not part of the public API.
|
|
*
|
|
* The room has to be the one the client's sync loop maintains; see "What a
|
|
* host must provide".
|
|
*/
|
|
export function createMatrixRTCClient(
|
|
scope: ObservableScope,
|
|
client: MatrixClient,
|
|
room: Room,
|
|
options: MatrixRTCClientOptions,
|
|
): MatrixRTCClient;
|
|
```
|
|
|
|
### The client
|
|
|
|
```ts
|
|
/** The homeserver link that is down, in the order they depend on each other. */
|
|
export type MatrixDisconnectReason = "sync" | "membership" | "probablyLeft";
|
|
|
|
/** Why the participation is not connected: the first failing link, in dependency order. */
|
|
export type DisconnectReason = MatrixDisconnectReason | "media";
|
|
|
|
export type ParticipationState =
|
|
| { kind: "waitingForTransport" }
|
|
| { kind: "connecting" }
|
|
| { kind: "connected" }
|
|
| { kind: "reconnecting"; reason: DisconnectReason }
|
|
/** A failed publication is not fatal: the member can still receive, so it stays on the local member's media. */
|
|
| { kind: "failed"; error: MatrixRTCError }
|
|
/** `leave()` was called. Terminal. */
|
|
| { kind: "left" };
|
|
|
|
/**
|
|
* An error raised by the client. `code` and `category` say what went wrong in
|
|
* a form a host can translate; `cause` holds the backend's error.
|
|
*/
|
|
export class MatrixRTCError extends Error {
|
|
code: ErrorCode;
|
|
category: ErrorCategory;
|
|
}
|
|
|
|
export interface MatrixRTCClient {
|
|
join(): void;
|
|
leave(): void;
|
|
/**
|
|
* The Matrix membership combined with the local media connection. One
|
|
* value: a reason exists only while `reconnecting`, an error only once
|
|
* `failed`.
|
|
*/
|
|
state$: Behavior<ParticipationState>;
|
|
|
|
/** Null until our own membership has been seen in the room. */
|
|
localMember$: Behavior<LocalRTCMember | null>;
|
|
remoteMembers$: Behavior<RemoteRTCMember[]>;
|
|
/** `remoteMembers.length`, plus one for the local member once it exists. One per membership, so one user may count several times. */
|
|
memberCount$: Behavior<number>;
|
|
|
|
/**
|
|
* Whether the session has grown large enough that MatrixRTC has stopped
|
|
* rotating the media encryption key.
|
|
*/
|
|
keyRotationSuppressed$: Behavior<boolean>;
|
|
|
|
/**
|
|
* Transports the client currently holds a live connection to. The
|
|
* connections themselves stay internal; this is what a developer panel
|
|
* needs.
|
|
*/
|
|
connectedTransports$: Behavior<TransportMetadata[]>;
|
|
|
|
/**
|
|
* Plays the members' audio on this device from now on. Rejects where the
|
|
* browser cannot switch to it. A host that routes audio itself, or leaves
|
|
* the browser's choice, never calls it.
|
|
*/
|
|
setAudioOutputDeviceId(deviceId: string): Promise<void>;
|
|
|
|
/**
|
|
* Sends a short text to every member on the local transport, over a
|
|
* reliable data channel beside the media. A packet holds about 15 KiB;
|
|
* anything larger belongs in a room event. Rejects while the local
|
|
* transport is not connected. Encrypted on the wire like the media, but
|
|
* not end to end with the media key.
|
|
*/
|
|
sendData(topic: string, text: string): Promise<void>;
|
|
/**
|
|
* What remote members sent with `sendData`, on every transport the client
|
|
* is connected to. A message from an identity that is not a member is
|
|
* dropped, so a host only ever hears from attested members.
|
|
*/
|
|
data$: Observable<DataMessage>;
|
|
}
|
|
|
|
/** One `sendData` call as it arrives at a remote member. */
|
|
export interface DataMessage {
|
|
member: RemoteRTCMember;
|
|
topic: string;
|
|
text: string;
|
|
}
|
|
```
|
|
|
|
`state$` is two state machines, one per box in the diagram, combined by one pure
|
|
function. `LocalMembership` knows only the Matrix side: `waitingForTransport`,
|
|
`joining`, `joined`, `reconnecting` with a `MatrixDisconnectReason`, `failed`,
|
|
`left`. The client folds the local media connection in: `joined` with the media
|
|
up is `connected`, a media drop after that is `reconnecting: "media"`, a
|
|
connection error is `failed`. Each step is computed in priority order from the
|
|
previous state, so `left` and `failed` absorb everything after them, and
|
|
`reconnecting` is only ever reached from `connected`; a membership that drops
|
|
before the media ever came up stays `connecting`. One reason, not a set, because
|
|
the links are a chain: without sync the membership is unknowable, and the media
|
|
is worth little while the Matrix side is down, so the first broken link in that
|
|
order is what a host shows and what Element Call's analytics count. A failed
|
|
publication is not in here at all: the member can still receive, so it stays on
|
|
the local member's media.
|
|
|
|
### Members
|
|
|
|
```ts
|
|
/**
|
|
* What a membership says, as hosts read it. The js-sdk `CallMembership` is
|
|
* behind it; anything beyond this is the js-sdk's API, not the SDK's.
|
|
*/
|
|
export type RTCMembership = Pick<
|
|
CallMembership,
|
|
| "userId"
|
|
| "deviceId"
|
|
| "memberId"
|
|
| "rtcBackendIdentity"
|
|
| "application"
|
|
| "applicationData"
|
|
| "getTransport"
|
|
| "transports"
|
|
| "createdTs"
|
|
| "getAbsoluteExpiry"
|
|
>;
|
|
|
|
export interface RTCMember {
|
|
local: boolean;
|
|
/** The membership's `rtcBackendIdentity`, `${userId}:${deviceId}`: a key and a debug label. */
|
|
rtcBackendIdentity: string;
|
|
/**
|
|
* Every identity the member may publish under on its transport, one per
|
|
* authentication flow the transport allows: the hashed identity the
|
|
* MSC4195 client-server API assigns, the legacy one the JWT service's
|
|
* `/sfu/get` assigns, or both for a transport carrying both a `url` and a
|
|
* `livekit_service_url`. The backend matches whichever shows up.
|
|
*/
|
|
backendIdentities: readonly string[];
|
|
userId: string;
|
|
deviceId: string;
|
|
membership$: Behavior<RTCMembership>;
|
|
/**
|
|
* Matrix room state rather than MatrixRTC, but the SDK already holds the
|
|
* room and every consumer wants them next to the membership. Disambiguated
|
|
* among the members of the session.
|
|
*/
|
|
displayName$: Behavior<string>;
|
|
avatarUrl$: Behavior<string | undefined>;
|
|
/** Which transport this member is on; undefined when the membership names none. */
|
|
transport$: Behavior<TransportMetadata | undefined>;
|
|
/**
|
|
* The member's tracks, in publication order, once it has shown up on its
|
|
* transport; null until then ("waiting for media"). An entry stays the same
|
|
* object for as long as the same publication is behind it. Which track is
|
|
* which is in its `source`; `trackBySource$` picks one out. Hand raise and
|
|
* reactions are room events keyed by member and stay with the host for now;
|
|
* they are the obvious next fields here.
|
|
*/
|
|
tracks$: Behavior<(AudioMediaTrack | VideoMediaTrack)[] | null>;
|
|
/** Emits when the SFU reports a key problem for this member. */
|
|
encryptionError$: Observable<EncryptionError>;
|
|
}
|
|
|
|
export interface RemoteRTCMember extends RTCMember {
|
|
local: false;
|
|
}
|
|
|
|
export interface LocalRTCMember extends RTCMember {
|
|
local: true;
|
|
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>;
|
|
/**
|
|
* Publishes a source and resolves with its track once it is in `tracks$`.
|
|
* Before the transport is connected the request is remembered and applied
|
|
* once it is. Rejects where the device could not be used, including the
|
|
* user closing the picker. One publication per source: publishing a source
|
|
* again unmutes it.
|
|
*/
|
|
publish(
|
|
request: PublishRequest,
|
|
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
|
/** Removes one of our tracks; a screen share takes its audio with it. */
|
|
unpublish(id: string): Promise<void>;
|
|
}
|
|
```
|
|
|
|
### Transports
|
|
|
|
```ts
|
|
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
|
|
|
/**
|
|
* One transport advertised in a membership. Transport independent: `type` and
|
|
* `id` are all the SDK needs; `raw` and `resolved$` are there for a
|
|
* backend-specific developer panel and for connection diagnostics. One object
|
|
* per transport, however many memberships name it.
|
|
*/
|
|
export interface TransportMetadata {
|
|
/** `"livekit"` today. */
|
|
type: string;
|
|
/** Stable key, unique per transport in the session: the raw transport, serialised with sorted keys. */
|
|
id: string;
|
|
/** The transport object as it appears in the membership, e.g. `{ type, livekit_service_url }`. */
|
|
raw: Transport;
|
|
/**
|
|
* What the backend had to fetch before it could connect. Undefined until
|
|
* the connection has resolved it, and again after the connection stops or
|
|
* fails. The local transport resolves before the join, since the membership
|
|
* needs it; remote ones when their connection starts.
|
|
*/
|
|
resolved$: Behavior<ResolvedTransport | undefined>;
|
|
}
|
|
|
|
export type ResolvedTransport =
|
|
| {
|
|
type: "livekit";
|
|
/** The SFU websocket url, as opposed to the JWT service url in `raw`. */
|
|
url: string;
|
|
/** A secret: fit for a developer panel, never for a log line. */
|
|
token: string;
|
|
roomAlias: string;
|
|
identity: string;
|
|
}
|
|
| { type: string; [key: string]: unknown };
|
|
```
|
|
|
|
### Call-specific things kept out of the client
|
|
|
|
Checked against `CallViewModel` and its options. These stay in Element Call because
|
|
they only make sense for a call:
|
|
|
|
| Today | Why it is call-specific | Where it lives instead |
|
|
| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
| `autoLeave$` / `AutoLeaveReason` (`"allOthersLeft" \| "timeout" \| "decline"`) | hanging up because the other side declined or everyone left is call etiquette, not session semantics | `CallViewModel.leave$`, built from `remoteMembers$` and the room timeline |
|
|
| `ringAttempts$`, `createCallNotificationLifecycle$`, `createSentCallNotification$`, `createReceivedDecline$` | ringing, pickup timeouts and decline events are MSC4075 call-notify UX | `src/state/CallViewModel/CallNotificationLifecycle.ts`, fed by `remoteMembers$` and the Matrix room |
|
|
| `waitForCallPickup`, `autoLeaveWhenOthersLeft` options | inputs to the above | `CallViewModelOptions` |
|
|
| `hideScreensharing` option | a UI policy; the session can always share a screen if the platform can | `CallViewModel` offers no screen share control |
|
|
| `ringingVm$`, `ringingStatusLocation` | view models | `CallViewModel` |
|
|
| `hostBridge.hangUp$`, `hangup()`, `userHangup$` | widget / host integration | `CallViewModel` |
|
|
| `ElementCallError` | carries Element Call translation keys | the view model wraps `MatrixRTCError`, reading `cause` |
|
|
| `participantCount$` sound limits, join/leave sounds | call UX | `CallViewModel` |
|
|
| `callIntent` | what a membership says about the call | `applicationData["m.call.intent"]`, set by the host |
|
|
|
|
Kept despite the name: `sendNotificationType`, because the js-sdk join sends the
|
|
notification as part of entering the session. Only _reacting_ to it is
|
|
call-specific.
|
|
|
|
## Media backend: `MediaBackend`
|
|
|
|
The client speaks MatrixRTC: memberships, the transports named in them, the
|
|
delayed leave, the keys. What carries the media is a backend behind one
|
|
interface, `MediaBackend` in `sdk/src/media-backend/api.ts`, created once per client.
|
|
LiveKit under `sdk/src/media-backend/livekit/` is the only backend today, and the only
|
|
place in the SDK that imports `livekit-client`; the import boundary lint rule
|
|
enforces that outside `sdk/src/media-backend/` the LiveKit packages appear in type
|
|
imports only.
|
|
|
|
The seam sits above the connection manager on purpose. One connection per
|
|
transport url, with a subscribe-only connection to every remote SFU, is the
|
|
LiveKit multi-SFU strategy, not a MatrixRTC concept: a cascading SFU keeps the
|
|
membership transport as it is and only changes which SFUs the client connects to,
|
|
and full mesh has one peer connection per member and no token service. So a
|
|
backend is handed the whole session's view and gives back media:
|
|
|
|
```ts
|
|
/** The fields of a member the backend supplies, as the member exposes them. */
|
|
type MemberMediaFields = Pick<RTCMember, "tracks$" | "encryptionError$">;
|
|
|
|
export interface MediaBackend {
|
|
readonly transportType: string; // "livekit"
|
|
/** Authenticate with the transport the client found; the probe for delegation runs here. */
|
|
prepareLocalTransport(
|
|
transport: Transport,
|
|
): Promise<{ canDelegateDelayedLeave: boolean }>;
|
|
/** Hand over the delayed leave, each time the membership manager has a new delay id. */
|
|
delegateDelayedLeave(delayId: string): Promise<void>;
|
|
readonly local: LocalMediaBackend; // tracks$ encryptionError$ connectionState$ setPublishing publishError$ publish unpublish
|
|
/** A remote member's media; also how the backend learns which transports to connect to. */
|
|
mediaFor$(
|
|
scope: ObservableScope,
|
|
membership$: Behavior<RTCMembership>,
|
|
): MemberMediaFields;
|
|
readonly connections$: Behavior<BackendConnection[]>; // transport, state, resolved; diagnostics
|
|
sendData(topic: string, text: string): Promise<void>;
|
|
readonly data$: Observable<DataPacket>;
|
|
}
|
|
```
|
|
|
|
What stays on the client's side: discovery of the local transport from the
|
|
homeserver's list (a homeserver endpoint, so not a backend's) with, for the
|
|
LiveKit backend only, a last resort of another member's SFU that lets us
|
|
publish (`findRemotePublishLivekitTransport`, wired in by `RTCParticipation` rather
|
|
than by the backend interface, since it is an MSC4195 matter), the membership
|
|
state machine in `LocalMembership.ts` and its combination with
|
|
`local.connectionState$` into `state$`, the choice of delayed leave timings,
|
|
mapping a data packet's sender to a member, and the transport registry, which keys on the raw
|
|
transport serialised with sorted keys and derives `connectedTransports$` and each
|
|
`TransportMetadata.resolved$` from `connections$`.
|
|
|
|
The backend's context carries no Matrix client and no membership list. A backend
|
|
that needs the client takes a `Pick` of exactly what it calls in its own options
|
|
(`createLivekitBackend(scope, context, { client, mediaQuality })`),
|
|
and learns of remote members only through `mediaFor$`, registering each
|
|
membership's transport for as long as the member's scope lives. Media keys reach
|
|
it as a stream, `mediaKeys$`, replayed on subscribe; the js-sdk session never
|
|
does. A host supplies its own backend through `MatrixRTCClientOptions.backend`;
|
|
without one, LiveKit is built from the LiveKit-flavoured client options.
|
|
|
|
### Authenticating with a LiveKit transport
|
|
|
|
A LiveKit transport comes in three shapes while MSC4195 settles: an SFU `url`,
|
|
a JWT service `livekit_service_url`, or both. `media-backend/livekit/auth/`
|
|
fronts the two flows and chooses between them per connection, from the shape
|
|
and from the role we take on the transport:
|
|
|
|
| Transport | Publisher | Subscriber |
|
|
| -------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
|
|
| `url` only | client-server API `get_token`, no fallback | client-server API `get_token`, no fallback |
|
|
| `livekit_service_url` only | JWT service `/sfu/get` (legacy identity) | JWT service `/get_token` (hashed identity) |
|
|
| both | JWT service `/sfu/get` (legacy identity) | client-server API, falling back to the JWT service `/get_token` where the homeserver, or a widget host, has no MSC4195 |
|
|
|
|
A publisher on a transport with a service URL keeps the legacy
|
|
`<user_id>:<device_id>` identity, because clients predating MSC4195 match a
|
|
membership against that and nothing else; a subscriber is matched by nobody and
|
|
takes the more private hashed identity. A publisher never falls back: a
|
|
homeserver that advertises a `url` without implementing MSC4195 is misconfigured
|
|
and fails with `NoMatrix2AuthorizationService`, so the admin hears about it.
|
|
The MatrixRTC mode plays no part in the choice. The delayed leave is delegated
|
|
through the same flow that issued the publisher's token, so that the identity
|
|
the service watches for on the SFU is the one we publish under, and the
|
|
delegation probe before the join asks only that service.
|
|
|
|
The client-server API needs the name of the homeserver the transport belongs to:
|
|
our own homeserver asks that server for the token, over federation where it is
|
|
not ours. The backend derives it from the member's user id, and from our own for
|
|
the local transport, and keys its connections on the transport plus the server
|
|
name where the transport has a `url`. A JWT service is reached directly, so
|
|
members of several homeservers advertising the same one share a connection.
|
|
|
|
## Media: `tracks$` and `MediaTrack`
|
|
|
|
Every member has media. Today, in Element Call, that media _is_ a `livekit-client`
|
|
participant, handed straight through to the view models. In the SDK the participant
|
|
becomes the member's `tracks$`, a list of `MediaTrack`s, so that nothing outside
|
|
the SDK imports `Participant`, `TrackPublication`, `Track` or `TrackReference`.
|
|
The adapter in `sdk/src/media-backend/livekit/` is the only place that reads a
|
|
participant. The backend supplies the same two fields through `mediaFor$`, so
|
|
there is one shape from the LiveKit participant to the host and nobody has to
|
|
subscribe through one Behavior to reach another.
|
|
|
|
```ts
|
|
export type MediaSource =
|
|
| "microphone"
|
|
| "camera"
|
|
| "screenShare"
|
|
| "screenShareAudio"
|
|
/** Published without a source; the application knows what it is. */
|
|
| "unknown";
|
|
|
|
export type MediaStreamStats =
|
|
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined;
|
|
|
|
/** One published track of a member. */
|
|
export interface MediaTrack {
|
|
source: MediaSource;
|
|
kind: "audio" | "video";
|
|
/** Stable for the life of the track. */
|
|
id: string;
|
|
muted$: Behavior<boolean>;
|
|
/** False when the SFU reports the track as unencrypted. Fixed for the life of the track. */
|
|
encrypted: boolean;
|
|
/**
|
|
* Inbound statistics for a remote track, outbound for a local one, polled
|
|
* every `intervalMs` while the observable is subscribed and not at all
|
|
* otherwise, so a statistics panel costs nothing while closed.
|
|
*/
|
|
stats$(intervalMs: number): Observable<MediaStreamStats>;
|
|
/**
|
|
* Rendering. The view hands its <video> or <audio> element over; the SDK
|
|
* sets its stream and, for video, registers the size and on-screen
|
|
* observers that pick a simulcast layer and pause the subscription while
|
|
* the element is hidden. attach is idempotent per element; detach has to be
|
|
* called before the element leaves the DOM so those observers are released.
|
|
*/
|
|
attach(element: HTMLMediaElement): void;
|
|
detach(element: HTMLMediaElement): void;
|
|
}
|
|
|
|
export interface AudioMediaTrack extends MediaTrack {
|
|
kind: "audio";
|
|
/**
|
|
* Whether the track carries sound right now, as the backend measures it.
|
|
* False while muted. A call reads this on the microphone track and calls it
|
|
* "speaking". LiveKit measures it per member, so every audio track of a
|
|
* member reports the same value.
|
|
*/
|
|
isActive$: Behavior<boolean>;
|
|
/**
|
|
* How loud the track is right now, 0 to 1, as the backend measures it. 0
|
|
* while muted. Not what decides `isActive$`: the backend applies its own
|
|
* threshold and hysteresis to that.
|
|
*/
|
|
audioLevel$: Behavior<number>;
|
|
/** Route playback through Web Audio, for earpiece pan and gain. Undefined resets. */
|
|
setAudioContext(ctx: AudioContext | undefined, plugins?: AudioNode[]): void;
|
|
/** No-op for our own audio, which is never played back. */
|
|
setVolume(volume: number): void;
|
|
}
|
|
|
|
export interface VideoMediaTrack extends MediaTrack {
|
|
kind: "video";
|
|
}
|
|
|
|
/** The controls a member has over a track it publishes itself. */
|
|
export interface LocalMediaTrack {
|
|
/** Mutes or unmutes. Resolves with the state that resulted. */
|
|
setEnabled(enabled: boolean): Promise<boolean>;
|
|
/** Captures from another device. Rejects for a screen share, which has none. */
|
|
setDevice(deviceId: string): Promise<void>;
|
|
}
|
|
|
|
export interface LocalAudioMediaTrack
|
|
extends AudioMediaTrack, LocalMediaTrack {}
|
|
|
|
export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
|
|
/** For mirroring; undefined where the camera does not say which way it faces. */
|
|
facingMode$: Behavior<"user" | "environment" | undefined>;
|
|
/** Restarts the camera facing the other way; resolves with the device now in use. */
|
|
switchFacingMode(): Promise<string | undefined>;
|
|
/** Background blur and the like; undefined removes the processor. */
|
|
setProcessor(processor: VideoProcessor | undefined): Promise<void>;
|
|
}
|
|
|
|
export type EncryptionError = "MissingKey" | "InvalidKey";
|
|
|
|
/** The member's first track of a source; undefined while there is none, or no media yet. */
|
|
export function trackBySource$<S extends MediaSource>(
|
|
scope: ObservableScope,
|
|
tracks$: Behavior<MediaTrack[] | null>,
|
|
source: S,
|
|
): Behavior<TrackOfSource<S> | undefined>;
|
|
```
|
|
|
|
### Rendering: `attach` and `detach`
|
|
|
|
The view passes its DOM element into the SDK and the SDK does the rest; there is no
|
|
`MediaStreamTrack` on the interface. The reason is adaptive stream: Element Call
|
|
runs with `adaptiveStream: true`, which means LiveKit decides the simulcast layer
|
|
from the size of the attached video element and pauses the subscription at the
|
|
SFU while no attached element is on screen. Those size and visibility observers are
|
|
set up inside LiveKit's `attach`. Handing the element over is the only way to keep
|
|
that without reimplementing the observers outside the SDK. The e2e tests check both
|
|
effects: a larger tile receives a higher resolution, a hidden tile stops receiving
|
|
frames.
|
|
|
|
What `attach` covers for free, so the host does not have to: `srcObject` and
|
|
`autoplay` setup with the Safari and Firefox black-video workarounds; `play()` and
|
|
the autoplay-blocked signal; following the track through mute, reconnect and
|
|
re-subscription into every attached element; the Web Audio graph for earpiece pan
|
|
and gain; the adaptive stream observers.
|
|
|
|
The interface is web-only. A non-DOM host would need a different media surface; if
|
|
one shows up, a `MediaStreamTrack` accessor is added next to `attach`, not instead
|
|
of it.
|
|
|
|
## Packaging
|
|
|
|
`sdk/` is packaged exactly as `component/` is: a `package.json` with peer
|
|
dependencies (`matrix-js-sdk`, `livekit-client`, `rxjs`), `exports` with types,
|
|
type emission through `sdk/tsconfig.build.json`, an externals list in
|
|
`vite-sdk.config.ts` that `pnpm lint:externals` enforces, and installation from the
|
|
repository as a git dependency with a `path:` suffix. `pnpm build:sdk` produces
|
|
`sdk/dist`. It is not a pnpm workspace package yet; the root is not a workspace, and
|
|
moving to one later carries the package name, entry point and externals over
|
|
unchanged.
|
|
|
|
Element Call consumes the SDK by its package name from day one, so the swap to a
|
|
published dependency is one line: `tsconfig.json` `paths` and the `resolve.alias`
|
|
in `vitePluginsConfig` both map `@element-hq/matrixrtc-sdk` to `sdk/index.ts`. The
|
|
alias has to live in `vitePluginsConfig`, which every config is built on. Put only
|
|
into the app's own config, the harness resolves the name through `sdk/package.json`
|
|
to whatever stale `sdk/dist` is lying around.
|
|
|
|
Layout:
|
|
|
|
```
|
|
sdk/
|
|
index.ts the entry point: re-exports the API and the primitives
|
|
src/api.ts the public types above: client, members, transports
|
|
src/media-api.ts the public media types: publish requests, tracks
|
|
src/errors.ts MatrixRTCError and its codes
|
|
src/config.ts MatrixRTCMode, session timings, media quality
|
|
src/encryption.ts E2eeType, EncryptionSystem
|
|
src/MatrixRTCClient.ts createMatrixRTCClient: wires the two folders below
|
|
src/participationState.ts the membership state combined with the media connection
|
|
src/reactive/ Behavior, ObservableScope, the observable operators
|
|
src/matrixrtc/ the MatrixRTC side: the local membership, memberships, member
|
|
metadata, transports, discovery, the js-sdk join
|
|
src/media-backend/ the MediaBackend interface in api.ts; livekit/ is the one
|
|
backend: connections, publisher, JWT, keys, the tracks
|
|
src/utils/ mapScoped, network retry, display names, test helpers
|
|
dev/ the harness (below)
|
|
SdkArchitecture.md this document
|
|
SdkMigration.md what moved from Element Call's src/ into the SDK
|
|
```
|
|
|
|
The lint rule `element-call/sdk-import-boundary` keeps it that way: nothing under
|
|
`sdk/` may import a file outside `sdk/` (that is `src/`, `component/`,
|
|
`playwright/`) or React, i18n or Compound. The harness under `sdk/dev/` is a host
|
|
and exempt.
|
|
|
|
## Dev harness: `sdk/dev/`
|
|
|
|
A web app that uses the SDK and nothing else: plain DOM, no React, no Compound, no
|
|
i18n, imports only `@element-hq/matrixrtc-sdk`, `matrix-js-sdk` and `rxjs`. It is
|
|
where the SDK is run during development and what the e2e tests in `playwright/sdk`
|
|
drive. If something cannot be done in the harness without reaching into `src/`, the
|
|
SDK API is missing something.
|
|
|
|
What it does: log in with a password, or with an access token the URL hands it (the
|
|
tests register users through the admin API and skip `/login`, which the dev
|
|
homeserver rate-limits); join the room and wait for the sync to hold it; create the
|
|
client with microphone and camera enabled; show one tile per member with the display
|
|
name, a `<video>` attached from the member's `"camera"` track and, for remote
|
|
members, an `<audio>` from the `"microphone"` track, both picked out of
|
|
`tracks$` with `trackBySource$`; toggle the microphone and camera; leave.
|
|
|
|
Every media element carries the track's state as `data-*` attributes (`muted`,
|
|
`encrypted`, `active`, `frameWidth`, `frames`), which is what the tests read to
|
|
check mute propagation, encryption, adaptive resolution and paused subscriptions
|
|
(the speaker detection check is a `fixme`: the fake microphones do not reliably
|
|
register at the SFU). That is more
|
|
than the smallest consumer needs, and the price of testing the SDK through its
|
|
public API only.
|
|
|
|
## Open decisions
|
|
|
|
- **Publishing before `join()`.** The initial requests are published at the
|
|
join; creating the tracks earlier, for a lobby preview, is still missing.
|
|
- **`applicationData` after the join.** The option is read once; there is no
|
|
way to update it on a live membership yet.
|
|
- **Raw membership state.** The Matrix side's own state machine
|
|
(`MembershipState` in `LocalMembership.ts`) stays internal; `state$` on the
|
|
participation is its public view combined with the media connection. A
|
|
developer panel may want it as `LocalRTCMember.membershipState$`.
|
|
- **Developer panel.** `resolved$` on each connected transport covers what the
|
|
panel shows today. Anything beyond that (LiveKit room state, connection
|
|
quality) needs an opaque `debug$` per transport.
|
|
- **Data beside the media.** `sendData`/`data$` ride on LiveKit's reliable data
|
|
packets: topic-agnostic, matched to members by identity, received on every
|
|
connection but sent only on the local transport, so a member on another
|
|
transport does not hear us. They are transport-encrypted, not end to end:
|
|
LiveKit can encrypt data packets with the media key, but that is not wired
|
|
through the key provider yet. A host that needs either reach or end-to-end
|
|
encryption uses room events.
|
|
- **Unencrypted rooms with per-participant keys.** Keys only reach devices the
|
|
crypto tracks, which are the members of encrypted rooms. If the SDK is to work
|
|
in an unencrypted room, tracking the members' devices becomes its job.
|