34 KiB
Media backend: trapping LiveKit behind one seam
The SDK's public API in sdk/src/api.ts names LiveKit in three places: the video
processor type, the video codec in the capture settings, and the LiveKit-flavoured
client options (mediaQuality, capture, transportUrl, fallbackTransportUrl).
The implementation is a different story. LiveKit participants, rooms, connections,
tokens and data packets run through most of sdk/src/session/. Only the two
adapters in sdk/src/media/ are the clean boundary they were meant to be.
MatrixRTC will get media backends other than a LiveKit SFU, full mesh and a cascading SFU among them. Neither fits the model the session is written against today, one connection per transport URL with a subscribe-only connection to every remote SFU. That model is the LiveKit multi-SFU strategy, so the seam has to sit above it. Cascading keeps the membership transport exactly as it is and only changes which SFUs the client connects to, which makes the connection manager a LiveKit-internal policy. Full mesh has one peer connection per remote member and no token service at all.
This plan introduces one interface, MediaBackend, created once per session. It
takes the memberships and the local media inputs and hands back the media the
session consumes, plus the three things the MatrixRTC join needs from the media
side: the transport to advertise, whether the backend can take over the delayed
leave, and the handover of the delayed leave once its id is known. Everything
LiveKit moves into sdk/src/backend/livekit/ behind it. The public API does not
change, behaviour does not change, and the playwright/sdk specs verify every step.
This is slice 12 in the status table of SdkMigration.md. It
is independent of slices 6 to 11 there, and slice 7 (a neutral processor type) and
slice 11 (the error renames) get easier once the folder exists.
What is LiveKit today
Everything under sdk/src/session/ that imports livekit-client,
@livekit/components-core, or speaks the LiveKit token protocol:
| Module | What it is |
|---|---|
media/LivekitMediaTrack.ts, media/LivekitMemberMedia.ts |
Participant to MemberMedia. Clean already. |
session/Connection.ts, ConnectionFactory.ts, ConnectionManager.ts |
One LiveKit room per transport URL, the multi-SFU policy, the token fetch per connection. |
session/MatrixLivekitMembers.ts |
Matches a participant identity to a membership, epoch-paired. |
session/Publisher.ts |
Local tracks, upstream pausing, device switching, the processor. |
session/KeyProvider.ts, MatrixKeyProvider.ts |
LiveKit's key provider on the js-sdk key stream. |
session/DataChannel.ts |
LiveKit data packets. |
session/openIDSFU.ts, livekitOptions.ts |
The JWT service protocol, including delegation, and room options. |
session/LocalTransport.ts |
The pre-join token fetch. Discovery itself is a homeserver endpoint and stays; see "Discovery stays in the session". |
session/RtcTransportAutoDiscovery.ts |
Generic apart from its isLivekitTransport filter and the fallback URL. Stays. |
session/LocalMember.ts |
participant$, connection$, upstream pausing, screen share, the delegation probe and handover, Track.Source in setEnabled. Roughly 150 of 530 lines. |
session/Members.ts |
mediaFor takes a participant and a room. |
session/Transports.ts |
Transport id, the connected check and resolved$ assume LiveKit. |
session/SessionBehaviors.ts |
membershipsAndTransports$ filters to LiveKit transports. Deleted in step 3, not moved. |
session/MatrixRTCClient.ts |
Wires all of the above. |
utils/test.ts |
mockRemoteParticipant, exampleTransport. |
errors.ts has a type-only import for LivekitConnectionError; that is slice 11's
business and stays. index.ts exports getSFUConfigWithOpenID as
authenticateWithTransport for the developer panel; it keeps being exported, from
the backend folder.
The interface
All of it lives in sdk/src/backend/api.ts. Types not defined here come from
sdk/src/api.ts (MemberMedia, LocalMemberMedia, LocalMediaInputs,
ResolvedTransport), sdk/src/config.ts (SessionTimings),
sdk/src/encryption.ts and matrix-js-sdk (Transport, CallMembership,
CallMembershipIdentityParts). The js-sdk session itself never reaches a
backend: MatrixKeyProvider reads three things from it today, the
EncryptionKeyChanged event, one reemitEncryptionKeys() call to replay the
keys it already holds, and the room id for a log line, and mediaKeys$ covers
all three.
Connection state, backend neutral
Replaces ConnectionState from Connection.ts inside the session. The LiveKit
backend maps its room state onto it; Reconnecting and SignalReconnecting both
become Reconnecting.
export enum MediaConnectionState {
/** No connection attempt yet, or no connection at all. */
Initialized = "initialized",
/** Fetching whatever the connection needs first; the SFU token for LiveKit. */
Preparing = "preparing",
Connecting = "connecting",
Connected = "connected",
Reconnecting = "reconnecting",
Disconnected = "disconnected",
Stopped = "stopped",
}
What the session hands the backend
export interface MediaBackendContext {
roomId: string;
ownMembershipIdentity: CallMembershipIdentityParts;
/** What the local member publishes; the exact type is below. */
localMedia: LocalMediaInputs;
encryptionSystem: EncryptionSystem;
/**
* Every media key the session knows: the current ones replayed on
* subscribe, new ones as they arrive. The backend feeds them into its own
* frame encryption. Empty for a shared key, which is in `encryptionSystem`.
*/
mediaKeys$: Observable<MediaKey>;
/** For the delegated leave's timeout, and whatever else a backend times. */
timings: SessionTimings;
logger: Logger;
}
/** One per-participant media key, as the js-sdk's `EncryptionKeyChanged` carries it. */
export interface MediaKey {
/** The `rtcBackendIdentity` of the member the key belongs to. */
participantId: string;
index: number;
key: Uint8Array;
}
/**
* The type of the `backend` option: a function the session calls once with
* its scope and the context to get the backend. Only an alias, so the option
* and a second backend's module do not spell the signature out. A backend's
* own factory keeps the codebase's shape,
* `createLivekitBackend(scope, context, options): MediaBackend`, and the host
* closes over its options: `(scope, ctx) => createLivekitBackend(scope, ctx,
* { client, tokenEndpoint: "msc4195" })`. No currying, so `createX` returns
* an X here as everywhere else.
*
* The Matrix client is not in the context: a backend that needs it takes it in
* its own options, as a `Pick` of exactly what it calls, so the type says what
* each backend reaches for. LiveKit picks the OpenID token and the base URL;
* a mesh backend would pick to-device sending.
*/
export type MediaBackendFactory = (
scope: ObservableScope,
context: MediaBackendContext,
) => MediaBackend;
LocalMediaInputs is unchanged from sdk/src/api.ts and is the only input the
backend reads about the local devices:
export interface LocalMediaInputs {
/** Initial state; `setMicrophoneEnabled` and `setCameraEnabled` change it from then on. */
microphoneEnabled: boolean;
cameraEnabled: boolean;
audioInputDeviceId$: Behavior<string | undefined>;
videoInputDeviceId$: Behavior<string | undefined>;
/** Undefined where the host routes audio itself, or to leave the browser's choice. */
audioOutputDeviceId$: Behavior<string | undefined>;
/** Background blur and the like. The LiveKit type on the public API; slice 7. */
videoProcessor$: Behavior<TrackProcessor<Track.Kind.Video> | undefined>;
}
The backend
/** What preparing the local transport established; what the MatrixRTC join needs from the media side. */
export interface TransportCapabilities {
/**
* Whether the backend can restart the delayed leave on the client's behalf,
* so that a client that vanishes is removed by the backend rather than by
* the timeout. Decides which delayed leave timings the join uses, and
* whether `delegateDelayedLeave` is ever called.
*/
canDelegateDelayedLeave: boolean;
}
/** One connection a backend holds. */
export interface BackendConnection {
/** The transport as it appears in memberships; what the registry keys on. */
transport: Transport;
state: MediaConnectionState | Error;
/**
* What the backend fetched to connect; undefined until it has, and again
* after the connection stops. One stable object per fetch, so that the
* registry's `distinctUntilChanged` holds across state changes.
*/
resolved: ResolvedTransport | undefined;
}
export interface MediaBackend {
/** The membership transport type this backend serves: "livekit". */
readonly transportType: string;
/**
* Makes `transport` usable as the one the local member publishes on. The
* session found it; the backend does whatever has to succeed before it is
* advertised in the membership. Resolves with what the join needs to know,
* rejects with the error the session reports as fatal. Called once per
* session. For LiveKit: the OpenID and JWT exchange, whose result the
* backend keeps for its own connection, and the delegation probe.
*/
prepareLocalTransport(transport: Transport): Promise<TransportCapabilities>;
/**
* Hands the delayed leave event to the backend, which restarts it from
* then on. Called by the session each time the membership manager reports
* a new delay id, only when `canDelegateDelayedLeave` is true. A rejection
* is logged, not fatal: the client keeps restarting the leave itself.
*/
delegateDelayedLeave(delayId: string): Promise<void>;
/** The local member's side; the exact type is below. */
readonly local: LocalMediaBackend;
/**
* The media of one remote member, null while nothing has arrived for it on
* its transport ("waiting for media"). Built in `scope`, which the caller
* ends with the member. The backend matches on `rtcBackendIdentity` and the
* membership's transport, both read from `membership$`.
*
* This is also how the backend learns which remote transports exist: it
* registers the membership's transport for as long as `scope` lives, and
* connects to the union of registered transports plus the local one. No
* membership list reaches the backend; the session decides which members
* exist and asks for their media.
*/
mediaFor$(
scope: ObservableScope,
membership$: Behavior<CallMembership>,
): Behavior<MemberMedia | null>;
/**
* Every connection the backend holds, for diagnostics. The session's
* transport registry derives `connectedTransports$` from the entries in
* `Connected` state, and each `TransportMetadata.resolved$` from the entry
* whose transport has the same canonical id, with `distinctUntilChanged`.
*/
readonly connections$: Behavior<BackendConnection[]>;
/**
* A text channel beside the media. Sent on the local transport, rejected
* while it is not connected; received on every transport. The session maps
* `senderId` to a member and drops what no member sent.
*/
sendData(topic: string, text: string): Promise<void>;
readonly data$: Observable<{ senderId: string; topic: string; text: string }>;
}
There is no transportId on the backend. The session's transport registry keys
on a canonical serialisation of the raw transport object, sorted keys. Transports
are plain JSON out of membership events, so identical transports serialise
identically, and a mesh transport carrying per-member data is correctly distinct.
The local side
This is what createLocalMembership$ in session/LocalMember.ts reads instead of
participant$, connection$, connection.livekitRoom and the publisher. The
join state machine, the Matrix join and leave, the error aggregation, the delay
id signal and the screen share error handling stay in the session; the LiveKit
calls and the per-connection publisher lifecycle move.
export interface LocalMediaBackend {
/**
* State of the connection the local member publishes on. `Initialized`
* while there is none. An `Error` is the connection's failure.
*/
readonly connectionState$: Behavior<MediaConnectionState | Error>;
/** The local member's own media. Null until the connection has a local participant. */
readonly media$: Behavior<LocalMemberMedia | null>;
/**
* Whether the local tracks reach the transport. The session sets it to
* "joined and the homeserver is reachable", so that a member who has left,
* or who may already have been dropped from the session, is not heard.
* Remembered across connections and applied to each new one: the tracks
* are created on the first `true`, and their upstream is paused while
* `false`, so the local preview stays live while nothing is heard.
*/
setPublishing(publish: boolean): void;
/** The first failure to publish, kept until the session ends. Not fatal. */
readonly publishError$: Behavior<Error | null>;
/**
* Enables or disables a source and resolves with what resulted, which
* differs from the request where the device could not be used. Remembered
* across connections, so a request before the transport is up is applied
* when the tracks are created. Today's `DesiredMedia` lives behind this.
*/
setEnabled(source: "microphone" | "camera", enabled: boolean): Promise<boolean>;
readonly sharingScreen$: Behavior<boolean>;
/**
* Rejects with the browser's `DOMException` when the user closes the
* picker; the session filters that out before showing an error.
*/
setScreenShareEnabled(enabled: boolean): Promise<void>;
}
There is one publish switch, not two. Today LocalMember.ts pauses the
upstream from two places: stopPublishing() on leave, and a separate
subscription on the homeserver connectivity that pauses every publication while
the sync is down and resumes every publication when it is back. Both are the
same LiveKit operation, and the second does not look at the join intent: after
leave() the tracks still exist with their upstream paused, so a sync hiccup
resumes them and a member who has left is heard again. The session computes
the one boolean, joined and reachable, and the backend applies it. The cost is
that the tracks are now created on the first true, so a join while the sync is
down waits for the sync before asking for devices. A join cannot send its
membership without the sync either, and pre-join track creation is slice 6's.
There is no publisher on the interface. Today's Publisher wraps one LiveKit
room's local participant and so lives and dies with the local connection, and
LocalMember.ts re-applies the join intent to each new one. That lifecycle is a
LiveKit detail: the backend keeps the publish intent, as it keeps the desired
media for setEnabled, and re-applies both to every connection it opens. A
mesh backend has no publisher object at all.
Whether the platform can share a screen at all stays in the session: it is a
getDisplayMedia check, the same for every backend.
Delayed leave delegation
How it works today, all in LocalMember.ts and openIDSFU.ts:
- The js-sdk membership manager sends the membership and a delayed leave event,
and emits the leave's delay id (
MembershipManagerEvent.DelayIdChanged), again whenever it has to send a new one.MatrixRTCClient.tsturns that intodelayId$. - When the local transport is known, the session probes two MSC4195 endpoints
without credentials: the homeserver's
/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leaveand the transport's<livekit_service_url>/delegate_delayed_leave. A 404 means no support. Either one supporting it is enough. - The join uses
timings.delegatedDelayedLeaveif supported, elsetimings.delayedLeave. The difference is an hour versus eighteen seconds of delay, because a delegated leave is restarted by the SFU and the client stops restarting it. - On every delay id, if supported, the session calls the token service again with
delay_id,delay_timeoutand the homeserver's URL. The token it returns is discarded; the request is the handover. Failure is logged and the client keeps restarting the leave itself.
Steps 1 and 3 are MatrixRTC and stay in the session. Steps 2 and 4 are the LiveKit token protocol and move behind the interface, as two members rather than one:
- The probe goes into
prepareLocalTransport, reported ascanDelegateDelayedLeave. It needs the transport's URL and the homeserver's, both of which the backend has at that moment, and the join needs its answer before it fires. The backend runs the probe concurrently with the token fetch, as today. - The handover is
delegateDelayedLeave(delayId), because the delay id arrives after the join and changes over the session's life. The session owns that signal and drives the method from it.
The session side after the change:
// discoverLocalTransport is the session's; see "Discovery stays in the session"
const prepared$ = from(
discoverLocalTransport(client, backend.transportType, options, logger).then(
async (transport) => ({
transport,
...(await backend.prepareLocalTransport(transport)),
}),
),
); // errors become fatalTransportError$
scope.reconcile(combineLatest([prepared$, joinRequested$]), ([prepared, join]) => {
if (prepared === null || !join) return;
joinMatrixRTC(
prepared.transport,
prepared.canDelegateDelayedLeave ? timings.delegatedDelayedLeave : timings.delayedLeave,
);
return leaveOnCleanup;
});
scope.reconcile(combineLatest([prepared$, delayId$]), async ([prepared, delayId]) => {
if (!prepared?.canDelegateDelayedLeave || delayId === null) return;
try {
await backend.delegateDelayedLeave(delayId);
} catch (e) {
logger.error("Failed to delegate the leave", e);
}
});
A mesh backend answers canDelegateDelayedLeave: false and is never asked to
delegate. Should a backend later delegate to something other than a token
service, it does so behind the same method.
Lifetimes: what ends what
mediaFor$ is bound to the scope it is given, not to its subscribers.
scope.behavior subscribes eagerly and tears down with the scope, so the
behavior and the transport registration live exactly as long as the member
does, whether or not a view reads them. Lazy registration on first subscribe
would be wrong: the audio renderer needs the microphone track of a member
nobody has a tile for, and that member's transport would never connect.
- A remote member leaves.
generateItemsends the member's scope. From that one scope themapScopeditem ends and detaches theMemberMedia, the behavior completes, and theonEndcallback releases the transport. If the count for that transport hits zero the connection manager drops it, and the connection's own scope ending callsstop(), as today. - A member changes transport. Registration follows
membership$withdistinctUntilChangedon the transport id: release the old, register the new, inside the same scope. - The local member calls
leave(). Nothing changes on the remote side. The memberships are still in the room, so the members still exist and the connections stay open. That is today's behaviour too: the connection manager never looked atjoinRequested$. - The network drops. The member still exists, so
mediaFor$is live. The connection reports reconnecting, its participant list empties, the participant behind the member becomes null, the media item ends andmedia$reads null, "waiting for media". A newMemberMediais built on reconnect. As today. - The session scope ends. The backend and every member scope end in the
same tick, in subscription order, so the backend's internals tear down
before the members' release callbacks run. Release must therefore do
nothing but update the registry map and
nexta subject, both no-ops with nobody listening. It must never stop a connection or touch LiveKit itself; the connection scopes do that.
Where the LiveKit backend gets each member from
| Interface member | Today | Moves from |
|---|---|---|
prepareLocalTransport |
OpenID and JWT fetch kept as existingSFUConfig, delegation probe |
LocalTransport.ts, LocalMember.ts |
delegateDelayedLeave |
getSFUConfigWithOpenID with delayId |
LocalMember.ts |
local.connectionState$ |
connection$.state$ for the local transport |
LocalMember.ts |
local.media$ |
mediaFor(participant$, connection$) with createLocalLivekitMemberMedia |
Members.ts |
local.setPublishing, publishError$ |
scope.reconcile(connection$, createPublisher), the [publisher$, joinRequested$] reconcile with its publishError$, and the homeserver-connectivity loop over participant.trackPublications |
LocalMember.ts, MatrixRTCClient.ts |
local.setEnabled |
DesiredMedia plus Publisher.setEnabled(Track.Source, …) |
LocalMember.ts, Publisher.ts |
local.sharingScreen$, setScreenShareEnabled |
observeSharingScreen$, setScreenShareEnabled with capture and publish options |
LocalMember.ts |
mediaFor$ |
createRemoteMatrixLivekitMembers$ matching plus mediaFor |
Members.ts, MatrixLivekitMembers.ts |
connections$ |
connectionManagerData$ joined with each connection's state$ and ResolvedConnection.resolved$ |
Transports.ts, ConnectionFactory.ts |
sendData, data$ |
createDataChannel$ minus the member lookup |
DataChannel.ts |
| internal | key provider on mediaKeys$ instead of setRTCSession, room options, E2EE worker, connection manager fed by the transports registered through mediaFor$ |
KeyProvider.ts, MatrixKeyProvider.ts, livekitOptions.ts, ConnectionFactory.ts, ConnectionManager.ts |
Discovery stays in the session
Which transport the local member advertises is answered by the homeserver's
MSC4143 transport list, and that list can name transports of any type. So
discovery is the session's, in session/LocalTransport.ts, and a backend is
only asked to prepare what the session picked:
/** In session/LocalTransport.ts. The homeserver's list, retried, filtered to what the backend serves. */
export async function discoverLocalTransport(
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
transportType: string,
options: Pick<MatrixRTCClientOptions, "transportUrl" | "fallbackTransportUrl">,
logger: Logger,
): Promise<Transport>; // throws MatrixRTCTransportMissingError
RtcTransportAutoDiscovery does the list fetch today and stays where it is,
with its isLivekitTransport filter replaced by a filter on transportType.
The two URL options are the awkward part. transportUrl and
fallbackTransportUrl are LiveKit service URLs, and turning a URL into a
membership transport means building { type: "livekit", livekit_service_url }.
That is LiveKit knowledge, but it is a workaround for hosts that configure a URL
rather than a transport, and it lives in discoverLocalTransport, not in
backend/livekit/. The backend folder never learns where its transport came
from. It is one Transport literal and the js-sdk's LivekitTransport type,
no livekit-client import, so the lint guard of step 6 is not affected. The
clean follow-up, outside this plan, is transport?: Transport and
fallbackTransport?: Transport on the client options, with the host building
the literal; then the workaround is deleted.
LiveKit's own options
mediaQuality and capture on MatrixRTCClientOptions configure LiveKit, not
MatrixRTC. They become LivekitBackendOptions, the third parameter of
createLivekitBackend(scope, context, options), together with the slice of the
Matrix client the backend calls. They also stay on MatrixRTCClientOptions, where they feed the
default backend when the host passes no backend, so Element Call's
InCallView does not change. A host that supplies its own backend configures
it itself, client included. transportUrl and fallbackTransportUrl are
discovery inputs and stay on the client options for good, as above.
export interface LivekitBackendOptions {
/**
* `getOpenIdToken` for the token exchange, `baseUrl` for the delegation
* probe and handover. Nothing else: discovery is the session's. This is
* today's `OpenIDClientParts` from `openIDSFU.ts` minus its unused
* `getDeviceId`, plus `baseUrl`.
*/
client: Pick<MatrixClient, "getOpenIdToken" | "baseUrl">;
mediaQuality?: MediaQuality;
capture?: CaptureSettings;
/**
* Which token service endpoint to use for the local transport: the MSC4195
* one, or the legacy one with the old identity format. Remote transports
* always try both. The default backend derives it from the client's
* `matrixRTCMode`; the mode itself never reaches a backend, since what it
* also decides, sticky events and the member id format, is the session's.
*/
tokenEndpoint: "msc4195" | "legacy";
}
Today openIDSFU.ts takes the MatrixRTCMode itself and reads the endpoint
choice off it. Step 5 changes it to take tokenEndpoint, with the mapping
Matrix_2_0 to msc4195 and Compatibility to legacy done once where the
default backend is built, in MatrixRTCClient.ts, which also hands the
client through. The exported authenticateWithTransport keeps its signature by
doing the same mapping.
Transition
Six steps, each green on its own and each a PR of under 400 lines. There is one connection manager at every point: the backend owns it from step 2 on, and the consumers not yet switched read it through a transitional field until step 4 removes it. Running the old wiring and the backend side by side would open a second connection to every SFU, so the plan never does that.
Step 1: carve the folder
Pure move, no code change beyond import paths.
git mvintosdk/src/backend/livekit/:media/*,Connection,ConnectionFactory,ConnectionManager,DataChannel,KeyProvider,MatrixKeyProvider,MatrixLivekitMembers,Publisher,livekitOptions,openIDSFU, each with its test.RtcTransportAutoDiscoveryandLocalTransportstay insession/.- Split
utils/test.ts:mockRemoteParticipantandexampleTransportgo tobackend/livekit/test.ts. index.tsre-exportsauthenticateWithTransportfrom the new path.
Check: pnpm lint, pnpm test, the playwright/sdk specs.
Step 2: the interface, the backend shell, the registry, the data channel
- Add
sdk/src/backend/api.tswith the types above. - Add
sdk/src/backend/livekit/LivekitBackend.tsexportingcreateLivekitBackend(scope, context, options): MediaBackend. It owns whatMatrixRTCClient.tsbuilds today: the key provider, theLivekitConnectionFactoryand the connection manager. Until step 3 the connection manager is still fed frommembershipsAndTransports$, passed in as a transitionalremoteTransports$input beside the transitionalconnectionManagerfield; both go in step 3. It implementstransportType,connections$,sendDataanddata$.ResolvedConnectionkeeps oneResolvedTransportobject per fetch instead of rebuilding it on every state change. MatrixRTCClient.tscreates the backend, takes the connection manager from it, and mapsdata$senders to members itself. It buildsmediaKeys$from the js-sdk session:fromEvent(EncryptionKeyChanged)with areemitEncryptionKeys()call on subscribe.MatrixKeyProvidersubscribes to that instead of taking the session; its test feeds aSubject.Transports.tsderives bothconnected$and eachTransportMetadata.resolved$frombackend.connections$, keys the registry on the canonical JSON of the raw transport, and losesResolvedConnection,isLivekitTransportandConnectionState.
New unit test: LivekitBackend.test.ts with a fake LiveKit room through the
existing livekitRoomFactory seam, covering connections$ through a connect,
a reconnect and a stop, and the data channel.
Step 3: remote media
- Implement
mediaFor$in the LiveKit backend. It registers the membership's transport, followingmembership$and released whenscopeends; looks the transport and identity up inconnectionManagerData$;distinctUntilChangedon the participant and room pair; thenmapScopedintocreateLivekitMemberMedia. The "participant matched or missing" log line moves here. - The connection manager's
remoteTransports$becomes the registered set: aMapof transport id to count, exposed as a behavior of its keys, withmembershipsAndTransports$'s LiveKit filter and dedup applied there. The transitional input from step 2 goes. Members.tsdropsmediaForand the participant and room parameters;createRemoteRTCMembertakesbackend.mediaFor$(scope, membership$).MatrixRTCClient.tsgeneratesremoteMembers$straight frommemberships$minus the local one, keyed bymembershipKeys, and stops usingcreateRemoteMatrixLivekitMembers$.membershipsAndTransports$is deleted fromSessionBehaviors.ts.
The epoch pairing that createRemoteMatrixLivekitMembers$ does today is not
needed any more. It existed to keep a membership list and the connection data
from different ticks apart; now a member's media depends on its own membership
only, and the connections exist because members asked for them. A connection
that does not exist yet reads as null, which is "waiting for media" and correct.
What has to hold is that MemberMedia instances stay stable while the
participant and room do not change, which the distinctUntilChanged above
guarantees, and that a transport stays registered while any member names it,
which the count does. media.spec.ts is the check that nothing flickers.
New unit test in LivekitBackend.test.ts: two members on one remote transport
open one connection, it survives the first member's scope ending, and closes
with the second's.
Step 4: local media
- Implement
localin the LiveKit backend:connectionState$from the local transport's connection mapped ontoMediaConnectionState,media$throughcreateLocalLivekitMemberMedia,setPublishingandpublishError$by reconciling aPublisherper connection against the remembered intent, which is the two reconciles fromLocalMember.tsmoved as they are,setEnabledover a backend-ownedDesiredMediaand the current publisher,sharingScreen$andsetScreenShareEnabledwith today's capture and publish options. The homeserver-connectivity loop is not moved;setPublishingcovers it. LocalMember.tstakesbackend.localinstead ofconnectionManager,createPublisheranddesired. Its publisher reconcile and the pause and resume loop become one subscription,joinRequested$ && homeserverConnected.combined$intobackend.local.setPublishing, andbackend.local.publishError$feedsstate$where the local subject did. It losesparticipant$,connection$, thelivekitRoomaccess, the pause and resume loop,Track.Source,ScreenShareCaptureOptionsandTrackPublishOptions, and compares againstMediaConnectionState.ConnectedinmediaState$anddisconnectReason$.LocalMembershipno longer exposesparticipant$orconnection$.MatrixRTCClient.tsstops passingcreatePublisher,desiredand the connection manager. The transitional field goes.status.test.tsmoves toMediaConnectionState.
New unit test: LocalMember.test.ts against a fake LocalMediaBackend. This is
the first time the local state machine is testable without LiveKit; cover join
before the connection is up, connection loss while publishing, a sync outage
after leave() leaving setPublishing at false, and a rejected
local transport.
Step 5: the local transport and the delegation
session/LocalTransport.tsbecomesdiscoverLocalTransportas above: the URL override, elseRtcTransportAutoDiscoveryfiltered on the backend's type, else the fallback URL. The token fetch leaves it, and so does theLocalTransportpair type with itssfuConfig.RtcTransportAutoDiscoverytakes atransportTypeand returns aTransport; itsisLivekitTransportfilter goes.- Implement
prepareLocalTransportin the LiveKit backend from the token fetch ingetLocalTransportand the twocheckDelegationSupportcalls, the probe running concurrently with the token fetch. The SFU config is kept for the local connection, keyed by service URL, replacingexistingSFUConfigflowing throughLocalTransport. - Implement
delegateDelayedLeavefrom the handoverreconcileinLocalMember.ts. MatrixRTCClient.tschains discovery and preparation intoprepared$as sketched above.LocalMember.tstakesprepared$instead oflocalTransport$, dropsjoinParams$,checkDelegationSupport,client,roomId,ownMembershipIdentityandmatrixRTCModefrom its props, and drivesbackend.delegateDelayedLeavefromdelayId$.joinJsSdkSessiontakes aTransport.encryptMediaderives fromencryptionSystem.kind, not from whether a key provider exists.
New unit tests: in LivekitBackend.test.ts, the probe outcomes (homeserver
supports it, only the transport does, neither, probe throws) and the handover
request body; in LocalMember.test.ts, that the join picks the delegated timings
and that a new delay id triggers a handover.
Step 6: the backend option and the guard
MatrixRTCClientOptions.backend?: MediaBackendFactory. When absent,MatrixRTCClient.tssupplies(scope, ctx) => createLivekitBackend(scope, ctx, livekitOptions), withlivekitOptionsbuilt from the client and the LiveKit options on the client options.- Extend
eslint/SdkImportBoundary.js: undersdk/src/session/, insdk/src/api.tsand insdk/index.ts,livekit-clientand@livekit/*are banned except asimport type. From here on the seam is enforced, not documented.oxlintcurrently reports no files undersdk/, so that has to be fixed first for the rule to bite at all. SdkArchitecture.mdgets a "Media backend" section built from this plan, the README's description of what imports LiveKit is updated, andSdkMigration.mdrecords the slice as done.
After this
- Full mesh or a cascading SFU is a new folder under
sdk/src/backend/with its owncreateMeshBackend(scope, context, options), handed in as aMediaBackendFactory; nothing insession/changes. - A member whose transport type has no backend gets null media. That is what
happens today through the
isLivekitTransportfilter; now it is explicit. - Slice 7, the neutral processor type, and slice 11, the error renames, each touch one folder.
- Several backends at once, grouping memberships by transport type, fits the interface but is out of scope until a second backend exists.