Fall back to publishing on a remote SFU if possible

Port of edf1feae from johannes/rtc-remote-publish onto the SDK split,
where
the MatrixRTC logic lives in sdk/ rather than src/state/CallViewModel/.

Where the original lands:

- getLocalTransport's remote fallback: the LiveKit backend's new
  findRemotePublishTransport (sdk/src/media-backend/livekit/
  RemotePublishTransport.ts), which tries the candidates from the
  js-sdk's
  getRemoteLivekitPublishCandidates oldest first and keeps the first
  whose
  homeserver issues a token with the publish grant.
  discoverLocalTransport
  asks the backend for it after the host's fallback, with a snapshot of
  the
  session's memberships from RTCParticipation, and throws
  MatrixRTCTransportMissingError where that finds nothing too. Transport
  discovery stays backend neutral: the MSC4195 token check is the
  backend's.
- LocalMember's "no delegation on a remote SFU" and the custom
  transport's
  server_name: LivekitBackend.prepareLocalTransport locates the
  transport on
  the homeserver it names (serverNameOfTransport) and skips the
  delegation
  probe where that is not our own.
- SessionBehaviors' membership.getTransportServerName: the same helper
  in
  LivekitBackend.mediaFor.
- decodeSFUJWTPayload, SFUJWTPayload.video.canPublish and the exported
  isCsApiUnavailable: sdk/src/media-backend/livekit/auth/.
- DeveloperSettingsTab, the widget capabilities, the locale string and
  the
  js-sdk pin (9405287c, 44.0.0): applied as is. The error boundary
  snapshots follow the string.

Tests: the LocalTransport cases go to RemotePublishTransport.test.ts and
LocalTransport.test.ts, the LocalMember remote case and the
SessionBehaviors
server-name case to LivekitBackend.test.ts, plus the memberships
snapshot in
RTCParticipation.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Timo K.
2026-10-14 16:09:11 +02:00
co-authored by Claude Fable 5.1
parent 4b73ce68e3
commit 8638d4f159
20 changed files with 527 additions and 51 deletions
+26 -1
View File
@@ -84,6 +84,7 @@ interface FakeBackend {
connectionState$: BehaviorSubject<MediaConnectionState | Error>;
data$: Subject<DataPacket>;
setPublishing: Mock<(publish: boolean) => void>;
findRemotePublishTransport: Mock<() => Promise<Transport | null>>;
delegateDelayedLeave: Mock<(delayId: string) => Promise<void>>;
mediaFor: Mock<(scope: ObservableScope, member: RTCMember) => MemberMedia>;
}
@@ -91,6 +92,7 @@ interface FakeBackend {
function setup(
options: Partial<RTCParticipationOptions> = {},
slotOptions: { matrixRTCMode?: MatrixRTCMode } = {},
homeserverTransports: Transport[] = [transport],
): {
scope: ObservableScope;
participation: ReturnType<typeof createRTCParticipation>["participation"];
@@ -112,7 +114,7 @@ function setup(
getDeviceId: () => deviceId,
getDomain: () => "example.org",
getSyncState: () => SyncState.Syncing,
_unstable_getRTCTransports: vi.fn().mockResolvedValue([transport]),
_unstable_getRTCTransports: vi.fn().mockResolvedValue(homeserverTransports),
} as unknown as MatrixClient;
const members$ = new BehaviorSubject<RTCMember[]>([]);
@@ -124,6 +126,7 @@ function setup(
),
data$: new Subject<DataPacket>(),
setPublishing: vi.fn(),
findRemotePublishTransport: vi.fn(async () => Promise.resolve(null)),
delegateDelayedLeave: vi.fn(async () => Promise.resolve()),
mediaFor: vi.fn(() => ({
tracks$: constant(null),
@@ -136,6 +139,7 @@ function setup(
): MediaBackend => {
const backend: MediaBackend = {
transportType: "livekit",
findRemotePublishTransport: fake.findRemotePublishTransport,
prepareLocalTransport: async () => fake.prepared.promise,
delegateDelayedLeave: fake.delegateDelayedLeave,
local: {
@@ -220,6 +224,27 @@ describe("createRTCParticipation", () => {
});
});
it("publishes on another member's SFU where the homeserver advertises none", async () => {
const bobsTransport: Transport = {
type: "livekit",
url: "wss://sfu.bob.com",
server_name: "bob.com",
};
const { session, join, fake } = setup({}, {}, []);
fake.findRemotePublishTransport.mockResolvedValue(bobsTransport);
fake.prepared.resolve({ canDelegateDelayedLeave: false });
await flushPromises();
expect(fake.findRemotePublishTransport).toHaveBeenCalledWith(
session.memberships,
);
expect(join).toHaveBeenCalledWith(
expect.anything(),
[bobsTransport],
expect.anything(),
);
});
it("publishes only while joined", async () => {
const { participation, session, fake, scope } = setup();
const ended = vi.fn();