Compare commits

..
Author SHA1 Message Date
Timo K. e02db20c9f sync with MSC (stream -> track) renames 2026-11-01 01:42:14 +01:00
Timo K.andClaude Opus 5.5 9aabe05b34 Use the full mesh backend from Element Call
A developer setting publishes on full mesh instead of LiveKit, and the
developer panel names full mesh transports and their peer connections. As a
widget, Element Call asks for the full mesh signalling event types while the
setting is on, and for TURN servers, which full mesh connects through. A
widget e2e spec covers a call between full mesh and LiveKit publishers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:35:31 +01:00
Timo K.andClaude Opus 5.5 b5165b8f3b Add the full mesh media backend
A peer to peer backend for the full mesh transport (draft MSC alongside it in
sdk/src/media-backend/full-mesh/): each member publishes to every other member
over its own peer connection, signalled with to-device events and steered over
a data channel control protocol.

The participation adds it to the combined backends, so members publishing on
full mesh are received, and a host joins with a full mesh transport type to
publish on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:35:31 +01:00
Timo K.andClaude Opus 5.5 9714412a43 Run the media backend inside a combination of backends
The participation now builds its default backend by combining every backend
the SDK has, publishing on the type of the transport the host names (LiveKit
without one) and handing each remote member to the backend of its transport.
LiveKit is the only member of the combination for now.

The LiveKit backend can run without a local transport, so it subscribes only
while another backend publishes, and probes the homeserver for delayed leave
delegation only when it first publishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:34:03 +01:00
Robin 76da59be40 Merge pull request #4301 from element-hq/floating-tile
Make drag & drop of floating tiles less janky
2026-10-27 12:16:12 +01:00
Robin ed6a0e1341 Merge pull request #4371 from element-hq/ellipsize-title
Ellipsise app bar text on mobile
2026-10-26 13:45:06 +01:00
renovate[bot]andJohannes Marbach 90ccf62d5c Update ghcr.io/element-hq/element-web:develop Docker digest to c9ffe3e (#4356)
* Update ghcr.io/element-hq/element-web:develop Docker digest to 79e47d8

* Adapt to new recovery popup button title

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-10-26 12:44:39 +00:00
Robin dde07f6d54 Ellipsise app bar text on mobile
We already had text-overflow: ellipsis set on the title and subtitle elements of the app bar, but due to an annoying quirk of place-items: center, this had no effect. place-items: center stretch seems to give the text elements the right size constraints.
2026-10-26 13:25:13 +01:00
35 changed files with 5118 additions and 55 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
+3
View File
@@ -71,6 +71,9 @@
"device_id": "Device ID: {{id}}",
"duplicate_tiles_label": "Number of additional tile copies per participant",
"environment_variables": "Environment variables",
"full_mesh_peer_connections": "Full mesh peer connections",
"full_mesh_publishing": "Publish on full mesh (MSC4575) instead of LiveKit",
"full_mesh_transport": "Full mesh: {{device}}",
"hostname": "Hostname: {{hostname}}",
"livekit_server_info": "LiveKit Server Info",
"livekit_sfu": "LiveKit SFU: {{url}}",
+167
View File
@@ -0,0 +1,167 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type Page, test } from "@playwright/test";
import { widgetTest } from "../fixtures/widget-user";
import { HOST1, TestHelpers } from "./test-helpers";
/**
* Records the LiveKit signalling websockets a page opens: the SFU is reached
* with an access token in the url, which full mesh never needs.
*/
function recordSfuSockets(page: Page): string[] {
const urls: string[] = [];
page.on("websocket", (ws) => {
if (ws.url().includes("access_token")) urls.push(ws.url());
});
return urls;
}
/**
* Starts recording the types of the to-device messages the widget on a page
* asks its host to send, and returns a function reading them. On the wire
* they are encrypted, so the type is only visible in the widget API.
*/
async function recordWidgetToDevice(
page: Page,
): Promise<() => Promise<string[]>> {
await page.evaluate(() => {
const types: string[] = [];
Object.assign(window, { widgetToDeviceTypes: types });
window.addEventListener("message", ({ data }) => {
const {
action,
response,
data: request,
} = (data ?? {}) as {
action?: string;
response?: unknown;
data?: { type?: string };
};
if (action === "send_to_device" && response === undefined)
types.push(request?.type ?? "");
});
});
return async () =>
page.evaluate(
() =>
(window as unknown as { widgetToDeviceTypes: string[] })
.widgetToDeviceTypes,
);
}
const cases: { name: string; fullMesh: [boolean, boolean] }[] = [
// Both publish on full mesh: the media flows without any SFU
{ name: "both on full mesh", fullMesh: [true, true] },
// One on each: Florian receives Valere over LiveKit without publishing there
{ name: "full mesh and LiveKit mixed", fullMesh: [true, false] },
];
cases.forEach(({ name, fullMesh: [florianMesh, valereMesh] }) => {
widgetTest(`Full mesh call: ${name}`, async ({ addUser, browserName }) => {
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow();
// Distinct per case: the cases run in parallel and register at once
const suffix = `${Number(florianMesh)}${Number(valereMesh)}`;
const [florian, valere] = await Promise.all([
addUser(`florian${suffix}`, HOST1),
addUser(`valere${suffix}`, HOST1),
]);
// Element Web doesn't auto-approve the full mesh signalling for Element
// Call yet, so it asks for it whenever a widget with full mesh opens
for (const { page } of [florian, valere])
await page.addLocatorHandler(
page.getByRole("heading", { name: "Approve widget permissions" }),
async () => page.getByRole("button", { name: "Approve" }).click(),
);
const sockets = {
florian: recordSfuSockets(florian.page),
valere: recordSfuSockets(valere.page),
};
const roomName = "Full Mesh Room";
await TestHelpers.createRoom(roomName, florian.page, [valere.mxId]);
await TestHelpers.acceptRoomInvite(roomName, valere.page);
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
florian.page,
"2_0",
{ fullMesh: florianMesh },
);
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
valere.page,
"2_0",
{ fullMesh: valereMesh },
);
await TestHelpers.startCallInCurrentRoom(florian.page, false);
await TestHelpers.joinCallFromLobby(florian.page);
await TestHelpers.joinCallInCurrentRoom(valere.page);
// A widget only asks for the full mesh signalling with full mesh turned
// on, so a LiveKit publisher in Element Web can't receive full mesh yet:
// the other's media arrives if it is on LiveKit or we have full mesh
const receives = {
florian: !valereMesh || florianMesh,
valere: !florianMesh || valereMesh,
};
// In parallel: assertions on a page also run its permission handler
await Promise.all(
(["florian", "valere"] as const).map(async (name) => {
const frame = { florian, valere }[name].page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(2, {
timeout: 15000,
});
const waiting = frame.getByText("Waiting for media...");
if (receives[name]) {
// Over whichever transport carries it
await expect(waiting).not.toBeVisible({ timeout: 20000 });
await TestHelpers.expectVisibleVideoCount(frame, 2);
} else {
await expect(waiting).toBeVisible({ timeout: 20000 });
await TestHelpers.expectVisibleVideoCount(frame, 1);
}
}),
);
if (florianMesh && valereMesh) {
// Nobody published on LiveKit, so nobody connected to an SFU
expect(sockets.florian).toEqual([]);
expect(sockets.valere).toEqual([]);
} else {
// Florian receives Valere's media from the SFU without publishing there
expect(sockets.florian.length).toBeGreaterThan(0);
}
// Florian sees Valere's camera go off: over the track metadata for full
// mesh, without renegotiating
const toDevice = await recordWidgetToDevice(valere.page);
const valereFrame = valere.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await valereFrame.getByRole("switch", { name: "Stop video" }).click();
const florianFrame = florian.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await TestHelpers.expectVisibleVideoCount(florianFrame, 1);
// On the connection's control channel, not through the homeserver.
// Candidates may still trickle there until the channel is up
if (florianMesh && valereMesh)
expect(
(await toDevice()).filter(
(type) => type.includes("full_mesh") && !type.endsWith(".candidates"),
),
).toEqual([]);
});
});
+13 -3
View File
@@ -159,7 +159,7 @@ export class TestHelpers {
public static async dismissStartupToasts(page: Page): Promise<void> {
const expectedToasts = [
{ title: "Failed to load service worker", button: "OK" },
{ title: "Back up your chats", button: "Dismiss" },
{ title: "Back up your chats", button: "Not now" },
{ title: "Turn on key storage", button: "Dismiss" },
{ title: "Element does not support this browser", button: "Dismiss" },
];
@@ -178,7 +178,11 @@ export class TestHelpers {
);
if (toastConfig) {
await toast.getByRole("button", { name: toastConfig.button }).click();
// Bounded so that a renamed button leaves the toast in place and the
// next step reports it, instead of hanging until the test timeout.
await toast
.getByRole("button", { name: toastConfig.button })
.click({ timeout: 5000 });
} else {
// Unknown toast. We don't want to act on unknown toasts
break;
@@ -288,6 +292,7 @@ export class TestHelpers {
public static async openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
page: Page,
mode: RtcMode,
{ fullMesh = false }: { fullMesh?: boolean } = {},
): Promise<void> {
await page.getByRole("button", { name: "Video call" }).click({
timeout: 5000,
@@ -296,7 +301,7 @@ export class TestHelpers {
timeout: 10000,
});
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode, { fullMesh });
await page.getByRole("button", { name: "Close lobby" }).click();
}
@@ -309,6 +314,7 @@ export class TestHelpers {
public static async setEmbeddedElementCallRtcMode(
page: Page,
mode: RtcMode,
{ fullMesh = false }: { fullMesh?: boolean } = {},
): Promise<void> {
const iframe = page.locator('iframe[title="Element Call"]').contentFrame();
@@ -326,6 +332,10 @@ export class TestHelpers {
// compat
await iframe.getByText("Compatibility: state events").click();
}
// Publish on the full mesh transport (MSC4575) instead of LiveKit
await iframe
.getByText("Publish on full mesh", { exact: false })
.setChecked(fullMesh);
await iframe.getByTestId("modal_close").click();
}
+9 -4
View File
@@ -66,9 +66,14 @@ media backend: whatever carries the media, behind the `MediaBackend` interface i
`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.
with the local media connection. Two backends exist, LiveKit (`livekit/`) and
full mesh (`full-mesh/`, MSC4575), and the client always runs both, combined
(`combineBackends.ts`). It publishes on the backend of the type of
`options.transport`, LiveKit without one, and hands every remote member to the
backend of that member's transport, so members on either are received. A
backend nobody is on connects nowhere. Another transport is another folder
under `sdk/src/media-backend/`, added to the combination, and nothing in the
left box changes; `options.backend` replaces the combination altogether.
### How Element Call uses it
@@ -149,7 +154,7 @@ the sections below still describe `MatrixRTCClient` until that lands.
| 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 |
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation: LiveKit and full mesh combined, publishing on the type of `transport` in the join options, unless `backend` replaces them | "SFU", a LiveKit room, a peer connection |
Names use `RTC` in capitals, as the js-sdk does: `RTCSlot`, `RTCParticipation`,
`RTCMember`, `matrixRTCMode`.
+8
View File
@@ -61,6 +61,14 @@ export {
createLivekitBackend,
type LivekitBackendOptions,
} from "./src/media-backend/livekit/LivekitBackend";
// Join with `transport: { type: FULL_MESH_TRANSPORT_TYPE }` to publish on full mesh
export {
createFullMeshBackend,
FULL_MESH_TRANSPORT_TYPE,
type FullMeshBackendOptions,
} from "./src/media-backend/full-mesh/FullMeshBackend";
// For a widget host to allow the full mesh signalling
export { signallingEventTypes as fullMeshSignallingEventTypes } from "./src/media-backend/full-mesh/signalling";
// For a host that creates LiveKit tracks itself, such as a preview before joining
export { convertToLivekitProcessor } from "./src/media-backend/livekit/videoProcessor";
// For a developer panel to check a transport before using it
+26 -3
View File
@@ -50,6 +50,11 @@ import {
type MediaBackendContext,
type MediaKey,
} from "./media-backend/api";
import { combineBackends } from "./media-backend/combineBackends";
import {
completeFullMeshTransport,
createFullMeshBackend,
} from "./media-backend/full-mesh/FullMeshBackend";
import { createLivekitBackend } from "./media-backend/livekit/LivekitBackend";
import { findRemotePublishLivekitTransport } from "./media-backend/livekit/RemotePublishTransport";
import { createHomeserverConnected$ } from "./matrixrtc/HomeserverConnected";
@@ -121,6 +126,13 @@ export function createRTCParticipation(
: `${userId}:${deviceId}`,
};
// The host names full mesh by its type alone; the device is ours
if (options.transport)
options = {
...options,
transport: completeFullMeshTransport(options.transport, deviceId),
};
const backend = createBackend(scope, client, options, {
roomId: room.roomId,
slotId: jsSdkSession.slotId,
@@ -310,15 +322,26 @@ export function createRTCParticipation(
};
}
/** The host's backend, or LiveKit configured from the participation options. */
/**
* The host's backend, or every backend the SDK has, combined: we publish on
* the type of the transport the host named (LiveKit without one) and receive
* from members on any of them.
*/
function createBackend(
scope: ObservableScope,
client: MatrixClient,
{ backend, mediaQuality }: RTCParticipationOptions,
{ backend, mediaQuality, transport }: RTCParticipationOptions,
context: MediaBackendContext,
): MediaBackend {
if (backend) return backend(scope, context);
return createLivekitBackend(scope, context, { client, mediaQuality });
return combineBackends(
scope,
[
createLivekitBackend(scope, context, { client, mediaQuality }),
createFullMeshBackend(scope, context, { client }),
],
transport?.type ?? "livekit",
);
}
/** The slot's per-participant keys: the ones it already holds on subscribe, then each new one. */
@@ -0,0 +1,91 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { BehaviorSubject, NEVER } from "rxjs";
import { type RTCMember, type TransportMetadata } from "../api";
import { type MemberMedia } from "../media-api";
import { constant } from "../reactive/Behavior";
import { ObservableScope } from "../reactive/ObservableScope";
import { type MediaBackend } from "./api";
import { combineBackends } from "./combineBackends";
function fakeBackend(type: string): MediaBackend & { ended: string[] } {
const ended: string[] = [];
return {
ended,
transportType: type,
prepareLocalTransport: vi.fn(async () =>
Promise.resolve({ canDelegateDelayedLeave: false }),
),
delegateDelayedLeave: vi.fn(),
local: {} as MediaBackend["local"],
mediaFor: (scope, member): MemberMedia => {
scope.onEnd(() => ended.push(member.memberId));
return {
tracks$: constant([{ id: `${type}:${member.memberId}` }] as never),
encryptionError$: NEVER,
};
},
connections$: constant([
{ transport: { type }, state: "connected" as never, resolved: undefined },
]),
setAudioOutputDeviceId: vi.fn(),
sendData: vi.fn(),
data$: NEVER,
};
}
function transport(type: string): TransportMetadata {
return { type, id: type, raw: { type }, resolved$: constant(undefined) };
}
describe("combineBackends", () => {
it("routes each member to the backend of its transport type", () => {
const scope = new ObservableScope();
const livekit = fakeBackend("livekit");
const other = fakeBackend("other");
const combined = combineBackends(scope, [livekit, other], "livekit");
expect(combined.transportType).toBe("livekit");
expect(combined.connections$.value).toHaveLength(2);
const transport$ = new BehaviorSubject<TransportMetadata | undefined>(
transport("other"),
);
const memberScope = new ObservableScope();
const media = combined.mediaFor(memberScope, {
memberId: "m1",
transport$,
} as unknown as RTCMember);
expect(media.tracks$.value).toEqual([{ id: "other:m1" }]);
transport$.next(transport("livekit"));
expect(other.ended).toEqual(["m1"]);
expect(media.tracks$.value).toEqual([{ id: "livekit:m1" }]);
transport$.next(transport("unknown"));
expect(media.tracks$.value).toBeNull();
// The publishing backend keeps every member for as long as it is one
expect(livekit.ended).toEqual([]);
memberScope.end();
expect(livekit.ended).toEqual(["m1"]);
scope.end();
});
it("publishes on the backend of the publish type", () => {
const scope = new ObservableScope();
const livekit = fakeBackend("livekit");
const other = fakeBackend("other");
const combined = combineBackends(scope, [livekit, other], "other");
expect(combined.transportType).toBe("other");
expect(() => combineBackends(scope, [livekit, other], "unknown")).toThrow(
"No media backend for transport unknown",
);
scope.end();
});
});
+80
View File
@@ -0,0 +1,80 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { combineLatest, map, merge, NEVER, of, switchMap } from "rxjs";
import { type RTCMember } from "../api";
import { MatrixRTCError } from "../errors";
import { type MemberMedia } from "../media-api";
import { type ObservableScope } from "../reactive/ObservableScope";
import { mapScoped } from "../utils/mapScoped";
import { type MediaBackend } from "./api";
/**
* Several backends as one: we publish on the backend of `publishType`, and
* receive from every member on a transport any of the backends serves. A
* member is handed to the backend of its current transport type. A backend
* nobody is on costs nothing: it connects only for the members handed to it.
*/
export function combineBackends(
scope: ObservableScope,
all: MediaBackend[],
publishType: string,
): MediaBackend {
const byType = new Map(all.map((b) => [b.transportType, b]));
const publish = byType.get(publishType);
if (publish === undefined)
throw new MatrixRTCError(`No media backend for transport ${publishType}`);
const mediaFor = (
memberScope: ObservableScope,
member: RTCMember,
): MemberMedia => {
// The publishing backend sees every member, whatever its transport, so
// that it can tell members from strangers when a backend is asked for our
// media by members on other transports. It connects nowhere for them
const publishMedia = publish.mediaFor(memberScope, member);
const type$ = memberScope.behavior(
member.transport$.pipe(map((transport) => transport?.type)),
);
const media$ = mapScoped(memberScope, type$, (s, type) =>
type === publish.transportType
? publishMedia
: byType.get(type)?.mediaFor(s, member),
);
return {
tracks$: memberScope.behavior(
media$.pipe(switchMap((media) => media?.tracks$ ?? of(null))),
),
encryptionError$: media$.pipe(
switchMap((media) => media?.encryptionError$ ?? NEVER),
),
};
};
return {
transportType: publish.transportType,
prepareLocalTransport: async (transport) =>
publish.prepareLocalTransport(transport),
delegateDelayedLeave: async (delayId) =>
publish.delegateDelayedLeave(delayId),
local: publish.local,
mediaFor,
connections$: scope.behavior(
combineLatest(all.map((b) => b.connections$)).pipe(
map((lists) => lists.flat()),
),
),
setAudioOutputDeviceId: async (deviceId) => {
await Promise.all(
all.map(async (b) => b.setAudioOutputDeviceId(deviceId)),
);
},
sendData: async (topic, text) => publish.sendData(topic, text),
data$: merge(...all.map((b) => b.data$)),
};
}
@@ -0,0 +1,87 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* The control channel of a full mesh connection (MSC4575): a negotiated data
* channel both sides create, which carries every message of the connection
* once it is open, apart from `close`.
*/
import {
type ConsumptionMetadata,
parseConsumptionMetadata,
parseTrackMetadata,
type TrackMetadata,
} from "./metadata";
export const CONTROL_CHANNEL_LABEL = "org.matrix.msc4575.rtc.full_mesh.control";
const CONTROL_CHANNEL_ID = 0;
export type ControlMessage =
| {
type: "description";
description: { type: "offer" | "answer"; sdp: string };
track_metadata?: TrackMetadata;
}
| { type: "candidates"; candidates: RTCIceCandidateInit[] }
| { type: "track_metadata"; track_metadata: TrackMetadata }
| { type: "consumption_metadata"; consumption_metadata: ConsumptionMetadata };
export function createControlChannel(pc: RTCPeerConnection): RTCDataChannel {
return pc.createDataChannel(CONTROL_CHANNEL_LABEL, {
negotiated: true,
id: CONTROL_CHANNEL_ID,
});
}
/** A received control message, or null for one to ignore: malformed, or of a type we don't know. */
export function parseControlMessage(data: unknown): ControlMessage | null {
if (typeof data !== "string") return null;
let m: Record<string, unknown>;
try {
m = JSON.parse(data) as Record<string, unknown>;
} catch {
return null;
}
if (typeof m !== "object" || m === null) return null;
switch (m.type) {
case "description": {
const d = m.description as { type?: unknown; sdp?: unknown } | undefined;
if (
(d?.type !== "offer" && d?.type !== "answer") ||
typeof d.sdp !== "string"
)
return null;
return {
type: "description",
description: { type: d.type, sdp: d.sdp },
track_metadata: parseTrackMetadata(m.track_metadata) ?? undefined,
};
}
case "candidates":
if (!Array.isArray(m.candidates)) return null;
return {
type: "candidates",
candidates: m.candidates as RTCIceCandidateInit[],
};
case "track_metadata": {
const metadata = parseTrackMetadata(m.track_metadata);
return metadata && { type: "track_metadata", track_metadata: metadata };
}
case "consumption_metadata": {
const metadata = parseConsumptionMetadata(m.consumption_metadata);
return (
metadata && {
type: "consumption_metadata",
consumption_metadata: metadata,
}
);
}
default:
return null;
}
}
@@ -0,0 +1,488 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "events";
import {
ClientEvent,
EventType,
type ITurnServer,
MatrixEvent,
} from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, NEVER } from "rxjs";
import { type RTCMember, type TransportMetadata } from "../../api";
import { defaultSessionTimings } from "../../config";
import { E2eeType } from "../../encryption";
import { constant } from "../../reactive/Behavior";
import { ObservableScope } from "../../reactive/ObservableScope";
import {
type DataPacket,
type MediaBackend,
MediaConnectionState,
} from "../api";
import {
completeFullMeshTransport,
createFullMeshBackend,
type FullMeshBackendOptions,
} from "./FullMeshBackend";
import { DATA_CHANNEL_LABEL, DATA_PROTOCOL } from "./metadata";
import { FULL_MESH_TRANSPORT_TYPE, SignallingEventType } from "./signalling";
import {
FakePeerConnection,
installWebRtcFakes,
lastPeerConnection,
peer,
settle,
} from "./test";
const roomId = "!room:example.org";
const slotId = "m.call#ROOM";
const own = { userId: "@me:example.org", deviceId: "MYDEVICE", memberId: "me" };
const fullMesh = (deviceId: string): Transport => ({
type: FULL_MESH_TRANSPORT_TYPE,
device_id: deviceId,
});
const livekit: Transport = { type: "livekit", livekit_service_url: "x" };
function metadata(raw: Transport): TransportMetadata {
return {
type: raw.type,
id: JSON.stringify(raw),
raw,
resolved$: constant(undefined),
};
}
describe("createFullMeshBackend", () => {
let scope: ObservableScope;
let client: EventEmitter & FullMeshBackendOptions["client"];
let send: ReturnType<typeof vi.fn>;
let turnServers: ITurnServer[];
let backend: MediaBackend;
const seq = new Map<string, number>();
/** A to-device message from `peer` (or another sender), as the sync would deliver it. */
function deliver(
type: string,
connectionId: string,
content: Record<string, unknown> = {},
from = peer,
): void {
const next = seq.get(from.memberId) ?? 0;
seq.set(from.memberId, next + 1);
const event = new MatrixEvent({
type,
sender: from.userId,
content: {
room_id: roomId,
slot_id: slotId,
member_id: from.memberId,
target_member_id: own.memberId,
device_id: from.deviceId,
connection_id: connectionId,
seq: next,
...content,
},
});
vi.spyOn(event, "getWireType").mockReturnValue(
EventType.RoomMessageEncrypted,
);
client.emit(ClientEvent.ToDeviceEvent, event);
}
/** What we sent, as [type, recipient device, content]. */
function sent(type?: string): [string, string, Record<string, unknown>][] {
return send.mock.calls
.filter(([t]) => type === undefined || t === type)
.map(([t, [target], content]) => [t, target.deviceId, content]);
}
/** A member of the slot, as the session hands it to the backend. */
function addMember(
transport: Transport,
who = peer,
): { scope: ObservableScope; tracks: () => unknown } {
const memberScope = new ObservableScope();
const media = backend.mediaFor(memberScope, {
...who,
rtcBackendIdentity: `${who.userId}:${who.deviceId}`,
transport$: new BehaviorSubject(metadata(transport)),
} as unknown as RTCMember);
return { scope: memberScope, tracks: () => media.tracks$.value };
}
beforeEach(() => {
vi.useFakeTimers();
installWebRtcFakes();
seq.clear();
scope = new ObservableScope();
send = vi.fn(async () => Promise.resolve());
turnServers = [];
client = Object.assign(new EventEmitter(), {
encryptAndSendToDevice: send,
getTurnServers: (): ITurnServer[] => turnServers,
checkTurnServers: vi.fn(async () => {
turnServers = [{ urls: ["turn:x"], username: "u", credential: "p" }];
return Promise.resolve(true);
}),
}) as unknown as EventEmitter & FullMeshBackendOptions["client"];
backend = createFullMeshBackend(
scope,
{
roomId,
slotId,
ownMembershipIdentity: own,
publish: [],
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
mediaKeys$: NEVER,
timings: defaultSessionTimings,
logger,
},
{ client },
);
});
afterEach(() => {
scope.end();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("only prepares a full mesh transport", async () => {
await expect(backend.prepareLocalTransport(livekit)).rejects.toThrow(
"Not a full mesh transport: livekit",
);
await expect(
backend.prepareLocalTransport(fullMesh(own.deviceId)),
).resolves.toEqual({ canDelegateDelayedLeave: false });
expect(backend.local.connectionState$.value).toBe(
MediaConnectionState.Connected,
);
});
it("fetches TURN servers when the client has none yet", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
expect(client.checkTurnServers).toHaveBeenCalledOnce();
addMember(fullMesh(peer.deviceId));
await settle();
expect(lastPeerConnection().config.iceServers).toEqual([
{ urls: ["turn:x"], username: "u", credential: "p" },
]);
});
describe("as a publisher", () => {
it("turns subscribers away while it doesn't publish", async () => {
addMember(livekit);
deliver(SignallingEventType.subscribe, "c1");
await settle();
expect(sent()).toEqual([
[
SignallingEventType.close,
peer.deviceId,
expect.objectContaining({
reason: "not_publishing",
connection_id: "c1",
}),
],
]);
});
it("offers to a member on any transport", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
addMember(livekit);
deliver(SignallingEventType.subscribe, "c1");
await settle();
expect(sent(SignallingEventType.description)).toEqual([
[
SignallingEventType.description,
peer.deviceId,
expect.objectContaining({
connection_id: "c1",
target_member_id: peer.memberId,
description: { type: "offer", sdp: "offer-1" },
}),
],
]);
});
it("waits for the membership of a subscriber that is ahead of it", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
deliver(SignallingEventType.subscribe, "c1");
await vi.advanceTimersByTimeAsync(5_000);
expect(sent()).toEqual([]);
addMember(livekit);
await settle();
expect(sent(SignallingEventType.description)).toHaveLength(1);
});
it("rejects a subscriber that never becomes a member", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
deliver(SignallingEventType.subscribe, "c1");
await vi.advanceTimersByTimeAsync(10_000);
expect(sent()).toEqual([
[
SignallingEventType.close,
peer.deviceId,
expect.objectContaining({ reason: "not_a_member" }),
],
]);
expect(FakePeerConnection.instances).toEqual([]);
});
it("rejects a subscriber claiming another device of the member", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
addMember(livekit);
deliver(
SignallingEventType.subscribe,
"c1",
{},
{
...peer,
deviceId: "OTHERDEVICE",
},
);
await settle();
expect(sent()).toEqual([
[
SignallingEventType.close,
"OTHERDEVICE",
expect.objectContaining({ reason: "not_a_member" }),
],
]);
});
it("only serves the newest of several waiting subscribe requests", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
deliver(SignallingEventType.subscribe, "c1");
deliver(SignallingEventType.subscribe, "c2");
addMember(livekit);
await settle();
expect(
sent(SignallingEventType.description).map(([, , c]) => c.connection_id),
).toEqual(["c2"]);
});
it("replaces the connection of a member that subscribes again", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
addMember(livekit);
deliver(SignallingEventType.subscribe, "c1");
await settle();
const first = lastPeerConnection();
deliver(SignallingEventType.subscribe, "c2");
await settle();
expect(first.closed).toBe(true);
expect(lastPeerConnection()).not.toBe(first);
});
it("hands the subscriber's messages to its connection", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
addMember(livekit);
deliver(SignallingEventType.subscribe, "c1");
await settle();
deliver(SignallingEventType.description, "c1", {
description: { type: "answer", sdp: "answer" },
});
deliver(SignallingEventType.candidates, "c1", {
candidates: [{ candidate: "x" }],
});
// A late message of an earlier connection goes nowhere
deliver(SignallingEventType.candidates, "c0", {
candidates: [{ candidate: "stale" }],
});
await settle();
const pc = lastPeerConnection();
expect(pc.remoteDescription).toEqual({ type: "answer", sdp: "answer" });
expect(pc.addedCandidates).toEqual([{ candidate: "x" }]);
});
it("stops sending to a member that leaves", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
const member = addMember(livekit);
deliver(SignallingEventType.subscribe, "c1");
await settle();
member.scope.end();
expect(lastPeerConnection().closed).toBe(true);
expect(sent(SignallingEventType.close)).toEqual([
[
SignallingEventType.close,
peer.deviceId,
expect.objectContaining({ connection_id: "c1" }),
],
]);
});
it("lists its subscribers for the developer tools", async () => {
expect(backend.connections$.value).toEqual([]);
await backend.prepareLocalTransport(fullMesh(own.deviceId));
expect(backend.connections$.value).toEqual([
{
transport: fullMesh(own.deviceId),
state: MediaConnectionState.Connected,
resolved: {
type: FULL_MESH_TRANSPORT_TYPE,
role: "publisher",
peerConnections: [],
},
},
]);
addMember(livekit);
deliver(SignallingEventType.subscribe, "c1");
await settle();
expect(backend.connections$.value[0].resolved).toMatchObject({
peerConnections: [
{
memberId: peer.memberId,
connectionId: "c1",
connectionState: "new",
},
],
});
});
});
describe("as a subscriber", () => {
it("subscribes to a member publishing on full mesh", async () => {
const member = addMember(fullMesh(peer.deviceId));
await settle();
expect(sent()).toEqual([
[
SignallingEventType.subscribe,
peer.deviceId,
expect.objectContaining({
room_id: roomId,
slot_id: slotId,
member_id: own.memberId,
device_id: own.deviceId,
target_member_id: peer.memberId,
seq: 0,
}),
],
]);
expect(member.tracks()).toBeNull();
expect(backend.connections$.value).toEqual([
expect.objectContaining({
transport: fullMesh(peer.deviceId),
resolved: expect.objectContaining({ role: "subscriber" }),
}),
]);
});
it("ignores a full mesh transport advertising another device", async () => {
addMember(fullMesh("OTHERDEVICE"));
await settle();
expect(sent()).toEqual([]);
});
it("leaves members on other transports alone", async () => {
const member = addMember(livekit);
await settle();
expect(sent()).toEqual([]);
expect(member.tracks()).toBeNull();
});
it("hands the publisher's messages to the subscription", async () => {
addMember(fullMesh(peer.deviceId));
await settle();
const [[, , subscribe]] = sent();
lastPeerConnection().receive("video", "1");
deliver(
SignallingEventType.description,
subscribe.connection_id as string,
{
description: { type: "offer", sdp: "offer" },
track_metadata: { "1": { purpose: "m.usermedia" } },
},
);
await settle();
expect(sent(SignallingEventType.description)).toEqual([
[
SignallingEventType.description,
peer.deviceId,
expect.objectContaining({
description: { type: "answer", sdp: "answer" },
}),
],
]);
});
it("closes the subscription when the member leaves", async () => {
const member = addMember(fullMesh(peer.deviceId));
await settle();
member.scope.end();
expect(lastPeerConnection().closed).toBe(true);
expect(backend.connections$.value).toEqual([]);
});
});
it("doesn't take over the delayed leave", async () => {
await expect(backend.delegateDelayedLeave("id")).rejects.toThrow();
});
describe("data", () => {
it("can't be sent while not publishing", async () => {
await expect(backend.sendData("topic", "text")).rejects.toThrow();
});
it("is sent to every subscriber on its data channel", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
addMember(livekit);
deliver(SignallingEventType.subscribe, "c1");
await settle();
const data = lastPeerConnection().dataChannels.find(
(c) => c.label === DATA_CHANNEL_LABEL,
)!;
data.open();
await backend.sendData("greeting", "hello");
expect(data.sent.map((m) => JSON.parse(m) as unknown)).toEqual([
{ topic: "greeting", text: "hello" },
]);
});
it("is received from a publisher, named by its member", async () => {
const received: DataPacket[] = [];
backend.data$.subscribe((p) => received.push(p));
addMember(fullMesh(peer.deviceId));
await settle();
const [[, , subscribe]] = sent();
deliver(
SignallingEventType.description,
subscribe.connection_id as string,
{
description: { type: "offer", sdp: "offer" },
track_metadata: {},
},
);
await settle();
const channel = lastPeerConnection().announceDataChannel(
DATA_CHANNEL_LABEL,
DATA_PROTOCOL,
);
channel.receive({ topic: "greeting", text: "hello" });
channel.receive({ topic: 1 });
expect(received).toEqual([
{
senderId: `${peer.userId}:${peer.deviceId}`,
topic: "greeting",
text: "hello",
},
]);
});
});
});
describe("completeFullMeshTransport", () => {
it("adds our device to a full mesh transport only", () => {
expect(
completeFullMeshTransport({ type: FULL_MESH_TRANSPORT_TYPE }, "DEV"),
).toEqual({ type: FULL_MESH_TRANSPORT_TYPE, device_id: "DEV" });
expect(completeFullMeshTransport(livekit, "DEV")).toBe(livekit);
});
});
@@ -0,0 +1,424 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* The full mesh transport (MSC4575): one WebRTC connection per subscriber
* and publisher, signalled over to-device messages, through the TURN server
* of each side's homeserver. Works subscribe-only too: without
* `prepareLocalTransport` it publishes nothing and still receives from every
* member on full mesh.
*/
import { type MatrixClient } from "matrix-js-sdk";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
filter,
firstValueFrom,
map,
NEVER,
type Observable,
of,
share,
Subject,
switchMap,
timeout,
} from "rxjs";
import { type RTCMember } from "../../api";
import { MatrixRTCError } from "../../errors";
import { type MemberMedia } from "../../media-api";
import { type Behavior } from "../../reactive/Behavior";
import { ObservableScope } from "../../reactive/ObservableScope";
import { mapScoped } from "../../utils/mapScoped";
import {
type BackendConnection,
type DataPacket,
type MediaBackend,
type MediaBackendContext,
MediaConnectionState,
} from "../api";
import { createFullMeshLocalMedia } from "./LocalMedia";
import { type PeerConnectionBase } from "./PeerConnection";
import {
createPublisherConnection,
type PublisherConnection,
} from "./PublisherConnection";
import {
createSignalling,
FULL_MESH_TRANSPORT_TYPE,
type Peer,
type SignallingClient,
type SignallingMessage,
} from "./signalling";
import {
createSubscriberConnection,
type SubscriberConnection,
} from "./SubscriberConnection";
export { FULL_MESH_TRANSPORT_TYPE } from "./signalling";
/** How long a subscribe request waits for the subscriber's membership to arrive. */
const SUBSCRIBE_GRACE_MS = 10_000;
export interface FullMeshBackendOptions {
client: SignallingClient &
Pick<MatrixClient, "getTurnServers" | "checkTurnServers">;
}
/**
* The transport object a member publishing on full mesh advertises: a host
* passes `{ type: FULL_MESH_TRANSPORT_TYPE }`, and the device is filled in.
* Any other transport is returned as it is.
*/
export function completeFullMeshTransport(
transport: Transport,
deviceId: string,
): Transport {
return transport.type === FULL_MESH_TRANSPORT_TYPE
? { ...transport, device_id: deviceId }
: transport;
}
export function createFullMeshBackend(
scope: ObservableScope,
{
roomId,
slotId,
ownMembershipIdentity,
publish,
logger: parentLogger,
}: MediaBackendContext,
{ client }: FullMeshBackendOptions,
): MediaBackend {
const logger = parentLogger.getChild("[FullMeshBackend]");
const signalling = createSignalling({
scope,
client,
roomId,
slotId,
own: ownMembershipIdentity,
logger,
});
const iceServers = async (): Promise<RTCIceServer[]> => {
if (client.getTurnServers().length === 0)
await client
.checkTurnServers()
.catch((e: unknown) => logger.warn("No TURN servers", e));
return client.getTurnServers().map(({ urls, username, credential }) => ({
urls,
username,
credential,
}));
};
let localTransport: Transport | null = null;
const prepared$ = new BehaviorSubject(false);
const audioOutputDeviceId$ = new BehaviorSubject<string | undefined>(
undefined,
);
const data$ = new Subject<DataPacket>();
scope.onEnd(() => data$.complete());
const members = new Map<string, RTCMember>();
const memberAdded$ = new Subject<RTCMember>();
const subscribers = new Map<
string,
{ transport: Transport; peer: Peer; connection: SubscriberConnection }
>();
const publishers = new Map<
string,
{ connection: PublisherConnection; peer: Peer; scope: ObservableScope }
>();
scope.onEnd(() => {
for (const { scope: s } of Array.from(publishers.values())) s.end();
});
/** The connection id of the newest subscribe request per member. */
const latestSubscribe = new Map<string, string>();
// For the developer tools: one entry per publisher we subscribe to, and one
// for our own transport with every subscriber we send to. `resolved` lists
// the peer connections, as an SFU's entry lists what it connected to
const connectionsChanged$ = new BehaviorSubject<void>(undefined);
const connections$ = scope.behavior<BackendConnection[]>(
combineLatest([connectionsChanged$, prepared$]).pipe(
switchMap(([, prepared]) => {
const entries: Observable<BackendConnection>[] = [];
for (const { transport, peer, connection } of subscribers.values()) {
const base = connection.current$.value;
if (base)
entries.push(
base.state$.pipe(
map((state) => ({
transport,
state,
resolved: {
type: FULL_MESH_TRANSPORT_TYPE,
role: "subscriber",
peerConnections: [describe(peer, base)],
},
})),
),
);
}
if (localTransport && prepared) {
const transport = localTransport;
const outgoing = [...publishers.values()];
entries.push(
(outgoing.length === 0
? of([])
: combineLatest(outgoing.map((p) => p.connection.state$))
).pipe(
map(() => ({
transport,
// Publishing needs no server, so it is up once prepared
state: MediaConnectionState.Connected,
resolved: {
type: FULL_MESH_TRANSPORT_TYPE,
role: "publisher",
peerConnections: outgoing.map(({ peer, connection }) =>
describe(peer, connection),
),
},
})),
),
);
}
return entries.length === 0 ? of([]) : combineLatest(entries);
}),
),
);
const local = createFullMeshLocalMedia({
scope,
publish,
connectionState$: scope.behavior(
prepared$.pipe(
map((prepared) =>
prepared
? MediaConnectionState.Connected
: MediaConnectionState.Initialized,
),
),
),
peerConnections: () =>
[...publishers.values()].map(({ connection }) => connection.pc),
logger,
});
const acceptSubscribe = async (message: SignallingMessage): Promise<void> => {
const peer: Peer = {
userId: message.userId,
deviceId: message.deviceId,
memberId: message.memberId,
};
// We don't advertise full mesh, so nobody should have asked
if (!prepared$.value) {
signalling.send(peer, message.connectionId, {
type: "close",
reason: "not_publishing",
});
return;
}
latestSubscribe.set(peer.memberId, message.connectionId);
// The request may overtake the subscriber's membership
const member =
members.get(peer.memberId) ??
(await firstValueFrom(
memberAdded$.pipe(
filter((m) => m.memberId === peer.memberId),
timeout(SUBSCRIBE_GRACE_MS),
),
).catch(() => undefined));
if (latestSubscribe.get(peer.memberId) !== message.connectionId) return;
if (
member === undefined ||
member.userId !== peer.userId ||
member.deviceId !== peer.deviceId
) {
logger.warn(`Rejecting a subscribe from ${peer.memberId}`);
signalling.send(peer, message.connectionId, {
type: "close",
reason: "not_a_member",
});
return;
}
// At most one connection per member pair: the new one replaces the old
publishers.get(peer.memberId)?.scope.end();
const pubScope = new ObservableScope();
const connection = createPublisherConnection({
scope: pubScope,
peer,
connectionId: message.connectionId,
signalling,
iceServers: await iceServers(),
local,
logger,
});
publishers.set(peer.memberId, { connection, peer, scope: pubScope });
pubScope.onEnd(() => {
if (publishers.get(peer.memberId)?.connection === connection) {
publishers.delete(peer.memberId);
connectionsChanged$.next();
}
});
connectionsChanged$.next();
};
signalling.messages$.pipe(scope.bind()).subscribe((message) => {
if (message.type === "subscribe") return void acceptSubscribe(message);
const subscriber = subscribers.get(message.memberId)?.connection;
if (subscriber?.current$.value?.connectionId === message.connectionId)
return subscriber.handle(message);
const publisher = publishers.get(message.memberId)?.connection;
if (publisher?.connectionId === message.connectionId)
return publisher.handle(message);
logger.debug(`Dropping ${message.type} of a stale connection`);
});
const mediaFor = (
memberScope: ObservableScope,
member: RTCMember,
): MemberMedia => {
members.set(member.memberId, member);
memberAdded$.next(member);
memberScope.onEnd(() => {
members.delete(member.memberId);
// We stop sending to members that are no longer joined
publishers.get(member.memberId)?.scope.end();
});
// In encrypted rooms the advertised device has to be the one the
// membership came from
const fullMesh$ = memberScope.behavior(
member.transport$.pipe(
map((transport) =>
transport?.type === FULL_MESH_TRANSPORT_TYPE &&
transport.raw.device_id === member.deviceId
? transport.raw
: undefined,
),
distinctUntilChanged((a, b) => a?.device_id === b?.device_id),
),
);
const subscriber$ = mapScoped(memberScope, fullMesh$, (s, transport) => {
const connection = createSubscriberConnection({
scope: s,
peer: {
userId: member.userId,
deviceId: member.deviceId,
memberId: member.memberId,
},
signalling,
iceServers,
audioOutputDeviceId$,
logger,
});
subscribers.set(member.memberId, {
transport,
peer: {
userId: member.userId,
deviceId: member.deviceId,
memberId: member.memberId,
},
connection,
});
connection.current$
.pipe(s.bind())
.subscribe(() => connectionsChanged$.next());
connection.data$.pipe(s.bind()).subscribe((message) => {
const packet = parseDataMessage(message);
if (packet === null)
logger.debug(`Ignoring a data message from ${member.memberId}`);
else data$.next({ senderId: member.rtcBackendIdentity, ...packet });
});
s.onEnd(() => {
subscribers.delete(member.memberId);
connectionsChanged$.next();
});
return connection;
});
return {
tracks$: memberScope.behavior(
subscriber$.pipe(switchMap((s) => s?.tracks$ ?? of(null))),
),
encryptionError$: NEVER,
};
};
return {
transportType: FULL_MESH_TRANSPORT_TYPE,
prepareLocalTransport: async (transport) => {
if (transport.type !== FULL_MESH_TRANSPORT_TYPE)
throw new MatrixRTCError(
`Not a full mesh transport: ${transport.type}`,
);
localTransport = transport;
// Fetched now, so that the first subscriber isn't kept waiting
await iceServers();
prepared$.next(true);
return { canDelegateDelayedLeave: false };
},
delegateDelayedLeave: async () =>
Promise.reject(
new MatrixRTCError("Full mesh cannot take over the delayed leave"),
),
local,
mediaFor,
connections$: connections$ as Behavior<BackendConnection[]>,
setAudioOutputDeviceId: async (deviceId) => {
audioOutputDeviceId$.next(deviceId);
return Promise.resolve();
},
sendData: async (topic, text) => {
if (!prepared$.value)
throw new MatrixRTCError(
"Cannot send data: not publishing on full mesh",
);
const message = JSON.stringify({ topic, text });
for (const { connection } of publishers.values())
connection.sendData(message);
return Promise.resolve();
},
data$: data$.pipe(share()),
};
}
/** A message of the `sendData` data channel, or null for a malformed one. */
function parseDataMessage(
message: string,
): Pick<DataPacket, "topic" | "text"> | null {
try {
const { topic, text } = JSON.parse(message) as Record<string, unknown>;
return typeof topic === "string" && typeof text === "string"
? { topic, text }
: null;
} catch {
return null;
}
}
/** One peer connection as the developer tools show it. */
function describe(
{ userId, deviceId, memberId }: Peer,
{ connectionId, pc }: PeerConnectionBase,
): Record<string, string> {
return {
userId,
deviceId,
memberId,
connectionId,
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
signalingState: pc.signalingState,
};
}
@@ -0,0 +1,425 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
BehaviorSubject,
combineLatest,
filter,
firstValueFrom,
map,
NEVER,
} from "rxjs";
import { v4 as uuidv4 } from "uuid";
import { MatrixRTCError, toMatrixRTCError } from "../../errors";
import {
type LocalAudioMediaTrack,
type LocalVideoMediaTrack,
type MediaSource,
type PublishRequest,
type VideoProcessor,
} from "../../media-api";
import { type Behavior } from "../../reactive/Behavior";
import { ObservableScope } from "../../reactive/ObservableScope";
import { type LocalMediaBackend, type MediaConnectionState } from "../api";
import { createFullMeshMediaTrack, rtpStats } from "./MediaTrack";
type PublishedSource = Exclude<MediaSource, "unknown">;
/** One local track as the publisher connections send it. */
export interface LocalPublication {
source: PublishedSource;
kind: "audio" | "video";
/**
* The `MediaStream` it is sent in: one for the user media, one for the
* screen share, so that the subscriber plays each in sync.
*/
stream: MediaStream;
/** What goes on the wire; null while the source is off. */
track$: Behavior<MediaStreamTrack | null>;
mediaTrack: LocalAudioMediaTrack | LocalVideoMediaTrack;
}
export interface FullMeshLocalMedia extends LocalMediaBackend {
/** Every local track, once the first `setPublishing(true)` started the capture. */
publications$: Behavior<LocalPublication[]>;
/** Whether the tracks reach the publisher connections. */
publishing$: Behavior<boolean>;
}
interface Props {
scope: ObservableScope;
publish: PublishRequest[];
connectionState$: Behavior<MediaConnectionState | Error>;
/** The publisher connections, for the outbound statistics of a track. */
peerConnections: () => RTCPeerConnection[];
logger: Logger;
}
interface Capture {
/** What was captured, before any processor. */
raw: MediaStreamTrack;
out: MediaStreamTrack;
}
/** The local member's side: capture, the publication list and the controls. */
export function createFullMeshLocalMedia({
scope,
publish: initialRequests,
connectionState$,
peerConnections,
logger: parentLogger,
}: Props): FullMeshLocalMedia {
const logger = parentLogger.getChild("[LocalMedia]");
// Only their ids matter: the tracks are added per connection
const userMediaStream = new MediaStream();
const screenShareStream = new MediaStream();
const desired = new Map<PublishRequest["source"], PublishRequest>(
initialRequests.map((r) => [r.source, r]),
);
const started$ = new BehaviorSubject(false);
const publishing$ = new BehaviorSubject(false);
const publishError$ = new BehaviorSubject<Error | null>(null);
const publications$ = new BehaviorSubject<LocalPublication[]>([]);
const teardowns = new Map<PublishedSource, () => void>();
const statsFor = async (track: MediaStreamTrack | null) => {
for (const pc of peerConnections())
if (pc.getSenders().some((s) => s.track === track && track !== null))
return rtpStats(pc, track, "outbound-rtp");
return undefined;
};
/** Adds a publication whose track is managed by `control`. */
const addPublication = (
source: PublishedSource,
kind: "audio" | "video",
stream: MediaStream,
initial: MediaStreamTrack,
control: {
setEnabled: (
enabled: boolean,
track$: BehaviorSubject<MediaStreamTrack | null>,
) => Promise<boolean>;
setDevice?: (
deviceId: string,
track$: BehaviorSubject<MediaStreamTrack | null>,
) => Promise<void>;
switchFacingMode?: (
track$: BehaviorSubject<MediaStreamTrack | null>,
) => Promise<string | undefined>;
setProcessor?: (
p: VideoProcessor | undefined,
track$: BehaviorSubject<MediaStreamTrack | null>,
) => Promise<void>;
stop: () => void;
},
): LocalPublication => {
const pubScope = new ObservableScope();
const track$ = new BehaviorSubject<MediaStreamTrack | null>(initial);
const enabled$ = new BehaviorSubject(true);
const base = createFullMeshMediaTrack(pubScope, {
source,
kind,
id: uuidv4(),
track$,
muted$: pubScope.behavior(enabled$.pipe(map((e) => !e))),
stats: async () => statsFor(track$.value),
});
const local = {
setEnabled: async (enabled: boolean): Promise<boolean> => {
const result = await control.setEnabled(enabled, track$);
enabled$.next(result);
return result;
},
setDevice: async (deviceId: string): Promise<void> => {
if (!control.setDevice)
throw new MatrixRTCError(`A ${source} track has no device`);
await control.setDevice(deviceId, track$);
},
};
const mediaTrack =
base.kind === "audio"
? { ...base, ...local }
: {
...base,
...local,
facingMode$: pubScope.behavior(
track$.pipe(map((t) => facingModeOf(t))),
),
switchFacingMode: async () => control.switchFacingMode?.(track$),
setProcessor: async (p: VideoProcessor | undefined) =>
control.setProcessor?.(p, track$),
};
const publication: LocalPublication = {
source,
kind,
stream,
track$,
mediaTrack: mediaTrack as LocalAudioMediaTrack | LocalVideoMediaTrack,
};
publications$.next([...publications$.value, publication]);
teardowns.set(source, () => {
control.stop();
track$.value?.stop();
pubScope.end();
publications$.next(publications$.value.filter((p) => p !== publication));
teardowns.delete(source);
});
return publication;
};
const captureMicrophone = async (
request: Extract<PublishRequest, { source: "microphone" }>,
): Promise<void> => {
const track = await getTrack({
audio: { deviceId: exact(request.deviceId), ...request.capture },
});
addPublication("microphone", "audio", userMediaStream, track, {
// Muting a microphone keeps the device: unmuting is instant
setEnabled: async (enabled, track$) => {
if (track$.value) track$.value.enabled = enabled;
return Promise.resolve(enabled);
},
setDevice: async (deviceId, track$) => {
const next = await getTrack({
audio: { deviceId: { exact: deviceId }, ...request.capture },
});
next.enabled = track$.value?.enabled ?? true;
track$.value?.stop();
track$.next(next);
},
stop: () => {},
});
};
const captureCamera = async (
request: Extract<PublishRequest, { source: "camera" }>,
): Promise<void> => {
let deviceId = request.deviceId;
let facingMode: string | undefined;
let processor = request.processor;
let capture: Capture | null = null;
let processorStarted = false;
const constraints = (): MediaTrackConstraints => ({
deviceId: exact(deviceId),
facingMode,
width: request.capture?.resolution?.width,
height: request.capture?.resolution?.height,
frameRate: request.capture?.resolution?.frameRate,
});
const open = async (): Promise<Capture> => {
const raw = await getTrack({ video: constraints() });
if (processor === undefined) return { raw, out: raw };
const out = processorStarted
? await processor.restart(raw)
: await processor.init(raw);
processorStarted = true;
return { raw, out };
};
const close = (): void => {
capture?.raw.stop();
if (capture?.out !== capture?.raw) capture?.out.stop();
capture = null;
};
// Reopens the camera with the current settings, if it is on
const reopen = async (
track$: BehaviorSubject<MediaStreamTrack | null>,
): Promise<void> => {
if (capture === null) return;
close();
capture = await open();
track$.next(capture.out);
};
capture = await open();
addPublication("camera", "video", userMediaStream, capture.out, {
// Muting a camera releases it, so that its light goes off
setEnabled: async (enabled, track$) => {
if (enabled === (capture !== null)) return enabled;
if (!enabled) {
close();
track$.next(null);
return false;
}
try {
capture = await open();
track$.next(capture.out);
return true;
} catch (e) {
logger.warn("Could not reopen the camera", e);
return false;
}
},
setDevice: async (id, track$) => {
deviceId = id;
facingMode = undefined;
await reopen(track$);
},
switchFacingMode: async (track$) => {
const current = facingModeOf(track$.value);
if (current === undefined) return undefined;
deviceId = undefined;
facingMode = current === "user" ? "environment" : "user";
await reopen(track$);
return capture?.raw.getSettings().deviceId;
},
setProcessor: async (p, track$) => {
await processor?.destroy();
processor = p;
processorStarted = false;
if (capture === null) return;
const out = p ? await p.init(capture.raw) : capture.raw;
processorStarted = p !== undefined;
if (capture.out !== capture.raw) capture.out.stop();
capture = { raw: capture.raw, out };
track$.next(out);
},
stop: () => {
close();
void processor?.destroy();
},
});
};
const captureScreen = async (
request: Extract<PublishRequest, { source: "screenShare" }>,
): Promise<void> => {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
width: request.capture?.resolution?.width,
height: request.capture?.resolution?.height,
frameRate: request.capture?.resolution?.frameRate,
},
audio: request.audio ?? true,
});
const [video] = stream.getVideoTracks();
const [audio] = stream.getAudioTracks();
const toggle = async (
enabled: boolean,
track$: BehaviorSubject<MediaStreamTrack | null>,
): Promise<boolean> => {
if (track$.value) track$.value.enabled = enabled;
return Promise.resolve(enabled);
};
addPublication("screenShare", "video", screenShareStream, video, {
setEnabled: toggle,
// The audio goes with its video
stop: () => {
audio?.stop();
teardowns.get("screenShareAudio")?.();
},
});
if (audio)
addPublication("screenShareAudio", "audio", screenShareStream, audio, {
setEnabled: toggle,
stop: () => {},
});
// Stopped from the browser's own controls
video.addEventListener("ended", () => void unpublish("screenShare"));
};
const capture = async (request: PublishRequest): Promise<void> => {
teardowns.get(request.source)?.();
switch (request.source) {
case "microphone":
return captureMicrophone(request);
case "camera":
return captureCamera(request);
case "screenShare":
return captureScreen(request);
}
};
const start = async (): Promise<void> => {
for (const request of desired.values())
await capture(request).catch((e: unknown) => {
logger.warn(`Could not capture ${request.source}`, e);
if (publishError$.value === null)
publishError$.next(toMatrixRTCError(e));
});
};
const setPublishing = (publish: boolean): void => {
publishing$.next(publish);
if (publish && !started$.value) {
started$.next(true);
void start();
}
};
const publish = async (
request: PublishRequest,
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack> => {
desired.set(request.source, request);
try {
// Before the start, the start captures everything desired itself
if (started$.value) await capture(request);
return await firstValueFrom(
publications$.pipe(
map((ps) => ps.find((p) => p.source === request.source)),
filter((p) => p !== undefined),
map((p) => p.mediaTrack),
),
);
} catch (e) {
desired.delete(request.source);
throw toMatrixRTCError(e);
}
};
const unpublish = async (source: MediaSource): Promise<void> => {
const published = source === "screenShareAudio" ? "screenShare" : source;
if (published === "unknown") return;
desired.delete(published);
teardowns.get(published)?.();
return Promise.resolve();
};
scope.onEnd(() => {
// Each teardown removes itself from the map
for (const teardown of Array.from(teardowns.values())) teardown();
});
const tracks$ = scope.behavior(
combineLatest([started$, publications$]).pipe(
map(([started, ps]) => (started ? ps.map((p) => p.mediaTrack) : null)),
),
);
return {
connectionState$,
tracks$,
encryptionError$: NEVER,
setPublishing,
publishError$,
publish,
unpublish,
publications$,
publishing$,
};
}
function exact(deviceId: string | undefined): ConstrainDOMString | undefined {
return deviceId === undefined ? undefined : { exact: deviceId };
}
async function getTrack(
constraints: MediaStreamConstraints,
): Promise<MediaStreamTrack> {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const [track] = stream.getTracks();
if (track === undefined) throw new MatrixRTCError("No track was captured");
return track;
}
function facingModeOf(
track: MediaStreamTrack | null,
): "user" | "environment" | undefined {
const mode = track?.getSettings().facingMode;
return mode === "user" || mode === "environment" ? mode : undefined;
}
@@ -0,0 +1,325 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
map,
Observable,
of,
scan,
switchMap,
timer,
} from "rxjs";
import { type Behavior, constant } from "../../reactive/Behavior";
import { type ObservableScope } from "../../reactive/ObservableScope";
import {
type AudioMediaTrack,
type MediaSource,
type MediaStreamStats,
type MediaTrack,
type VideoMediaTrack,
} from "../../media-api";
export interface TrackProps {
source: MediaSource;
kind: "audio" | "video";
id: string;
/** The track behind it; may change, as on a device switch, or be null while off. */
track$: Behavior<MediaStreamTrack | null>;
muted$: Behavior<boolean>;
/** Statistics of the RTP stream carrying it, if there is one. */
stats: () => Promise<MediaStreamStats>;
/** Where attached elements play; only for tracks that are played. */
audioOutputDeviceId$?: Behavior<string | undefined>;
/** Whether to follow where attached video elements are shown, for `viewport$`. */
observeViewport?: boolean;
}
/** Where a video is shown: the largest of its visible elements, in device pixels if known. */
export interface Viewport {
width?: number;
height?: number;
}
export type FullMeshMediaTrack = (AudioMediaTrack | VideoMediaTrack) & {
/** Null while no attached element is visible; always null unless observed. */
viewport$: Behavior<Viewport | null>;
};
/** Above this level a track turns active; below `INACTIVE_LEVEL` for `INACTIVE_AFTER_MS` it turns inactive. */
const ACTIVE_LEVEL = 0.04;
const INACTIVE_LEVEL = 0.02;
const LEVEL_INTERVAL_MS = 100;
const INACTIVE_AFTER_MS = 500;
let levelContext: AudioContext | undefined;
/**
* A `MediaStreamTrack` as a `MediaTrack`: the same object for the life of a
* publication, with every attached element following the track behind it.
*/
export function createFullMeshMediaTrack(
scope: ObservableScope,
props: TrackProps,
): FullMeshMediaTrack {
const { source, kind, id, track$, muted$, stats, audioOutputDeviceId$ } =
props;
const attached = new Set<HTMLMediaElement>();
const viewport =
kind === "video" && props.observeViewport
? observeViewport(scope)
: undefined;
let audioContext: AudioContext | undefined;
let audioPlugins: AudioNode[] = [];
let volume = 1;
let webAudio: { nodes: AudioNode[]; gain: GainNode } | undefined;
const routeWebAudio = (track: MediaStreamTrack | null): void => {
for (const node of webAudio?.nodes ?? []) node.disconnect();
webAudio = undefined;
if (audioContext === undefined || track === null) return;
const input = audioContext.createMediaStreamSource(
new MediaStream([track]),
);
const gain = audioContext.createGain();
gain.gain.value = volume;
const chain: AudioNode[] = [input, ...audioPlugins, gain];
for (let i = 0; i < chain.length - 1; i++) chain[i].connect(chain[i + 1]);
gain.connect(audioContext.destination);
webAudio = { nodes: chain, gain };
};
const apply = (element: HTMLMediaElement): void => {
const track = track$.value;
element.srcObject = track ? new MediaStream([track]) : null;
if (kind === "audio") {
// Played through Web Audio instead, where it is routed there
element.muted = audioContext !== undefined;
element.volume = volume;
const sinkId = audioOutputDeviceId$?.value;
if (sinkId !== undefined && "setSinkId" in element)
void element.setSinkId(sinkId).catch(() => {});
}
if (element.paused) void element.play().catch(() => {});
};
track$.pipe(scope.bind()).subscribe((track) => {
for (const element of attached) apply(element);
if (kind === "audio") routeWebAudio(track);
});
audioOutputDeviceId$?.pipe(scope.bind()).subscribe(() => {
for (const element of attached) apply(element);
});
scope.onEnd(() => {
for (const element of attached) element.srcObject = null;
attached.clear();
routeWebAudio(null);
});
const base: MediaTrack = {
source,
kind,
id,
muted$,
// DTLS-SRTP, its fingerprints exchanged over Olm
encrypted: true,
stats$: (intervalMs): Observable<MediaStreamStats> =>
timer(0, intervalMs).pipe(
switchMap(async () => stats()),
scope.bind(),
),
attach: (element) => {
if (attached.has(element)) return;
attached.add(element);
apply(element);
viewport?.observe(element);
},
detach: (element) => {
if (!attached.delete(element)) return;
element.srcObject = null;
viewport?.unobserve(element);
},
};
if (kind === "video")
return {
...base,
kind: "video",
viewport$: viewport?.viewport$ ?? constant(null),
};
const audioLevel$ = scope.behavior(
combineLatest([track$, muted$]).pipe(
switchMap(([track, muted]) =>
track === null || muted ? of(0) : level$(track),
),
),
0,
);
const isActive$ = scope.behavior(
audioLevel$.pipe(
scan(
({ active, quietMs }, level) => {
if (level >= ACTIVE_LEVEL) return { active: true, quietMs: 0 };
if (level >= INACTIVE_LEVEL) return { active, quietMs: 0 };
const quiet = quietMs + LEVEL_INTERVAL_MS;
return {
active: active && quiet < INACTIVE_AFTER_MS,
quietMs: quiet,
};
},
{ active: false, quietMs: 0 },
),
map(({ active }) => active),
distinctUntilChanged(),
),
false,
);
return {
...base,
kind: "audio",
viewport$: constant(null),
audioLevel$,
isActive$,
setAudioContext: (ctx, plugins = []) => {
audioContext = ctx;
audioPlugins = plugins;
routeWebAudio(track$.value);
for (const element of attached) apply(element);
},
setVolume: (v) => {
volume = v;
if (webAudio) webAudio.gain.gain.value = v;
for (const element of attached) element.volume = v;
},
};
}
/**
* Follows the size and visibility of elements. Where the platform has no
* observers, an element counts as visible at an unknown size.
*/
function observeViewport(scope: ObservableScope): {
viewport$: Behavior<Viewport | null>;
observe: (element: Element) => void;
unobserve: (element: Element) => void;
} {
const shown = new Map<
Element,
{ visible: boolean; width?: number; height?: number }
>();
const viewport$ = new BehaviorSubject<Viewport | null>(null);
const update = (): void => {
const visible = [...shown.values()].filter(
({ visible, width, height }) => visible && width !== 0 && height !== 0,
);
// One element of unknown size leaves the size unknown
const next: Viewport | null =
visible.length === 0
? null
: visible.some((e) => e.width === undefined || e.height === undefined)
? {}
: {
width: Math.max(...visible.map((e) => e.width!)),
height: Math.max(...visible.map((e) => e.height!)),
};
const current = viewport$.value;
if (
(current === null) !== (next === null) ||
current?.width !== next?.width ||
current?.height !== next?.height
)
viewport$.next(next);
};
const pixelRatio = globalThis.devicePixelRatio ?? 1;
const resizes =
typeof ResizeObserver === "undefined"
? undefined
: new ResizeObserver((entries) => {
for (const { target, contentRect } of entries) {
const entry = shown.get(target);
if (entry === undefined) continue;
entry.width = Math.ceil(contentRect.width * pixelRatio);
entry.height = Math.ceil(contentRect.height * pixelRatio);
}
update();
});
const intersections =
typeof IntersectionObserver === "undefined"
? undefined
: new IntersectionObserver((entries) => {
for (const { target, isIntersecting } of entries) {
const entry = shown.get(target);
if (entry !== undefined) entry.visible = isIntersecting;
}
update();
});
scope.onEnd(() => {
resizes?.disconnect();
intersections?.disconnect();
});
return {
viewport$: scope.behavior(viewport$),
observe: (element): void => {
// Visible until the observers say otherwise, so that nothing is paused
// before they first report
shown.set(element, { visible: true });
resizes?.observe(element);
intersections?.observe(element);
update();
},
unobserve: (element): void => {
shown.delete(element);
resizes?.unobserve(element);
intersections?.unobserve(element);
update();
},
};
}
/** The RMS level of an audio track, 0 to 1, sampled while subscribed. */
function level$(track: MediaStreamTrack): Observable<number> {
return new Observable<number>((subscriber) => {
levelContext ??= new AudioContext();
const ctx = levelContext;
const input = ctx.createMediaStreamSource(new MediaStream([track]));
const analyser = ctx.createAnalyser();
analyser.fftSize = 512;
input.connect(analyser);
const samples = new Float32Array(analyser.fftSize);
const sub = timer(0, LEVEL_INTERVAL_MS).subscribe(() => {
analyser.getFloatTimeDomainData(samples);
let sum = 0;
for (const s of samples) sum += s * s;
subscriber.next(Math.min(1, Math.sqrt(sum / samples.length) * 4));
});
return () => {
sub.unsubscribe();
input.disconnect();
};
});
}
/** The inbound or outbound RTP statistics of one track on a peer connection. */
export async function rtpStats(
pc: RTCPeerConnection | undefined,
track: MediaStreamTrack | null,
direction: "inbound-rtp" | "outbound-rtp",
): Promise<MediaStreamStats> {
if (pc === undefined || track === null) return undefined;
const report = await pc.getStats(track);
for (const stats of report.values())
if ((stats as RTCStats).type === direction)
return stats as RTCInboundRtpStreamStats & RTCOutboundRtpStreamStats;
return undefined;
}
@@ -0,0 +1,248 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { ObservableScope } from "../../reactive/ObservableScope";
import { MediaConnectionState } from "../api";
import {
CANDIDATE_BATCH_MS,
createPeerConnectionBase,
type PeerConnectionBase,
} from "./PeerConnection";
import { type ControlMessage, CONTROL_CHANNEL_LABEL } from "./ControlChannel";
import {
type FakeSignalling,
fakeSignalling,
installWebRtcFakes,
lastPeerConnection,
peer,
settle,
} from "./test";
describe("createPeerConnectionBase", () => {
let scope: ObservableScope;
let signalling: FakeSignalling;
let base: PeerConnectionBase;
let received: ControlMessage[];
beforeEach(() => {
vi.useFakeTimers();
installWebRtcFakes();
scope = new ObservableScope();
signalling = fakeSignalling();
received = [];
base = createPeerConnectionBase({
scope,
peer,
connectionId: "c1",
signalling,
iceServers: [{ urls: ["turn:example.org"] }],
onControlMessage: (message) => received.push(message),
logger,
});
});
afterEach(() => {
scope.end();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("uses the ICE servers it was given", () => {
expect(lastPeerConnection().config.iceServers).toEqual([
{ urls: ["turn:example.org"] },
]);
});
it("sends the candidates gathered within a batch in one message", () => {
const pc = lastPeerConnection();
pc.gather({ candidate: "a", sdpMid: "0", sdpMLineIndex: 0 });
pc.gather({ candidate: "b", sdpMid: "0", sdpMLineIndex: 0 });
pc.gather(null);
expect(signalling.send).not.toHaveBeenCalled();
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
expect(signalling.sent()).toEqual([
{
type: "candidates",
candidates: [
{ candidate: "a", sdpMid: "0", sdpMLineIndex: 0 },
{ candidate: "b", sdpMid: "0", sdpMLineIndex: 0 },
// The end of the gathering
{ candidate: "", sdpMid: null, sdpMLineIndex: null },
],
},
]);
expect(signalling.sentConnectionIds()).toEqual(["c1"]);
});
it("drops pending candidates when it closes", () => {
lastPeerConnection().gather({ candidate: "a" });
base.close("bye", false);
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
expect(signalling.send).not.toHaveBeenCalled();
});
it("runs queued operations in order, past a failing one", async () => {
const order: string[] = [];
base.enqueue(async () => {
order.push("first");
return Promise.reject(new Error("failed"));
});
base.enqueue(async () => {
order.push("second");
return Promise.resolve();
});
await settle();
expect(order).toEqual(["first", "second"]);
});
it("applies received candidates, ignoring one that fails", async () => {
base.addCandidates([{ candidate: "bad" }, { candidate: "good" }]);
await settle();
expect(lastPeerConnection().addedCandidates).toEqual([
{ candidate: "good" },
]);
});
it("tells the peer once when it closes", () => {
base.close("done", true);
base.close("again", true);
expect(lastPeerConnection().closed).toBe(true);
expect(signalling.sent()).toEqual([{ type: "close", reason: "done" }]);
});
it("doesn't tell a peer that closed first", () => {
base.close("theirs", false);
expect(lastPeerConnection().closed).toBe(true);
expect(signalling.send).not.toHaveBeenCalled();
});
it("closes when its scope ends", () => {
scope.end();
expect(lastPeerConnection().closed).toBe(true);
expect(signalling.sent()).toEqual([{ type: "close", reason: "ended" }]);
});
it("maps the connection state", () => {
const pc = lastPeerConnection();
expect(base.state$.value).toBe(MediaConnectionState.Initialized);
pc.setConnectionState("connecting");
expect(base.state$.value).toBe(MediaConnectionState.Connecting);
pc.setConnectionState("connected");
expect(base.state$.value).toBe(MediaConnectionState.Connected);
pc.setConnectionState("disconnected");
expect(base.state$.value).toBe(MediaConnectionState.Reconnecting);
pc.setConnectionState("failed");
expect(base.state$.value).toBeInstanceOf(Error);
pc.setConnectionState("closed");
expect(base.state$.value).toBe(MediaConnectionState.Stopped);
});
describe("the control channel", () => {
const offer: ControlMessage = {
type: "description",
description: { type: "offer", sdp: "o" },
};
const metadata = (muted: boolean): ControlMessage => ({
type: "track_metadata",
track_metadata: { "0": { purpose: "m.usermedia", muted } },
});
it("is negotiated, with the reserved label and id", () => {
base.openControlChannel();
base.openControlChannel();
const [channel] = lastPeerConnection().dataChannels;
expect(lastPeerConnection().dataChannels).toHaveLength(1);
expect(channel.label).toBe(CONTROL_CHANNEL_LABEL);
expect(channel.init).toEqual({ negotiated: true, id: 0 });
});
it("carries every message once it is open, apart from close", () => {
base.openControlChannel();
base.send(offer);
expect(signalling.sent()).toEqual([offer]);
const channel = lastPeerConnection().control();
channel.open();
base.answerReceived();
base.send(offer);
lastPeerConnection().gather({ candidate: "a" });
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
expect(channel.sentMessages()).toEqual([
offer,
{ type: "candidates", candidates: [{ candidate: "a" }] },
]);
base.close("done", true);
expect(signalling.sent()).toEqual([
offer,
{ type: "close", reason: "done" },
]);
});
it("is only switched to once no to-device offer awaits its answer", () => {
base.openControlChannel();
base.send(offer);
base.send(metadata(true));
const channel = lastPeerConnection().control();
channel.open();
// A late to-device offer could otherwise overwrite this metadata
lastPeerConnection().gather({ candidate: "a" });
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
expect(channel.sentMessages()).toEqual([]);
expect(signalling.sent()).toEqual([
offer,
{ type: "candidates", candidates: [{ candidate: "a" }] },
]);
base.answerReceived();
expect(channel.sentMessages()).toEqual([metadata(true)]);
});
it("holds the latest metadata until it opens", () => {
base.openControlChannel();
base.send(metadata(true));
base.send(metadata(false));
expect(signalling.send).not.toHaveBeenCalled();
const channel = lastPeerConnection().control();
channel.open();
expect(channel.sentMessages()).toEqual([metadata(false)]);
});
it("hands over the messages it receives, ignoring what it doesn't know", () => {
base.openControlChannel();
const channel = lastPeerConnection().control();
channel.open();
channel.receive(metadata(true));
channel.receive({ type: "future_message" });
channel.receive("not json");
channel.receive({ type: "description", description: { type: "x" } });
expect(received).toEqual([metadata(true)]);
});
it("fails the connection when it closes on its own", () => {
base.openControlChannel();
const pc = lastPeerConnection();
pc.setConnectionState("connected");
pc.control().open();
pc.control().close();
expect(base.state$.value).toEqual(
new Error("The control channel closed"),
);
});
it("doesn't fail a connection we close", () => {
base.openControlChannel();
lastPeerConnection().control().open();
base.close("done", true);
expect(base.state$.value).not.toBeInstanceOf(Error);
});
});
});
@@ -0,0 +1,226 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/** What the publisher and the subscriber side of a connection share. */
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
BehaviorSubject,
combineLatest,
fromEvent,
map,
startWith,
} from "rxjs";
import { type Behavior } from "../../reactive/Behavior";
import { type ObservableScope } from "../../reactive/ObservableScope";
import { MediaConnectionState } from "../api";
import {
type ControlMessage,
createControlChannel,
parseControlMessage,
} from "./ControlChannel";
import { type Peer, type Signalling } from "./signalling";
/** How long trickled candidates are collected before they are sent in one message. */
export const CANDIDATE_BATCH_MS = 200;
type MetadataMessage = Extract<
ControlMessage,
{ type: "track_metadata" | "consumption_metadata" }
>;
export interface PeerConnectionBase {
readonly connectionId: string;
readonly pc: RTCPeerConnection;
readonly state$: Behavior<MediaConnectionState | Error>;
/** Runs `op` after every operation queued before it, so that SDP and candidates apply in order. */
enqueue(op: () => Promise<void>): void;
/**
* Creates our side of the control channel. The publisher does so before
* its first offer, the subscriber once it applied that offer.
*/
openControlChannel(): void;
/**
* Sends a message to the peer: over the control channel once we switched
* to it, before that as a to-device message. Metadata has no to-device
* message of its own: the latest waits for the switch.
*/
send(message: ControlMessage): void;
/**
* Tells that the answer to our latest offer arrived. We switch to the
* control channel only once no to-device offer is waiting for its answer,
* so that a late offer can't overwrite newer metadata.
*/
answerReceived(): void;
/** Applies a received batch of candidates. */
addCandidates(candidates: RTCIceCandidateInit[]): void;
/** Closes the peer connection, telling the peer unless it closed first. */
close(reason: string | undefined, notifyPeer: boolean): void;
}
export function createPeerConnectionBase({
scope,
peer,
connectionId,
signalling,
iceServers,
onControlMessage,
logger,
}: {
scope: ObservableScope;
peer: Peer;
connectionId: string;
signalling: Signalling;
iceServers: RTCIceServer[];
/** A message the peer sent over the control channel. */
onControlMessage: (message: ControlMessage) => void;
logger: Logger;
}): PeerConnectionBase {
const pc = new RTCPeerConnection({ iceServers });
const closed$ = new BehaviorSubject(false);
const controlFailed$ = new BehaviorSubject(false);
let queue = Promise.resolve();
const enqueue = (op: () => Promise<void>): void => {
queue = queue.then(op).catch((e: unknown) => {
logger.warn("Signalling operation failed", e);
});
};
let control: RTCDataChannel | null = null;
let switched = false;
let awaitingAnswer = false;
const pendingMetadata = new Map<MetadataMessage["type"], MetadataMessage>();
const trySwitch = (): void => {
if (switched || awaitingAnswer || control?.readyState !== "open") return;
switched = true;
logger.debug("Switched to the control channel");
for (const message of pendingMetadata.values())
control.send(JSON.stringify(message));
pendingMetadata.clear();
};
const openControlChannel = (): void => {
if (control !== null) return;
const channel = createControlChannel(pc);
control = channel;
channel.addEventListener("open", trySwitch);
channel.addEventListener("message", ({ data }: MessageEvent) => {
const message = parseControlMessage(data);
if (message === null) logger.debug("Ignoring a control message", data);
else onControlMessage(message);
});
// Ours to close only with the connection: closing otherwise is a failure
channel.addEventListener("close", () => {
if (!closed$.value) controlFailed$.next(true);
});
};
const send = (message: ControlMessage): void => {
if (closed$.value) return;
if (switched) control!.send(JSON.stringify(message));
else if (
message.type === "track_metadata" ||
message.type === "consumption_metadata"
)
pendingMetadata.set(message.type, message);
else {
if (
message.type === "description" &&
message.description.type === "offer"
)
awaitingAnswer = true;
signalling.send(peer, connectionId, message);
}
};
const answerReceived = (): void => {
awaitingAnswer = false;
trySwitch();
};
// Trickled, in batches
let pending: RTCIceCandidateInit[] = [];
let batchTimer: ReturnType<typeof setTimeout> | undefined;
const flushCandidates = (): void => {
batchTimer = undefined;
if (pending.length === 0) return;
send({ type: "candidates", candidates: pending });
pending = [];
};
pc.addEventListener("icecandidate", ({ candidate }) => {
// A null candidate ends the gathering: an empty one says so on the wire
pending.push(
candidate
? candidate.toJSON()
: { candidate: "", sdpMid: null, sdpMLineIndex: null },
);
batchTimer ??= setTimeout(flushCandidates, CANDIDATE_BATCH_MS);
});
const addCandidates: PeerConnectionBase["addCandidates"] = (candidates) =>
enqueue(async () => {
for (const candidate of candidates)
await pc
.addIceCandidate(candidate)
// A candidate of an outdated description, or a bad one: not fatal
.catch((e: unknown) => logger.debug("Ignored a candidate", e));
});
const close: PeerConnectionBase["close"] = (reason, notifyPeer) => {
if (closed$.value) return;
clearTimeout(batchTimer);
// Always to-device: closing the peer connection would race a channel message
if (notifyPeer)
signalling.send(peer, connectionId, { type: "close", reason });
closed$.next(true);
pc.close();
};
scope.onEnd(() => close("ended", true));
const state$ = scope.behavior(
combineLatest([
fromEvent(pc, "connectionstatechange").pipe(startWith(null)),
controlFailed$,
]).pipe(
map(([, controlFailed]) =>
controlFailed && pc.connectionState !== "closed"
? new Error("The control channel closed")
: toState(pc.connectionState),
),
),
);
return {
connectionId,
pc,
state$,
enqueue,
openControlChannel,
send,
answerReceived,
addCandidates,
close,
};
}
function toState(state: RTCPeerConnectionState): MediaConnectionState | Error {
switch (state) {
case "new":
return MediaConnectionState.Initialized;
case "connecting":
return MediaConnectionState.Connecting;
case "connected":
return MediaConnectionState.Connected;
case "disconnected":
return MediaConnectionState.Reconnecting;
case "failed":
return new Error("The peer connection failed");
case "closed":
return MediaConnectionState.Stopped;
}
}
@@ -0,0 +1,360 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { ObservableScope } from "../../reactive/ObservableScope";
import { CONTROL_CHANNEL_LABEL } from "./ControlChannel";
import { type FullMeshLocalMedia } from "./LocalMedia";
import { DATA_CHANNEL_LABEL, DATA_PROTOCOL } from "./metadata";
import {
createPublisherConnection,
type PublisherConnection,
} from "./PublisherConnection";
import {
type FakeSignalling,
fakeLocalMedia,
fakePublication,
fakeSignalling,
fakeTrack,
fromPeer,
type PeerMessage,
installWebRtcFakes,
lastPeerConnection,
peer,
settle,
} from "./test";
describe("createPublisherConnection", () => {
let scope: ObservableScope;
let signalling: FakeSignalling;
let local: ReturnType<typeof fakeLocalMedia>;
function create(): PublisherConnection {
return createPublisherConnection({
scope,
peer,
connectionId: "c1",
signalling,
iceServers: [],
local: local as unknown as FullMeshLocalMedia,
logger,
});
}
const answer = (): PeerMessage =>
fromPeer("c1", {
type: "description",
description: { type: "answer", sdp: "answer" },
});
beforeEach(() => {
installWebRtcFakes();
scope = new ObservableScope();
signalling = fakeSignalling();
local = fakeLocalMedia();
});
afterEach(() => {
scope.end();
vi.unstubAllGlobals();
});
it("offers right away, even without tracks", async () => {
create();
await settle();
expect(signalling.sent("description")).toEqual([
{
type: "description",
description: { type: "offer", sdp: "offer-1" },
track_metadata: {},
},
]);
});
it("describes each track by the mid it gets with the offer", async () => {
const mic = fakePublication("audio");
const camera = fakePublication("video", { stream: mic.stream });
const screen = fakePublication("video", { source: "screenShare" });
camera.muted$.next(true);
local.publications$.next([mic, camera, screen]);
create();
await settle();
expect(signalling.sent("description")).toEqual([
expect.objectContaining({
track_metadata: {
"0": { purpose: "m.usermedia", muted: false },
"1": { purpose: "m.usermedia", muted: true },
"2": { purpose: "m.screenshare", muted: false },
},
}),
]);
});
it("creates the control channel and the data channel before its first offer", () => {
create();
const [control, data] = lastPeerConnection().dataChannels;
expect(control.label).toBe(CONTROL_CHANNEL_LABEL);
expect(data).toMatchObject({
label: DATA_CHANNEL_LABEL,
protocol: DATA_PROTOCOL,
});
expect(lastPeerConnection().offers).toBe(0);
});
it("sends each publication on a sendonly transceiver in its stream", async () => {
const mic = fakePublication("audio");
const camera = fakePublication("video", { stream: mic.stream });
local.publications$.next([mic, camera]);
create();
await settle();
const [a, v] = lastPeerConnection().transceivers;
expect(a).toMatchObject({ kind: "audio", direction: "sendonly" });
expect(a.streams).toEqual([mic.stream]);
expect(a.sender.track).toBe(mic.track$.value);
expect(v).toMatchObject({ kind: "video", direction: "sendonly" });
expect(v.sender.track).toBe(camera.track$.value);
// One offer for both
expect(signalling.sent("description")).toHaveLength(1);
});
it("follows a track change without renegotiating", async () => {
const camera = fakePublication("video");
local.publications$.next([camera]);
create();
await settle();
const next = fakeTrack("video");
camera.track$.next(next);
expect(lastPeerConnection().transceivers[0].sender.track).toBe(next);
expect(signalling.sent("description")).toHaveLength(1);
});
it("sends nothing while not publishing", async () => {
const camera = fakePublication("video");
local.publications$.next([camera]);
local.publishing$.next(false);
create();
await settle();
const { sender } = lastPeerConnection().transceivers[0];
expect(sender.track).toBeNull();
local.publishing$.next(true);
expect(sender.track).toBe(camera.track$.value);
});
it("holds changes while an offer is unanswered, then offers once for all", async () => {
const connection = create();
const pc = lastPeerConnection();
await settle();
expect(pc.offers).toBe(1);
local.publications$.next([fakePublication("audio")]);
local.publications$.next([
...local.publications$.value,
fakePublication("video"),
]);
await settle();
expect(pc.offers).toBe(1);
connection.handle(answer());
await settle();
expect(pc.offers).toBe(2);
expect(pc.transceivers).toHaveLength(2);
// Nothing changed since: the next answer doesn't lead to another offer
connection.handle(answer());
await settle();
expect(pc.offers).toBe(2);
});
it("stops the transceiver of a removed publication and renegotiates", async () => {
const camera = fakePublication("video");
local.publications$.next([camera]);
const connection = create();
await settle();
connection.handle(answer());
await settle();
local.publications$.next([]);
await settle();
const pc = lastPeerConnection();
expect(pc.transceivers[0].stopped).toBe(true);
expect(pc.offers).toBe(2);
});
it("signals mute changes over the control channel without renegotiating", async () => {
const mic = fakePublication("audio");
local.publications$.next([mic]);
const connection = create();
await settle();
connection.handle(answer());
await settle();
const control = lastPeerConnection().control();
control.open();
mic.muted$.next(true);
expect(control.sentMessages()).toEqual([
{
type: "track_metadata",
track_metadata: { "0": { purpose: "m.usermedia", muted: true } },
},
]);
expect(lastPeerConnection().offers).toBe(1);
});
it("holds a mute change until its to-device offer is answered", async () => {
const mic = fakePublication("audio");
local.publications$.next([mic]);
const connection = create();
await settle();
const control = lastPeerConnection().control();
control.open();
mic.muted$.next(true);
expect(control.sentMessages()).toEqual([]);
connection.handle(answer());
await settle();
expect(control.sentMessages()).toEqual([
{
type: "track_metadata",
track_metadata: { "0": { purpose: "m.usermedia", muted: true } },
},
]);
});
it("renegotiates over the control channel once it is open", async () => {
const connection = create();
await settle();
connection.handle(answer());
await settle();
const control = lastPeerConnection().control();
control.open();
local.publications$.next([fakePublication("audio")]);
await settle();
expect(control.sentMessages()).toContainEqual(
expect.objectContaining({
type: "description",
description: { type: "offer", sdp: "offer-2" },
}),
);
expect(signalling.sent("description")).toHaveLength(1);
// The answer comes back the same way
control.receive({
type: "description",
description: { type: "answer", sdp: "answer-2" },
});
await settle();
expect(lastPeerConnection().remoteDescription).toEqual({
type: "answer",
sdp: "answer-2",
});
});
describe("consumption metadata", () => {
/** A connection sending one camera track, with its control channel open. */
async function sendingCamera(settings: MediaTrackSettings = {}): Promise<{
camera: ReturnType<typeof fakePublication>;
consume: (metadata: unknown) => Promise<void>;
}> {
const camera = fakePublication("video");
camera.track$.next(fakeTrack("video", settings));
local.publications$.next([camera]);
const connection = create();
await settle();
connection.handle(answer());
await settle();
const control = lastPeerConnection().control();
control.open();
return {
camera,
consume: async (metadata): Promise<void> => {
control.receive({
type: "consumption_metadata",
consumption_metadata: metadata,
});
await settle();
},
};
}
it("stops sending a track that isn't in use, without renegotiating", async () => {
const { camera, consume } = await sendingCamera();
const { sender } = lastPeerConnection().transceivers[0];
await consume({ "0": { in_use: false } });
expect(sender.track).toBeNull();
await consume({});
expect(sender.track).toBe(camera.track$.value);
expect(lastPeerConnection().offers).toBe(1);
});
it("scales the video down to the largest size shown", async () => {
const { consume } = await sendingCamera({ width: 1280, height: 720 });
const { sender } = lastPeerConnection().transceivers[0];
await consume({ "0": { max_width: 320, max_height: 320 } });
expect(sender.parameters.encodings).toEqual([
{ scaleResolutionDownBy: 4 },
]);
// Larger than the camera: sent as it is
await consume({ "0": { max_width: 4000, max_height: 4000 } });
expect(sender.parameters.encodings).toEqual([
{ scaleResolutionDownBy: 1 },
]);
});
});
it("sends data once the data channel is open", () => {
const connection = create();
expect(connection.sendData("early")).toBe(false);
const data = lastPeerConnection().dataChannels[1];
data.open();
expect(connection.sendData("hello")).toBe(true);
expect(data.sent).toEqual(["hello"]);
});
it("closes, telling the subscriber, when the control channel fails", async () => {
create();
await settle();
const pc = lastPeerConnection();
pc.control().open();
pc.control().close();
expect(pc.closed).toBe(true);
expect(signalling.sent("close")).toEqual([
{ type: "close", reason: "failed" },
]);
});
it("applies the subscriber's candidates", async () => {
const connection = create();
await settle();
connection.handle(answer());
connection.handle(
fromPeer("c1", {
type: "candidates",
candidates: [{ candidate: "x" }],
}),
);
await settle();
expect(lastPeerConnection().addedCandidates).toEqual([{ candidate: "x" }]);
});
it("ends without answering when the subscriber closes", async () => {
const connection = create();
await settle();
connection.handle(fromPeer("c1", { type: "close", reason: "bye" }));
const pc = lastPeerConnection();
expect(pc.closed).toBe(true);
expect(signalling.sent("close")).toEqual([]);
// Its scope ended: no more offers go out
local.publications$.next([fakePublication("audio")]);
await settle();
expect(pc.offers).toBe(1);
});
});
@@ -0,0 +1,281 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
filter,
map,
merge,
type Observable,
skip,
type Subscription,
switchMap,
} from "rxjs";
import { type ObservableScope } from "../../reactive/ObservableScope";
import { type FullMeshLocalMedia, type LocalPublication } from "./LocalMedia";
import {
type ConsumptionMetadata,
type ConsumptionMetadataEntry,
DATA_CHANNEL_LABEL,
DATA_PROTOCOL,
type MediaPurpose,
type TrackMetadata,
} from "./metadata";
import {
createPeerConnectionBase,
type PeerConnectionBase,
} from "./PeerConnection";
import {
type ConnectionMessage,
type Peer,
type Signalling,
} from "./signalling";
export interface PublisherConnection extends PeerConnectionBase {
/** A to-device message of this connection from the subscriber. */
handle(message: ConnectionMessage): void;
/** Sends one message on the data channel; false while it isn't open. */
sendData(message: string): boolean;
}
/**
* Our side of a connection a subscriber asked for: we only send, and we are
* the only side that makes offers, so there is no glare.
*/
export function createPublisherConnection({
scope,
peer,
connectionId,
signalling,
iceServers,
local,
logger: parentLogger,
}: {
scope: ObservableScope;
peer: Peer;
connectionId: string;
signalling: Signalling;
iceServers: RTCIceServer[];
local: FullMeshLocalMedia;
logger: Logger;
}): PublisherConnection {
const logger = parentLogger.getChild(
`[Publisher → ${peer.memberId} ${connectionId}]`,
);
const base = createPeerConnectionBase({
scope,
peer,
connectionId,
signalling,
iceServers,
onControlMessage: (message) => handle(message),
logger,
});
const { pc, enqueue } = base;
const consumption$ = new BehaviorSubject<ConsumptionMetadata>({});
// Both before the first offer, which so has the connection's data section
base.openControlChannel();
const data = pc.createDataChannel(DATA_CHANNEL_LABEL, {
protocol: DATA_PROTOCOL,
});
// One sendonly transceiver per publication, for as long as it exists
const transceivers = new Map<
LocalPublication,
{ transceiver: RTCRtpTransceiver; tracks: Subscription }
>();
/** The metadata of every track with a `mid`, which it gets with the offer that adds it. */
const trackMetadata = (): TrackMetadata => {
const metadata: TrackMetadata = {};
for (const [publication, { transceiver }] of transceivers)
if (transceiver.mid !== null)
metadata[transceiver.mid] = {
purpose: purposeOf(publication),
muted: publication.mediaTrack.muted$.value,
};
return metadata;
};
// A new offer has to wait while one is unanswered; changes made meanwhile
// go out together in the next one
let offerPending = false;
let renegotiate = false;
const negotiate = (): void => {
if (offerPending) {
renegotiate = true;
return;
}
offerPending = true;
renegotiate = false;
enqueue(async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
base.send({
type: "description",
description: { type: "offer", sdp: pc.localDescription!.sdp },
track_metadata: trackMetadata(),
});
});
};
scope.onEnd(() => {
for (const { tracks } of transceivers.values()) tracks.unsubscribe();
});
local.publications$.pipe(scope.bind()).subscribe((publications) => {
let changed = false;
for (const [publication, { transceiver, tracks }] of transceivers)
if (!publications.includes(publication)) {
tracks.unsubscribe();
if (pc.signalingState !== "closed") transceiver.stop();
transceivers.delete(publication);
changed = true;
}
for (const publication of publications)
if (!transceivers.has(publication)) {
const transceiver = pc.addTransceiver(publication.kind, {
direction: "sendonly",
streams: [publication.stream],
});
const tracks = sendPublication(transceiver, publication);
transceivers.set(publication, { transceiver, tracks });
changed = true;
}
if (changed) negotiate();
});
// Even without tracks: the subscriber learns that we are there
if (transceivers.size === 0) negotiate();
/**
* Sends the track of a publication as the subscriber wants it. None while
* we don't publish or it isn't in use: the local preview stays live, and
* turning it back on needs no renegotiation.
*/
function sendPublication(
transceiver: RTCRtpTransceiver,
publication: LocalPublication,
): Subscription {
const { sender } = transceiver;
return combineLatest([
publication.track$,
local.publishing$,
consumptionOf(transceiver),
]).subscribe(([track, publishing, consumption]) => {
const inUse = consumption.in_use ?? true;
void sender
.replaceTrack(publishing && inUse ? track : null)
.then(async () => {
if (publication.kind === "video" && track !== null)
await constrainVideo(sender, track, consumption);
})
.catch((e: unknown) => logger.warn("Could not send a track", e));
});
}
// The mid is only set with the local description
function consumptionOf(
transceiver: RTCRtpTransceiver,
): Observable<ConsumptionMetadataEntry> {
return consumption$.pipe(
map((c) => (transceiver.mid === null ? {} : (c[transceiver.mid] ?? {}))),
distinctUntilChanged(
(a, b) =>
a.in_use === b.in_use &&
a.max_width === b.max_width &&
a.max_height === b.max_height,
),
);
}
// Mute changes without renegotiating; an offer carries the state at its time
local.publications$
.pipe(
switchMap((ps) =>
merge(...ps.map((p) => p.mediaTrack.muted$.pipe(skip(1)))),
),
scope.bind(),
)
.subscribe(() =>
base.send({ type: "track_metadata", track_metadata: trackMetadata() }),
);
base.state$
.pipe(
filter((s) => s instanceof Error),
scope.bind(),
)
.subscribe((e) => {
// The subscriber retries; a publisher doesn't
logger.info(`Connection failed: ${e.message}`);
base.close("failed", true);
scope.end();
});
function handle(message: ConnectionMessage): void {
switch (message.type) {
case "description":
if (message.description.type !== "answer") return;
enqueue(async () => {
await pc.setRemoteDescription(message.description);
base.answerReceived();
offerPending = false;
if (renegotiate) negotiate();
});
break;
case "candidates":
base.addCandidates(message.candidates);
break;
case "consumption_metadata":
consumption$.next(message.consumption_metadata);
break;
case "close":
logger.info(`Closed by the subscriber: ${message.reason}`);
base.close(message.reason, false);
scope.end();
break;
default:
logger.debug(`Ignoring ${message.type}`);
}
}
const sendData = (message: string): boolean => {
if (data.readyState !== "open") return false;
data.send(message);
return true;
};
return { ...base, handle, sendData };
}
function purposeOf({ source }: LocalPublication): MediaPurpose {
return source === "microphone" || source === "camera"
? "m.usermedia"
: "m.screenshare";
}
/** Scales the video down to the size the subscriber shows it at. */
async function constrainVideo(
sender: RTCRtpSender,
track: MediaStreamTrack,
{ max_width, max_height }: ConsumptionMetadataEntry,
): Promise<void> {
const parameters = sender.getParameters();
const [encoding] = parameters.encodings ?? [];
if (encoding === undefined) return;
const { width, height } = track.getSettings();
encoding.scaleResolutionDownBy = Math.max(
1,
width && max_width ? width / max_width : 1,
height && max_height ? height / max_height : 1,
);
await sender.setParameters(parameters);
}
+21
View File
@@ -0,0 +1,21 @@
# Full mesh media backend
The MatrixRTC full mesh transport, as specified in
[MSC4575: Full mesh transport for MatrixRTC](https://github.com/matrix-org/matrix-spec-proposals/pull/4575).
Every subscriber opens a WebRTC connection to every publisher it receives from. To-device messages set
the connection up; after that, the connection's control channel carries all signalling.
The MSC is not merged, so all identifiers use its unstable prefix `org.matrix.msc4575`.
| File | What it implements |
| ------------------------- | ------------------------------------------------------------------- |
| `FullMeshBackend.ts` | The `MediaBackend`: subscribing to members, accepting subscribers |
| `signalling.ts` | The to-device messages and their ordering by `seq` |
| `ControlChannel.ts` | The control channel and its messages |
| `PeerConnection.ts` | What both sides of a connection share, including the channel switch |
| `PublisherConnection.ts` | Sending our tracks and the data channel to one subscriber |
| `SubscriberConnection.ts` | Receiving one publisher's tracks, with retries |
| `metadata.ts` | Track and consumption metadata, and their validation |
| `LocalMedia.ts` | Capturing the local tracks every publisher connection sends |
| `MediaTrack.ts` | A WebRTC track as the SDK's `MediaTrack` |
@@ -0,0 +1,307 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { constant } from "../../reactive/Behavior";
import { ObservableScope } from "../../reactive/ObservableScope";
import {
DATA_CHANNEL_LABEL,
DATA_PROTOCOL,
type TrackMetadata,
} from "./metadata";
import {
createSubscriberConnection,
type SubscriberConnection,
} from "./SubscriberConnection";
import {
type FakeSignalling,
fakeSignalling,
fromPeer,
type PeerMessage,
installWebRtcFakes,
lastPeerConnection,
peer,
settle,
} from "./test";
describe("createSubscriberConnection", () => {
let scope: ObservableScope;
let signalling: FakeSignalling;
let subscriber: SubscriberConnection;
beforeEach(async () => {
vi.useFakeTimers();
installWebRtcFakes();
scope = new ObservableScope();
signalling = fakeSignalling();
subscriber = createSubscriberConnection({
scope,
peer,
signalling,
iceServers: async () => Promise.resolve([{ urls: ["turn:x"] }]),
audioOutputDeviceId$: constant(undefined),
logger,
});
await settle();
});
afterEach(() => {
scope.end();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("asks the publisher for its media on a fresh connection", () => {
expect(signalling.sent()).toEqual([{ type: "subscribe" }]);
expect(signalling.sentConnectionIds()).toEqual([current()]);
expect(lastPeerConnection().config.iceServers).toEqual([
{ urls: ["turn:x"] },
]);
});
it("answers an offer and shows the tracks it brings", async () => {
expect(subscriber.tracks$.value).toBeNull();
const pc = lastPeerConnection();
pc.receive("video", "1");
pc.receive("video", "2");
subscriber.handle(
offer(current(), {
"1": { purpose: "m.usermedia", muted: true },
"2": { purpose: "m.screenshare" },
}),
);
await settle();
expect(pc.remoteDescription).toEqual({ type: "offer", sdp: "offer" });
// How it shows them goes over the control channel once it is open
expect(signalling.sent("description")).toEqual([
{ type: "description", description: { type: "answer", sdp: "answer" } },
]);
const tracks = subscriber.tracks$.value!;
expect(tracks.map((t) => [t.source, t.kind, t.muted$.value])).toEqual([
["camera", "video", true],
["screenShare", "video", false],
]);
});
it("names audio by its purpose", async () => {
const pc = lastPeerConnection();
pc.receive("audio", "0");
pc.receive("audio", "3");
subscriber.handle(
offer(current(), {
"0": { purpose: "m.usermedia" },
"3": { purpose: "m.screenshare" },
}),
);
await settle();
expect(subscriber.tracks$.value!.map((t) => t.source)).toEqual([
"microphone",
"screenShareAudio",
]);
});
it("leaves out tracks without metadata or with an unknown purpose", async () => {
const pc = lastPeerConnection();
pc.receive("video", "0");
pc.receive("video", "1");
subscriber.handle(offer(current(), { "1": { purpose: "m.whiteboard" } }));
await settle();
expect(subscriber.tracks$.value).toEqual([]);
});
it("follows mute changes, keeping the same track", async () => {
lastPeerConnection().receive("video", "1");
subscriber.handle(offer(current(), { "1": { purpose: "m.usermedia" } }));
await settle();
const [track] = subscriber.tracks$.value!;
expect(track.muted$.value).toBe(false);
lastPeerConnection()
.control()
.receive({
type: "track_metadata",
track_metadata: { "1": { purpose: "m.usermedia", muted: true } },
});
await settle();
expect(subscriber.tracks$.value).toEqual([track]);
expect(track.muted$.value).toBe(true);
});
it("drops a track whose transceiver no longer receives", async () => {
const pc = lastPeerConnection();
const transceiver = pc.receive("video", "1");
subscriber.handle(offer(current(), { "1": { purpose: "m.usermedia" } }));
await settle();
expect(subscriber.tracks$.value).toHaveLength(1);
transceiver.currentDirection = "inactive";
subscriber.handle(offer(current(), { "1": { purpose: "m.usermedia" } }));
await settle();
expect(subscriber.tracks$.value).toEqual([]);
});
it("ignores messages of another connection", async () => {
subscriber.handle(offer("other", {}));
await settle();
expect(lastPeerConnection().remoteDescription).toBeNull();
});
it("subscribes anew after the publisher closes, backing off", async () => {
const first = current();
subscriber.handle(fromPeer(first, { type: "close", reason: "nope" }));
expect(subscriber.current$.value).toBeNull();
expect(subscriber.tracks$.value).toBeNull();
// It doesn't echo the publisher's close
expect(signalling.sent("close")).toEqual([]);
await vi.advanceTimersByTimeAsync(1_000);
const second = current();
expect(second).not.toBe(first);
expect(signalling.sentConnectionIds("subscribe")).toEqual([first, second]);
// A failed connection is closed and retried after twice the wait
lastPeerConnection().setConnectionState("failed");
expect(signalling.sent("close")).toEqual([
{ type: "close", reason: "failed" },
]);
await vi.advanceTimersByTimeAsync(1_000);
expect(signalling.sent("subscribe")).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1_000);
expect(signalling.sent("subscribe")).toHaveLength(3);
});
it("stops retrying once its scope ends", async () => {
subscriber.handle(fromPeer(current(), { type: "close", reason: "nope" }));
scope.end();
await vi.advanceTimersByTimeAsync(60_000);
expect(signalling.sent("subscribe")).toHaveLength(1);
});
describe("the control channel", () => {
it("is created once the offer is applied", async () => {
const pc = lastPeerConnection();
expect(pc.dataChannels).toEqual([]);
subscriber.handle(offer(current(), {}));
await settle();
expect(pc.control().init).toEqual({ negotiated: true, id: 0 });
});
it("failing leads to a new subscription", async () => {
const first = current();
subscriber.handle(offer(first, {}));
await settle();
const control = lastPeerConnection().control();
control.open();
control.close();
expect(signalling.sent("close")).toEqual([
{ type: "close", reason: "failed" },
]);
await vi.advanceTimersByTimeAsync(1_000);
expect(current()).not.toBe(first);
});
it("carries the answers to later offers", async () => {
subscriber.handle(offer(current(), {}));
await settle();
const control = lastPeerConnection().control();
control.open();
control.receive({
type: "description",
description: { type: "offer", sdp: "offer-2" },
track_metadata: {},
});
await vi.advanceTimersByTimeAsync(200);
expect(control.sentMessages()).toContainEqual({
type: "description",
description: { type: "answer", sdp: "answer" },
});
expect(signalling.sent("description")).toHaveLength(1);
});
});
describe("consumption metadata", () => {
it("tells the publisher how each video is shown, once the channel is open", async () => {
const pc = lastPeerConnection();
pc.receive("audio", "0");
pc.receive("video", "1");
subscriber.handle(
offer(current(), {
"0": { purpose: "m.usermedia" },
"1": { purpose: "m.usermedia" },
}),
);
await vi.advanceTimersByTimeAsync(200);
const control = pc.control();
control.open();
// Nothing shows the video yet; the audio is always in use
expect(control.sentMessages()).toEqual([
{
type: "consumption_metadata",
consumption_metadata: { "1": { in_use: false } },
},
]);
const video = subscriber.tracks$.value!.find((t) => t.kind === "video")!;
const element = { paused: false } as HTMLMediaElement;
video.attach(element);
await vi.advanceTimersByTimeAsync(200);
// Shown at a size the platform doesn't report: no constraint at all
expect(control.sentMessages().at(-1)).toEqual({
type: "consumption_metadata",
consumption_metadata: {},
});
video.detach(element);
await vi.advanceTimersByTimeAsync(200);
expect(control.sentMessages().at(-1)).toEqual({
type: "consumption_metadata",
consumption_metadata: { "1": { in_use: false } },
});
});
});
describe("the data channel", () => {
it("delivers what the publisher sends on it", async () => {
const received: string[] = [];
subscriber.data$.subscribe((text) => received.push(text));
subscriber.handle(offer(current(), {}));
await settle();
const pc = lastPeerConnection();
pc.announceDataChannel(DATA_CHANNEL_LABEL, DATA_PROTOCOL).receive("hi");
expect(received).toEqual(["hi"]);
});
it("ignores a channel whose protocol it doesn't know, whatever its label", async () => {
const received: string[] = [];
subscriber.data$.subscribe((text) => received.push(text));
subscriber.handle(offer(current(), {}));
await settle();
const pc = lastPeerConnection();
pc.announceDataChannel(DATA_CHANNEL_LABEL, "x.other").receive("no");
pc.announceDataChannel(DATA_CHANNEL_LABEL).receive("no");
expect(received).toEqual([]);
});
});
function offer(connectionId: string, metadata: TrackMetadata): PeerMessage {
return fromPeer(connectionId, {
type: "description",
description: { type: "offer", sdp: "offer" },
track_metadata: metadata,
});
}
/** The connection id of the current attempt. */
function current(): string {
const base = subscriber.current$.value;
if (base === null) throw new Error("No current attempt");
return base.connectionId;
}
});
@@ -0,0 +1,366 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
BehaviorSubject,
combineLatest,
debounceTime,
distinctUntilChanged,
filter,
map,
type Observable,
of,
Subject,
switchMap,
} from "rxjs";
import { v4 as uuidv4 } from "uuid";
import { type MediaSource } from "../../media-api";
import { type Behavior } from "../../reactive/Behavior";
import { ObservableScope } from "../../reactive/ObservableScope";
import { MediaConnectionState } from "../api";
import {
createFullMeshMediaTrack,
type FullMeshMediaTrack,
rtpStats,
type Viewport,
} from "./MediaTrack";
import {
type ConsumptionMetadata,
DATA_PROTOCOL,
isMediaPurpose,
type MediaPurpose,
type TrackMetadata,
} from "./metadata";
import {
createPeerConnectionBase,
type PeerConnectionBase,
} from "./PeerConnection";
import {
type ConnectionMessage,
type Peer,
type Signalling,
} from "./signalling";
const RETRY_MIN_MS = 1_000;
const RETRY_MAX_MS = 30_000;
/** How long the shown size of the tracks settles before the publisher hears of it. */
const CONSUMPTION_DEBOUNCE_MS = 200;
export interface SubscriberConnection {
/** The current attempt; null while waiting to retry. */
current$: Behavior<PeerConnectionBase | null>;
/** The publisher's tracks; null until its first offer is applied. */
tracks$: Behavior<FullMeshMediaTrack[] | null>;
/** What the publisher sent on its data channel, on any attempt. */
data$: Observable<string>;
/** A to-device message from the publisher; dropped unless it is for the current attempt. */
handle(message: ConnectionMessage & { connectionId: string }): void;
}
/**
* Our subscription to one publisher: asks it for its media and answers its
* offers. A failed or closed attempt is retried with a fresh connection id,
* backing off, for as long as `scope` lives.
*/
export function createSubscriberConnection({
scope,
peer,
signalling,
iceServers,
audioOutputDeviceId$,
logger: parentLogger,
}: {
scope: ObservableScope;
peer: Peer;
signalling: Signalling;
iceServers: () => Promise<RTCIceServer[]>;
audioOutputDeviceId$: Behavior<string | undefined>;
logger: Logger;
}): SubscriberConnection {
const logger = parentLogger.getChild(`[Subscriber ← ${peer.memberId}]`);
const attempt$ = new BehaviorSubject<Attempt | null>(null);
const data$ = new Subject<string>();
let retryMs = RETRY_MIN_MS;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let ended = false;
scope.onEnd(() => (ended = true));
const startAttempt = async (): Promise<void> => {
const servers = await iceServers();
if (ended) return;
const attemptScope = new ObservableScope();
const attempt = createAttempt({
scope: attemptScope,
peer,
signalling,
iceServers: servers,
audioOutputDeviceId$,
onData: (text) => data$.next(text),
onFailed: (reason) => {
logger.info(`Attempt ${attempt.connectionId} ended: ${reason}`);
attemptScope.end();
if (attempt$.value === attempt) attempt$.next(null);
if (ended) return;
retryTimer = setTimeout(() => void startAttempt(), retryMs);
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
},
onConnected: () => (retryMs = RETRY_MIN_MS),
logger,
});
attempt$.next(attempt);
};
void startAttempt();
scope.onEnd(() => {
clearTimeout(retryTimer);
attempt$.value?.end();
attempt$.next(null);
data$.complete();
});
return {
current$: scope.behavior(attempt$.pipe(map((a) => a?.base ?? null))),
tracks$: scope.behavior(
attempt$.pipe(switchMap((a) => a?.tracks$ ?? of(null))),
),
data$,
handle: (message) => {
if (message.connectionId === attempt$.value?.connectionId)
attempt$.value.handle(message);
},
};
}
interface Attempt {
connectionId: string;
base: PeerConnectionBase;
tracks$: Behavior<FullMeshMediaTrack[] | null>;
handle(message: ConnectionMessage): void;
end(): void;
}
interface ReceivedTrack {
track: FullMeshMediaTrack;
mid: string;
scope: ObservableScope;
source: MediaSource;
}
function createAttempt({
scope,
peer,
signalling,
iceServers,
audioOutputDeviceId$,
onData,
onFailed,
onConnected,
logger,
}: {
scope: ObservableScope;
peer: Peer;
signalling: Signalling;
iceServers: RTCIceServer[];
audioOutputDeviceId$: Behavior<string | undefined>;
onData: (text: string) => void;
onFailed: (reason: string) => void;
onConnected: () => void;
logger: Logger;
}): Attempt {
const connectionId = uuidv4();
let failed = false;
const fail = (reason: string): void => {
if (failed) return;
failed = true;
onFailed(reason);
};
const base = createPeerConnectionBase({
scope,
peer,
connectionId,
signalling,
iceServers,
onControlMessage: (message) => handle(message),
logger,
});
const { pc, enqueue } = base;
const metadata$ = new BehaviorSubject<TrackMetadata>({});
const received$ = new BehaviorSubject<ReceivedTrack[] | null>(null);
const wrappers = new Map<RTCRtpTransceiver, ReceivedTrack>();
pc.addEventListener("datachannel", ({ channel }) => {
if (channel.protocol !== DATA_PROTOCOL) return;
channel.addEventListener("message", ({ data }: MessageEvent) => {
if (typeof data === "string") onData(data);
});
});
/** The tracks of the transceivers that receive, after each negotiation. */
const updateTracks = (): void => {
const metadata = metadata$.value;
const live = new Set<RTCRtpTransceiver>();
for (const transceiver of pc.getTransceivers()) {
const { mid, receiver } = transceiver;
const track = receiver.track;
const entry = mid === null ? undefined : metadata[mid];
if (
transceiver.currentDirection !== "recvonly" ||
mid === null ||
entry === undefined ||
!isMediaPurpose(entry.purpose)
)
continue;
const kind = track.kind === "audio" ? "audio" : "video";
const source = sourceOf(entry.purpose, kind);
live.add(transceiver);
const existing = wrappers.get(transceiver);
if (existing?.source === source) continue;
existing?.scope.end();
const trackScope = new ObservableScope();
wrappers.set(transceiver, {
scope: trackScope,
source,
mid,
track: createFullMeshMediaTrack(trackScope, {
source,
kind,
id: `${connectionId}:${mid}`,
track$: trackScope.behavior(of(track)),
muted$: trackScope.behavior(
metadata$.pipe(
map((m) => m[mid]?.muted ?? false),
distinctUntilChanged(),
),
),
stats: async () => rtpStats(pc, track, "inbound-rtp"),
audioOutputDeviceId$,
observeViewport: true,
}),
});
}
for (const [transceiver, { scope: s }] of wrappers)
if (!live.has(transceiver)) {
s.end();
wrappers.delete(transceiver);
}
received$.next([...wrappers.values()]);
};
// How the videos are shown; the first goes out once the control channel
// is open, as everything is sent at full size until then anyway
received$
.pipe(
switchMap((received) => {
const video = (received ?? []).filter((r) => r.track.kind === "video");
return video.length === 0
? of({})
: combineLatest(video.map((r) => r.track.viewport$)).pipe(
map((viewports) => consumptionOf(video, viewports)),
);
}),
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
debounceTime(CONSUMPTION_DEBOUNCE_MS),
scope.bind(),
)
.subscribe((metadata) =>
base.send({
type: "consumption_metadata",
consumption_metadata: metadata,
}),
);
base.state$
.pipe(
filter((s) => s instanceof Error || s === MediaConnectionState.Connected),
scope.bind(),
)
.subscribe((s) => {
if (s instanceof Error) {
base.close("failed", true);
fail(s.message);
} else onConnected();
});
function handle(message: ConnectionMessage): void {
switch (message.type) {
case "description":
if (message.description.type !== "offer") return;
enqueue(async () => {
if (message.track_metadata) metadata$.next(message.track_metadata);
await pc.setRemoteDescription(message.description);
base.openControlChannel();
await pc.setLocalDescription(await pc.createAnswer());
updateTracks();
base.send({
type: "description",
description: { type: "answer", sdp: pc.localDescription!.sdp },
});
});
break;
case "candidates":
base.addCandidates(message.candidates);
break;
case "track_metadata":
metadata$.next(message.track_metadata);
// A track may only become known with its metadata
enqueue(async () => Promise.resolve(updateTracks()));
break;
case "close":
base.close(message.reason, false);
fail(`closed by the publisher: ${message.reason}`);
break;
default:
logger.debug(`Ignoring ${message.type}`);
}
}
scope.onEnd(() => {
for (const { scope: s } of wrappers.values()) s.end();
wrappers.clear();
});
signalling.send(peer, connectionId, { type: "subscribe" });
return {
connectionId,
base,
tracks$: scope.behavior(
received$.pipe(map((r) => r?.map(({ track }) => track) ?? null)),
),
handle,
end: () => scope.end(),
};
}
/**
* The consumption metadata for the received videos, given their viewports
* in order: a video no element shows isn't in use, and one shown at a known
* size needs no more than that.
*/
function consumptionOf(
video: ReceivedTrack[],
viewports: (Viewport | null)[],
): ConsumptionMetadata {
const metadata: ConsumptionMetadata = {};
video.forEach(({ mid }, i) => {
const viewport = viewports[i];
if (viewport === null) metadata[mid] = { in_use: false };
else if (viewport.width !== undefined && viewport.height !== undefined)
metadata[mid] = {
max_width: viewport.width,
max_height: viewport.height,
};
});
return metadata;
}
function sourceOf(purpose: MediaPurpose, kind: "audio" | "video"): MediaSource {
if (purpose === "m.usermedia")
return kind === "audio" ? "microphone" : "camera";
return kind === "audio" ? "screenShareAudio" : "screenShare";
}
@@ -0,0 +1,52 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { parseConsumptionMetadata, parseTrackMetadata } from "./metadata";
describe("parseTrackMetadata", () => {
it("keeps the well-formed entries", () => {
expect(
parseTrackMetadata({
"0": { purpose: "m.usermedia", muted: true, extra: 1 },
"1": { purpose: "m.screenshare", muted: "yes" },
"2": { muted: true },
"3": [],
}),
).toEqual({
"0": { purpose: "m.usermedia", muted: true },
"1": { purpose: "m.screenshare" },
});
});
it("rejects what isn't metadata at all", () => {
expect(parseTrackMetadata("x")).toBeNull();
expect(parseTrackMetadata([])).toBeNull();
});
it("reads no more entries than any member publishes", () => {
const many = Object.fromEntries(
Array.from({ length: 100 }, (_, i) => [i, { purpose: "m.usermedia" }]),
);
expect(Object.keys(parseTrackMetadata(many)!)).toHaveLength(64);
});
});
describe("parseConsumptionMetadata", () => {
it("bounds the sizes to what makes sense", () => {
expect(
parseConsumptionMetadata({
"0": { in_use: false, max_width: 1e9, max_height: -1 },
"1": { max_width: 320.4, max_height: Number.NaN },
}),
).toEqual({
"0": { in_use: false, max_width: 16_384 },
"1": { max_width: 320 },
});
});
});
@@ -0,0 +1,95 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* The track and consumption metadata of the full mesh transport (MSC4575),
* and their validation: both come from the other member and are untrusted.
* Both are keyed by the `mid` of a track's transceiver.
*/
/**
* The data channel every publisher connection has for `sendData`, and its
* protocol: one JSON object `{ topic, text }` per message.
*/
export const DATA_CHANNEL_LABEL = "io.element.matrixrtc.data";
export const DATA_PROTOCOL = "io.element.matrixrtc.topic_text";
export type MediaPurpose = "m.usermedia" | "m.screenshare";
export interface TrackMetadataEntry {
purpose: string;
muted?: boolean;
}
export type TrackMetadata = Record<string, TrackMetadataEntry>;
export interface ConsumptionMetadataEntry {
in_use?: boolean;
max_width?: number;
max_height?: number;
}
export type ConsumptionMetadata = Record<string, ConsumptionMetadataEntry>;
/** Larger than any video we capture; a bound on what a subscriber can ask for. */
const MAX_DIMENSION = 16_384;
/** More tracks than any member publishes; a bound on the work a message causes. */
const MAX_TRACKS = 64;
export function isMediaPurpose(purpose: string): purpose is MediaPurpose {
return purpose === "m.usermedia" || purpose === "m.screenshare";
}
/** The entries of `value` that are well formed, or null if it isn't metadata at all. */
export function parseTrackMetadata(value: unknown): TrackMetadata | null {
const entries = recordEntries(value);
if (entries === null) return null;
const metadata: TrackMetadata = {};
for (const [mid, e] of entries) {
if (typeof e.purpose !== "string") continue;
const entry: TrackMetadataEntry = { purpose: e.purpose };
if (typeof e.muted === "boolean") entry.muted = e.muted;
metadata[mid] = entry;
}
return metadata;
}
/** As `parseTrackMetadata`, with the sizes bounded to what makes sense. */
export function parseConsumptionMetadata(
value: unknown,
): ConsumptionMetadata | null {
const entries = recordEntries(value);
if (entries === null) return null;
const metadata: ConsumptionMetadata = {};
for (const [mid, e] of entries) {
const entry: ConsumptionMetadataEntry = {};
if (typeof e.in_use === "boolean") entry.in_use = e.in_use;
const width = bounded(e.max_width);
if (width !== undefined) entry.max_width = width;
const height = bounded(e.max_height);
if (height !== undefined) entry.max_height = height;
metadata[mid] = entry;
}
return metadata;
}
function recordEntries(
value: unknown,
): [string, Record<string, unknown>][] | null {
if (typeof value !== "object" || value === null || Array.isArray(value))
return null;
return Object.entries(value)
.filter(
(e): e is [string, Record<string, unknown>] =>
typeof e[1] === "object" && e[1] !== null && !Array.isArray(e[1]),
)
.slice(0, MAX_TRACKS);
}
function bounded(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.round(Math.min(value, MAX_DIMENSION))
: undefined;
}
@@ -0,0 +1,199 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "events";
import { ClientEvent, EventType, MatrixEvent } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { ObservableScope } from "../../reactive/ObservableScope";
import {
createSignalling,
SEQ_GAP_TIMEOUT_MS,
SeqOrderer,
SignallingEventType,
type SignallingClient,
type SignallingMessage,
} from "./signalling";
describe("SeqOrderer", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("delivers in seq order", () => {
const delivered: string[] = [];
const orderer = new SeqOrderer<string>((s) => delivered.push(s));
orderer.push(1, "b");
orderer.push(2, "c");
expect(delivered).toEqual([]);
orderer.push(0, "a");
expect(delivered).toEqual(["a", "b", "c"]);
});
it("skips a gap that isn't filled in time, then drops the late message", () => {
const delivered: string[] = [];
const orderer = new SeqOrderer<string>((s) => delivered.push(s));
orderer.push(0, "a");
orderer.push(2, "c");
vi.advanceTimersByTime(SEQ_GAP_TIMEOUT_MS);
expect(delivered).toEqual(["a", "c"]);
orderer.push(1, "b");
expect(delivered).toEqual(["a", "c"]);
});
});
describe("createSignalling", () => {
const own = { userId: "@me:x", deviceId: "MINE", memberId: "me" };
function setup(): {
client: SignallingClient & EventEmitter;
received: SignallingMessage[];
scope: ObservableScope;
} {
const client = Object.assign(new EventEmitter(), {
encryptAndSendToDevice: vi.fn(async () => Promise.resolve()),
}) as unknown as SignallingClient & EventEmitter;
const scope = new ObservableScope();
const signalling = createSignalling({
scope,
client,
roomId: "!room:x",
slotId: "m.call#ROOM",
own,
logger,
});
const received: SignallingMessage[] = [];
signalling.messages$.subscribe((m) => received.push(m));
return { client, received, scope };
}
function toDevice(
type: string,
content: Record<string, unknown>,
encrypted = true,
): MatrixEvent {
const event = new MatrixEvent({ type, sender: "@peer:x", content });
if (encrypted)
vi.spyOn(event, "getWireType").mockReturnValue(
EventType.RoomMessageEncrypted,
);
return event;
}
const common = {
room_id: "!room:x",
slot_id: "m.call#ROOM",
member_id: "peer",
target_member_id: "me",
connection_id: "c1",
device_id: "PEER",
};
it("passes valid messages on and drops cleartext or misaddressed ones", () => {
const { client, received, scope } = setup();
client.emit(
ClientEvent.ToDeviceEvent,
toDevice(SignallingEventType.subscribe, { ...common, seq: 0 }, false),
);
client.emit(
ClientEvent.ToDeviceEvent,
toDevice(SignallingEventType.subscribe, {
...common,
target_member_id: "someone else",
seq: 0,
}),
);
expect(received).toEqual([]);
client.emit(
ClientEvent.ToDeviceEvent,
toDevice(SignallingEventType.subscribe, { ...common, seq: 0 }),
);
expect(received).toEqual([
{
type: "subscribe",
memberId: "peer",
userId: "@peer:x",
deviceId: "PEER",
connectionId: "c1",
},
]);
scope.end();
});
it("orders by seq across connection ids", () => {
const { client, received, scope } = setup();
client.emit(
ClientEvent.ToDeviceEvent,
toDevice(SignallingEventType.subscribe, {
...common,
connection_id: "c2",
seq: 2,
}),
);
client.emit(
ClientEvent.ToDeviceEvent,
toDevice(SignallingEventType.close, { ...common, seq: 1 }),
);
client.emit(
ClientEvent.ToDeviceEvent,
toDevice(SignallingEventType.subscribe, { ...common, seq: 0 }),
);
expect(received.map((m) => [m.type, m.connectionId])).toEqual([
["subscribe", "c1"],
["close", "c1"],
["subscribe", "c2"],
]);
scope.end();
});
it("keeps the well-formed metadata of a description", () => {
const { client, received, scope } = setup();
client.emit(
ClientEvent.ToDeviceEvent,
toDevice(SignallingEventType.description, {
...common,
seq: 0,
description: { type: "offer", sdp: "offer" },
track_metadata: {
"0": { purpose: "m.usermedia", muted: "yes" },
"1": { muted: true },
"2": "not an object",
},
}),
);
expect(received).toEqual([
expect.objectContaining({
type: "description",
track_metadata: { "0": { purpose: "m.usermedia" } },
}),
]);
scope.end();
});
it("sends each payload as its event type", () => {
const { client, scope } = setup();
const signalling = createSignalling({
scope,
client,
roomId: "!room:x",
slotId: "m.call#ROOM",
own,
logger,
});
signalling.send(
{ userId: "@peer:x", deviceId: "PEER", memberId: "peer" },
"c1",
{ type: "close", reason: "bye" },
);
expect(client.encryptAndSendToDevice).toHaveBeenCalledWith(
SignallingEventType.close,
[{ userId: "@peer:x", deviceId: "PEER" }],
expect.objectContaining({ reason: "bye", connection_id: "c1", seq: 0 }),
);
scope.end();
});
});
@@ -0,0 +1,291 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* The to-device signalling of the full mesh transport (MSC4575): four event
* types, the fields every one carries, and the ordering by `seq`. It sets a
* connection up; the control channel carries the rest.
*/
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
ClientEvent,
EventType,
type MatrixClient,
type MatrixEvent,
} from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { fromEvent, Observable, share } from "rxjs";
import { type ObservableScope } from "../../reactive/ObservableScope";
import { type ControlMessage } from "./ControlChannel";
import { parseTrackMetadata } from "./metadata";
export const FULL_MESH_TRANSPORT_TYPE = "org.matrix.msc4575.full_mesh";
const prefix = "org.matrix.msc4575.rtc.full_mesh";
/** The event type of each payload type. */
export const SignallingEventType = {
subscribe: `${prefix}.subscribe`,
description: `${prefix}.description`,
candidates: `${prefix}.candidates`,
close: `${prefix}.close`,
} as const;
export const signallingEventTypes: string[] =
Object.values(SignallingEventType);
/** How long a gap in `seq` is waited for before the missing messages are skipped. */
export const SEQ_GAP_TIMEOUT_MS = 10_000;
interface CommonContent {
room_id: string;
slot_id: string;
member_id: string;
target_member_id: string;
connection_id: string;
seq: number;
/**
* Not in the MSC, which takes the device from the Olm session. The js-sdk
* does not expose that for to-device events, so, like the key transport's
* `claimed_device_id`, the sender states it.
*/
device_id: string;
}
/** What a connection hears from its peer, over to-device messages or the control channel. */
export type ConnectionMessage =
| ControlMessage
| { type: "close"; reason?: string };
export type SignallingPayload =
| { type: "subscribe" }
| Extract<
ConnectionMessage,
{ type: "description" | "candidates" | "close" }
>;
export type SignallingMessage = SignallingPayload & {
/** The member that sent it, as named in `member_id`. */
memberId: string;
userId: string;
deviceId: string;
connectionId: string;
};
/** Another member, as far as signalling needs to know it. */
export interface Peer {
userId: string;
deviceId: string;
memberId: string;
}
export interface Signalling {
send(peer: Peer, connectionId: string, payload: SignallingPayload): void;
/** Every valid message for us, per sender in `seq` order. */
messages$: Observable<SignallingMessage>;
}
export type SignallingClient = Pick<
MatrixClient,
"encryptAndSendToDevice" | "on" | "off"
>;
interface Props {
scope: ObservableScope;
client: SignallingClient;
roomId: string;
slotId: string;
own: CallMembershipIdentityParts;
logger: Logger;
}
export function createSignalling({
scope,
client,
roomId,
slotId,
own,
logger: parentLogger,
}: Props): Signalling {
const logger = parentLogger.getChild("[Signalling]");
const nextSeqTo = new Map<string, number>();
const send = (
peer: Peer,
connectionId: string,
{ type, ...payload }: SignallingPayload,
): void => {
const eventType = SignallingEventType[type];
const seq = nextSeqTo.get(peer.memberId) ?? 0;
nextSeqTo.set(peer.memberId, seq + 1);
const content: CommonContent & Record<string, unknown> = {
...payload,
room_id: roomId,
slot_id: slotId,
member_id: own.memberId,
target_member_id: peer.memberId,
connection_id: connectionId,
seq,
device_id: own.deviceId,
};
client
.encryptAndSendToDevice(
eventType,
[{ userId: peer.userId, deviceId: peer.deviceId }],
content,
)
.catch((e: unknown) => logger.warn(`Failed to send ${eventType}`, e));
};
const messages$ = new Observable<SignallingMessage>((subscriber) => {
const orderers = new Map<string, SeqOrderer<SignallingMessage>>();
const sub = fromEvent<MatrixEvent>(client, ClientEvent.ToDeviceEvent)
.pipe(scope.bind())
.subscribe((event) => {
const parsed = parse(event, roomId, slotId, own.memberId, logger);
if (parsed === null) return;
let orderer = orderers.get(parsed.message.memberId);
if (orderer === undefined) {
orderer = new SeqOrderer((m) => subscriber.next(m), logger);
orderers.set(parsed.message.memberId, orderer);
}
orderer.push(parsed.seq, parsed.message);
});
return () => {
sub.unsubscribe();
for (const orderer of orderers.values()) orderer.dispose();
};
}).pipe(share());
return { send, messages$ };
}
function parse(
event: MatrixEvent,
roomId: string,
slotId: string,
ownMemberId: string,
logger: Logger,
): { seq: number; message: SignallingMessage } | null {
const type = event.getType();
if (!signallingEventTypes.includes(type)) return null;
// Via the widget API, to-device events are reconstructed as encrypted
if (event.getWireType() !== EventType.RoomMessageEncrypted) {
logger.warn(`Dropping ${type} sent in clear from ${event.getSender()}`);
return null;
}
const c = event.getContent<
Partial<CommonContent> & Record<string, unknown>
>();
const userId = event.getSender();
if (
userId === undefined ||
c.room_id !== roomId ||
c.slot_id !== slotId ||
c.target_member_id !== ownMemberId ||
typeof c.member_id !== "string" ||
typeof c.connection_id !== "string" ||
typeof c.device_id !== "string" ||
typeof c.seq !== "number"
)
return null;
const common = {
memberId: c.member_id,
userId,
deviceId: c.device_id,
connectionId: c.connection_id,
};
let payload: SignallingPayload;
switch (type) {
case SignallingEventType.subscribe:
payload = { type: "subscribe" };
break;
case SignallingEventType.description: {
const d = c.description as { type?: unknown; sdp?: unknown } | undefined;
if (
(d?.type !== "offer" && d?.type !== "answer") ||
typeof d.sdp !== "string"
)
return null;
payload = {
type: "description",
description: { type: d.type, sdp: d.sdp },
track_metadata: parseTrackMetadata(c.track_metadata) ?? undefined,
};
break;
}
case SignallingEventType.candidates:
if (!Array.isArray(c.candidates)) return null;
payload = {
type: "candidates",
candidates: c.candidates as RTCIceCandidateInit[],
};
break;
case SignallingEventType.close:
payload = {
type: "close",
reason: typeof c.reason === "string" ? c.reason : undefined,
};
break;
default:
return null;
}
return { seq: c.seq, message: { ...payload, ...common } };
}
/**
* Delivers the messages of one sender in `seq` order. A gap is waited for
* `SEQ_GAP_TIMEOUT_MS`, then skipped. A message below the next expected `seq`
* came after its gap was skipped, and is dropped.
*/
export class SeqOrderer<T> {
private next = 0;
private readonly buffer = new Map<number, T>();
private gapTimer: ReturnType<typeof setTimeout> | undefined;
public constructor(
private readonly deliver: (item: T) => void,
private readonly logger?: Logger,
private readonly gapTimeoutMs = SEQ_GAP_TIMEOUT_MS,
) {}
public push(seq: number, item: T): void {
if (seq < this.next) {
this.logger?.warn(`Dropping seq ${seq}, already past it`);
return;
}
this.buffer.set(seq, item);
this.flush();
}
public dispose(): void {
clearTimeout(this.gapTimer);
this.buffer.clear();
}
private flush(): void {
let item = this.buffer.get(this.next);
while (item !== undefined) {
this.buffer.delete(this.next);
this.next++;
this.deliver(item);
item = this.buffer.get(this.next);
}
clearTimeout(this.gapTimer);
this.gapTimer = undefined;
if (this.buffer.size > 0)
this.gapTimer = setTimeout(() => this.skipGap(), this.gapTimeoutMs);
}
private skipGap(): void {
const lowest = Math.min(...this.buffer.keys());
this.logger?.warn(`Skipping seq ${this.next} to ${lowest - 1}`);
this.next = lowest;
this.flush();
}
}
+380
View File
@@ -0,0 +1,380 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* Fakes for the parts of WebRTC that jsdom lacks, and for the signalling, so
* that the full mesh connections can run in unit tests.
*/
import { vi } from "vitest";
import { BehaviorSubject, Subject } from "rxjs";
import { CONTROL_CHANNEL_LABEL } from "./ControlChannel";
import { type LocalPublication } from "./LocalMedia";
import {
type ConnectionMessage,
type Peer,
type Signalling,
type SignallingMessage,
type SignallingPayload,
} from "./signalling";
let nextId = 0;
export class FakeMediaStream {
public readonly id: string;
public constructor(public readonly tracks: MediaStreamTrack[] = []) {
this.id = `stream-${nextId++}`;
}
public getTracks(): MediaStreamTrack[] {
return this.tracks;
}
}
export function fakeTrack(
kind: "audio" | "video",
settings: MediaTrackSettings = {},
): MediaStreamTrack {
return {
id: `track-${nextId++}`,
kind,
enabled: true,
stop: vi.fn(),
getSettings: () => settings,
} as unknown as MediaStreamTrack;
}
/** A data channel whose opening and incoming messages the test drives. */
export class FakeDataChannel extends EventTarget {
public readyState: RTCDataChannelState = "connecting";
public readonly sent: string[] = [];
public readonly protocol: string;
public constructor(
public readonly label: string,
public readonly init: RTCDataChannelInit = {},
) {
super();
this.protocol = init.protocol ?? "";
}
public send(data: string): void {
if (this.readyState !== "open") throw new Error("The channel isn't open");
this.sent.push(data);
}
/** What was sent, parsed. */
public sentMessages(): unknown[] {
return this.sent.map((data) => JSON.parse(data) as unknown);
}
public open(): void {
this.readyState = "open";
this.dispatchEvent(new Event("open"));
}
public close(): void {
if (this.readyState === "closed") return;
this.readyState = "closed";
this.dispatchEvent(new Event("close"));
}
/** A message from the peer; an object is sent as JSON. */
public receive(data: unknown): void {
this.dispatchEvent(
Object.assign(new Event("message"), {
data: typeof data === "string" ? data : JSON.stringify(data),
}),
);
}
}
interface FakeTransceiver {
kind: string;
/** Null until a local description gives it one, as in a browser. */
mid: string | null;
direction: RTCRtpTransceiverDirection;
currentDirection: RTCRtpTransceiverDirection | null;
streams: MediaStream[];
stopped: boolean;
stop(): void;
sender: {
track: MediaStreamTrack | null;
replaceTrack: ReturnType<typeof vi.fn>;
parameters: RTCRtpSendParameters;
getParameters(): RTCRtpSendParameters;
setParameters: ReturnType<typeof vi.fn>;
};
receiver: { track: MediaStreamTrack };
}
/** Enough of `RTCPeerConnection` for one side of a connection, driven by the test. */
export class FakePeerConnection extends EventTarget {
public static instances: FakePeerConnection[] = [];
public signalingState: RTCSignalingState = "stable";
public connectionState: RTCPeerConnectionState = "new";
public iceConnectionState: RTCIceConnectionState = "new";
public localDescription: RTCSessionDescriptionInit | null = null;
public remoteDescription: RTCSessionDescriptionInit | null = null;
public readonly transceivers: FakeTransceiver[] = [];
public readonly addedCandidates: RTCIceCandidateInit[] = [];
public readonly dataChannels: FakeDataChannel[] = [];
public offers = 0;
public closed = false;
public constructor(public readonly config: RTCConfiguration) {
super();
FakePeerConnection.instances.push(this);
}
public addTransceiver(
kind: string,
init: RTCRtpTransceiverInit,
): FakeTransceiver {
const transceiver: FakeTransceiver = {
kind,
mid: null,
direction: init.direction ?? "sendrecv",
currentDirection: null,
streams: init.streams ?? [],
stopped: false,
stop(): void {
this.stopped = true;
},
sender: {
track: null,
replaceTrack: vi.fn(async (track: MediaStreamTrack | null) => {
transceiver.sender.track = track;
return Promise.resolve();
}),
parameters: {
encodings: [{}],
} as unknown as RTCRtpSendParameters,
getParameters: (): RTCRtpSendParameters =>
structuredClone(transceiver.sender.parameters),
setParameters: vi.fn(async (parameters: RTCRtpSendParameters) => {
transceiver.sender.parameters = parameters;
return Promise.resolve();
}),
},
receiver: { track: fakeTrack(kind === "audio" ? "audio" : "video") },
};
this.transceivers.push(transceiver);
return transceiver;
}
public createDataChannel(
label: string,
init?: RTCDataChannelInit,
): FakeDataChannel {
const channel = new FakeDataChannel(label, init);
this.dataChannels.push(channel);
return channel;
}
/** Our side of the control channel. */
public control(): FakeDataChannel {
const channel = this.dataChannels.find(
(c) => c.label === CONTROL_CHANNEL_LABEL,
);
if (channel === undefined) throw new Error("No control channel");
return channel;
}
/** A data channel the remote side created, as `ondatachannel` brings it. */
public announceDataChannel(label: string, protocol = ""): FakeDataChannel {
const channel = new FakeDataChannel(label, { protocol });
this.dispatchEvent(Object.assign(new Event("datachannel"), { channel }));
return channel;
}
public getTransceivers(): FakeTransceiver[] {
return this.transceivers;
}
public getSenders(): FakeTransceiver["sender"][] {
return this.transceivers.map((t) => t.sender);
}
public async createOffer(): Promise<RTCSessionDescriptionInit> {
return Promise.resolve({ type: "offer", sdp: `offer-${++this.offers}` });
}
public async createAnswer(): Promise<RTCSessionDescriptionInit> {
return Promise.resolve({ type: "answer", sdp: "answer" });
}
public async setLocalDescription(
description: RTCSessionDescriptionInit,
): Promise<void> {
this.localDescription = description;
this.transceivers.forEach((t, i) => (t.mid ??= String(i)));
this.signalingState =
description.type === "offer" ? "have-local-offer" : "stable";
return Promise.resolve();
}
public async setRemoteDescription(
description: RTCSessionDescriptionInit,
): Promise<void> {
this.remoteDescription = description;
this.signalingState =
description.type === "offer" ? "have-remote-offer" : "stable";
return Promise.resolve();
}
public async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
if (candidate.candidate === "bad")
return Promise.reject(new Error("bad candidate"));
this.addedCandidates.push(candidate);
return Promise.resolve();
}
public async getStats(): Promise<RTCStatsReport> {
return Promise.resolve(new Map() as unknown as RTCStatsReport);
}
public close(): void {
this.closed = true;
this.signalingState = "closed";
this.connectionState = "closed";
for (const channel of this.dataChannels) channel.close();
}
/** Gathers a local candidate; null ends the gathering. */
public gather(candidate: RTCIceCandidateInit | null): void {
this.dispatchEvent(
Object.assign(new Event("icecandidate"), {
candidate: candidate && { toJSON: () => candidate },
}),
);
}
public setConnectionState(state: RTCPeerConnectionState): void {
this.connectionState = state;
this.dispatchEvent(new Event("connectionstatechange"));
}
/** A track the remote side sends, as the next negotiation would bring it. */
public receive(kind: "audio" | "video", mid: string): FakeTransceiver {
const transceiver = this.addTransceiver(kind, { direction: "recvonly" });
transceiver.currentDirection = "recvonly";
transceiver.mid = mid;
return transceiver;
}
}
class FakeAudioContext {
public destination = {};
public createMediaStreamSource(): object {
return { connect: vi.fn(), disconnect: vi.fn() };
}
public createAnalyser(): object {
return {
fftSize: 512,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: (samples: Float32Array): void => {
samples.fill(0);
},
};
}
public createGain(): object {
return { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
}
}
/** Replaces the WebRTC and Web Audio globals jsdom doesn't have. Undo with `vi.unstubAllGlobals()`. */
export function installWebRtcFakes(): void {
FakePeerConnection.instances = [];
vi.stubGlobal("RTCPeerConnection", FakePeerConnection);
vi.stubGlobal("MediaStream", FakeMediaStream);
vi.stubGlobal("AudioContext", FakeAudioContext);
}
/** The last peer connection created. */
export function lastPeerConnection(): FakePeerConnection {
const pc = FakePeerConnection.instances.at(-1);
if (pc === undefined) throw new Error("No peer connection was created");
return pc;
}
/** Lets every queued signalling operation run. */
export async function settle(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
export const peer: Peer = {
userId: "@peer:example.org",
deviceId: "PEERDEVICE",
memberId: "peerMember",
};
export interface FakeSignalling extends Signalling {
send: ReturnType<typeof vi.fn<Signalling["send"]>>;
messages$: Subject<SignallingMessage>;
/** The payloads sent so far, optionally only those of one type. */
sent(type?: string): SignallingPayload[];
/** The connection ids sent with the payloads, in the same order. */
sentConnectionIds(type?: string): string[];
}
export function fakeSignalling(): FakeSignalling {
const send = vi.fn<Signalling["send"]>();
const calls = (type?: string): Parameters<Signalling["send"]>[] =>
send.mock.calls.filter(([, , p]) => type === undefined || p.type === type);
return {
send,
messages$: new Subject(),
sent: (type) => calls(type).map(([, , p]) => p),
sentConnectionIds: (type) => calls(type).map(([, id]) => id),
};
}
/** A to-device message of a connection, as signalling delivers it. */
export type PeerMessage = ConnectionMessage & Omit<SignallingMessage, "type">;
/** A to-device message from `peer` on a connection. */
export function fromPeer<P extends SignallingPayload>(
connectionId: string,
payload: P,
): P & Omit<SignallingMessage, "type"> {
return { ...payload, ...peer, connectionId };
}
/** The local media a publisher connection reads. */
export function fakeLocalMedia(): {
publications$: BehaviorSubject<LocalPublication[]>;
publishing$: BehaviorSubject<boolean>;
} {
return {
publications$: new BehaviorSubject<LocalPublication[]>([]),
publishing$: new BehaviorSubject(true),
};
}
export function fakePublication(
kind: "audio" | "video",
{
source = kind === "audio" ? "microphone" : "camera",
stream = new FakeMediaStream() as unknown as MediaStream,
}: Partial<Pick<LocalPublication, "source" | "stream">> = {},
): LocalPublication & {
track$: BehaviorSubject<MediaStreamTrack | null>;
muted$: BehaviorSubject<boolean>;
} {
const muted$ = new BehaviorSubject(false);
return {
source,
kind,
stream,
track$: new BehaviorSubject<MediaStreamTrack | null>(fakeTrack(kind)),
muted$,
mediaTrack: { muted$ } as unknown as LocalPublication["mediaTrack"],
};
}
+11 -4
View File
@@ -101,8 +101,11 @@ export class ConnectionMap {
interface Props {
scope: ObservableScope;
connectionFactory: ConnectionFactory;
/** The transport we publish on; the one connection with the publisher role. */
localTransport: TransportLocator;
/**
* The transport we publish on; the one connection with the publisher role.
* Null for a backend that only subscribes, while another backend publishes.
*/
localTransport: TransportLocator | null;
/** Every transport some member is on, deduplicated against the local one. */
remoteTransports$: Behavior<Epoch<TransportLocator[]>>;
logger: Logger;
@@ -128,7 +131,11 @@ export function createConnectionMap$({
scope.behavior(
remoteTransports$.pipe(
mapEpoch((remoteTransports) =>
removeDuplicateTransports([localTransport, ...remoteTransports]),
removeDuplicateTransports(
localTransport
? [localTransport, ...remoteTransports]
: remoteTransports,
),
),
tap((transports) =>
logger.debug("localAndRemoteTransports$ = ", transports),
@@ -136,7 +143,7 @@ export function createConnectionMap$({
),
);
const localTransportKey = keyFor(localTransport);
const localTransportKey = localTransport && keyFor(localTransport);
const connections$ = scope.behavior(
localAndRemoteTransports$.pipe(
@@ -115,9 +115,14 @@ export function createLivekitBackend(
);
// A new map, in a scope of its own, whenever the local transport changes:
// the publisher role sits on one connection, and which one is fixed per map
// the publisher role sits on one connection, and which one is fixed per map.
// Without a local transport the map only subscribes, for when another
// backend publishes; boxed so that `mapScoped` builds a map for null too
const boxedLocalTransport$ = scope.behavior(
localTransport$.pipe(map((localTransport) => ({ localTransport }))),
);
const connectionMap$ = scope.behavior<Epoch<ConnectionMap>>(
mapScoped(scope, localTransport$, (mapScope, localTransport) =>
mapScoped(scope, boxedLocalTransport$, (mapScope, { localTransport }) =>
createConnectionMap$({
scope: mapScope,
connectionFactory,
@@ -139,12 +144,15 @@ export function createLivekitBackend(
logger,
});
// Probed once: the answer cannot change within a session
const homeserverSupportsDelegation = supportsDelegation(
`${client.baseUrl}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`,
"homeserver",
logger,
);
// Probed once, when first needed: the answer cannot change within a
// session, and a backend that only subscribes never needs it
let homeserverProbe: Promise<boolean> | undefined;
const homeserverSupportsDelegation = async (): Promise<boolean> =>
(homeserverProbe ??= supportsDelegation(
`${client.baseUrl}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`,
"homeserver",
logger,
));
const ownServerName = serverNameOf(ownMembershipIdentity.userId);
@@ -176,7 +184,7 @@ export function createLivekitBackend(
`transport ${flow.serviceUrl}`,
logger,
)
: homeserverSupportsDelegation;
: homeserverSupportsDelegation();
}
localTransport$.next(locator);
// The membership is only sent once the publisher connection holds its
+2 -1
View File
@@ -7,6 +7,7 @@
padding-top: env(safe-area-inset-top);
opacity: 1;
transition: opacity 0.15s;
text-align: center;
}
/* Pseudo-element for the gradient background */
@@ -52,7 +53,7 @@
". . ."
"primaryButton title secondaryButton"
". . .";
place-items: center;
place-items: center stretch;
column-gap: var(--cpd-space-2x);
}
+5 -1
View File
@@ -70,6 +70,7 @@ import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import {
createRTCSlot,
FULL_MESH_TRANSPORT_TYPE,
type RTCParticipationOptions,
type RTCSlotOptions,
constant,
@@ -84,6 +85,7 @@ import {
cameraFramerate,
cameraResolution,
customTransport,
fullMeshPublishing,
echoCancellationSetting,
matrixRTCMode as matrixRTCModeSetting,
noiseSuppressionSetting,
@@ -823,7 +825,9 @@ export function rtcParticipationOptions(
delegatedDelayedLeave: session.delegated_delayed_leave,
},
mediaQuality: config.media_quality,
transport: customTransport.value$.value ?? undefined,
transport: fullMeshPublishing.value$.value
? { type: FULL_MESH_TRANSPORT_TYPE }
: (customTransport.value$.value ?? undefined),
fallbackTransport: config.livekit && {
type: "livekit",
livekit_service_url: config.livekit.livekit_service_url,
+28 -2
View File
@@ -45,6 +45,7 @@ import {
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
matrixRTCMode as matrixRTCModeSetting,
customTransport as customTransportSetting,
fullMeshPublishing as fullMeshPublishingSetting,
advancedScreenShare as advancedScreenShareSetting,
screenShareResolution as screenShareResolutionSetting,
screenShareFramerate as screenShareFramerateSetting,
@@ -68,6 +69,7 @@ import { Slider } from "../Slider";
import { useUrlParams } from "../UrlParams";
import {
authenticateWithTransport,
FULL_MESH_TRANSPORT_TYPE,
type TransportMetadata,
} from "@element-hq/matrixrtc-sdk";
import { useBehavior } from "../useBehavior";
@@ -175,6 +177,9 @@ export const DeveloperSettingsTab: FC<Props> = ({
const [customTransportUpdateError, setCustomTransportUpdateError] = useState<
string | null
>(null);
const [fullMeshPublishing, setFullMeshPublishing] = useSetting(
fullMeshPublishingSetting,
);
const [customTransport, setCustomTransport] = useSetting(
customTransportSetting,
);
@@ -417,6 +422,17 @@ export const DeveloperSettingsTab: FC<Props> = ({
)}
/>
</FieldRow>
<FieldRow>
<InputField
id="fullMeshPublishing"
type="checkbox"
checked={fullMeshPublishing}
label={t("developer_mode.full_mesh_publishing")}
onChange={(event: ChangeEvent<HTMLInputElement>): void =>
setFullMeshPublishing(event.target.checked)
}
/>
</FieldRow>
<FieldRow>
<InputField
id="debugTileLayout"
@@ -665,11 +681,21 @@ function transportText(transport: UnstableLivekitTransport | null): string {
const TransportBox: FC<TransportInfo> = ({ transport, local }) => {
const { t } = useTranslation();
const resolved = useBehavior(transport.resolved$);
const fullMesh = transport.type === FULL_MESH_TRANSPORT_TYPE;
return (
<div className={styles.livekit_room_box}>
<h4>{t("developer_mode.livekit_sfu", { url: transport.id })}</h4>
<h4>
{fullMesh
? t("developer_mode.full_mesh_transport", {
device: transport.raw.device_id,
})
: t("developer_mode.livekit_sfu", { url: transport.id })}
</h4>
<p>
{t("developer_mode.livekit_server_info")}({local ? "local" : "remote"})
{fullMesh
? t("developer_mode.full_mesh_peer_connections")
: t("developer_mode.livekit_server_info")}
({local ? "local" : "remote"})
</p>
<pre className={styles.pre}>
{resolved === undefined
@@ -51,6 +51,43 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<input
aria-describedby="_r_2_"
id="fullMeshPublishing"
type="checkbox"
/>
<label
for="fullMeshPublishing"
>
<div
class="_checkbox_1bd8c0"
>
<svg
fill="none"
height="24"
stroke="#000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20 6-11 11-5-5"
/>
</svg>
</div>
Publish on full mesh (MSC4575) instead of LiveKit
</label>
</div>
</div>
<div
class="_fieldRow_1bd8c0"
>
<div
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_3_"
id="debugTileLayout"
type="checkbox"
/>
@@ -87,7 +124,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_3_"
aria-describedby="_r_4_"
id="showConnectionStats"
type="checkbox"
/>
@@ -124,7 +161,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_4_"
aria-describedby="_r_5_"
id="muteAllAudio"
type="checkbox"
/>
@@ -162,7 +199,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_5_"
aria-describedby="_r_6_"
id="alwaysShowIphoneEarpiece"
type="checkbox"
/>
@@ -199,7 +236,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_6_"
aria-describedby="_r_7_"
id="enableLivekitExtendedLogs"
type="checkbox"
/>
@@ -237,7 +274,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-_r_7_"
for="radix-_r_8_"
>
Custom transport JSON
</label>
@@ -245,9 +282,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_controls_17lij_8"
>
<input
aria-describedby="radix-_r_8_"
aria-describedby="radix-_r_9_"
class="_control_d83jn_10"
id="radix-_r_7_"
id="radix-_r_8_"
name="input"
title=""
value=""
@@ -255,7 +292,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</div>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_8_"
id="radix-_r_9_"
>
Currently, no overwrite is set. Url from config is used.
</span>
@@ -285,10 +322,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_a_ radix-_r_c_"
aria-describedby="radix-_r_b_ radix-_r_d_"
checked=""
class="_input_1ug7n_18"
id="radix-_r_9_"
id="radix-_r_a_"
name="_r_0_"
title=""
type="radio"
@@ -304,13 +341,13 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-_r_9_"
for="radix-_r_a_"
>
Compatibility: state events & multi SFU
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_a_"
id="radix-_r_b_"
>
Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)
</span>
@@ -326,9 +363,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_a_ radix-_r_c_"
aria-describedby="radix-_r_b_ radix-_r_d_"
class="_input_1ug7n_18"
id="radix-_r_b_"
id="radix-_r_c_"
name="_r_0_"
title=""
type="radio"
@@ -344,13 +381,13 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-_r_b_"
for="radix-_r_c_"
>
Matrix 2.0: sticky events & multi SFU
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_c_"
id="radix-_r_d_"
>
Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later
</span>
@@ -415,7 +452,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_i_"
aria-describedby="_r_j_"
id="cameraToggle"
type="checkbox"
/>
@@ -445,7 +482,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</label>
<p
class="_description_1bd8c0"
id="_r_i_"
id="_r_j_"
>
Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.
</p>
@@ -467,7 +504,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_j_"
aria-describedby="_r_k_"
id="screenShareToggle"
type="checkbox"
/>
@@ -497,7 +534,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</label>
<p
class="_description_1bd8c0"
id="_r_j_"
id="_r_k_"
>
Configure resolution, framerate, bitrate, and codec for screen sharing
</p>
@@ -522,7 +559,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_k_"
aria-describedby="_r_l_"
checked=""
id="echoCancellation"
type="checkbox"
@@ -560,7 +597,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_l_"
aria-describedby="_r_m_"
checked=""
id="noiseSuppression"
type="checkbox"
@@ -598,7 +635,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_m_"
aria-describedby="_r_n_"
checked=""
id="autoGainControl"
type="checkbox"
+9
View File
@@ -159,6 +159,15 @@ export const customTransport = new Setting<UnstableLivekitTransport | null>(
null,
);
/**
* Publish on the full mesh transport (MSC4575) instead of LiveKit. Members on
* either transport are received whatever this says.
*/
export const fullMeshPublishing = new Setting<boolean>(
"full-mesh-publishing",
false,
);
export type VideoCodec = "vp8" | "vp9" | "h264" | "av1";
/**
+1 -1
View File
@@ -109,7 +109,7 @@ describe("widget", () => {
receiveState,
sendToDevice: sendRecvToDevice,
receiveToDevice: sendRecvToDevice,
turnServers: false,
turnServers: true,
rtcTransports: true,
rtcLivekitGetToken: true,
rtcLivekitDelegateDelayedLeave: true,
+11 -2
View File
@@ -21,8 +21,12 @@ import type { IWidgetApiRequest } from "matrix-widget-api";
import { LazyEventEmitter } from "./LazyEventEmitter";
import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config";
import { seedSettingsFromConfig } from "./settings/settings";
import {
fullMeshPublishing,
seedSettingsFromConfig,
} from "./settings/settings";
import { ElementCallReactionEventType } from "./reactions";
import { fullMeshSignallingEventTypes } from "@element-hq/matrixrtc-sdk";
// Subset of the actions in element-web
export enum ElementWidgetActions {
@@ -162,6 +166,10 @@ export const initializeWidget = (
EventType.CallSDPStreamMetadataChangedPrefix,
EventType.CallReplaces,
EventType.CallEncryptionKeysPrefix,
// Full mesh signalling (MSC4575), only where it is turned on: Element
// Web doesn't auto-approve these for Element Call yet, and would ask
// the user on every call
...(fullMeshPublishing.getValue() ? fullMeshSignallingEventTypes : []),
];
const client = createRoomWidgetClient(
@@ -173,7 +181,8 @@ export const initializeWidget = (
receiveState,
sendToDevice: sendRecvToDevice,
receiveToDevice: sendRecvToDevice,
turnServers: false,
// Full mesh connects through the homeserver's TURN server
turnServers: true,
rtcTransports: true,
rtcLivekitGetToken: true,
rtcLivekitDelegateDelayedLeave: true,