Merge pull request #4316 from element-hq/toger5/matrixrtc-sdk-package

[MatrixRTCSdk] add the sdk
This commit is contained in:
Timo
2026-10-14 10:23:24 +02:00
committed by GitHub
115 changed files with 16793 additions and 143 deletions
@@ -64,5 +64,6 @@ jobs:
with:
name: build-output-${{ inputs.package }}
path: dist
if-no-files-found: error
# We'll only use this in a triggered job, then we're done with it
retention-days: 1
+4 -1
View File
@@ -73,7 +73,10 @@ jobs:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build_sdk_element_call:
# Use the embedded package vite build
# The 'sdk' package is the CallViewModel bundle that predates the sdk/
# package (build:sdk:production, sdk-target-based-on-call-view-model/), not
# the package itself. The name is shared with pr-deploy.yaml, which always
# runs from main, so it stays until the bundle's consumers have moved over.
uses: ./.github/workflows/build-element-call.yaml
with:
package: sdk
+1
View File
@@ -35,6 +35,7 @@
],
"element-call/no-observablescope-leak": "error",
"element-call/no-top-level-logger-get-child": "error",
"element-call/sdk-import-boundary": "error",
"jsdoc/empty-tags": "error",
"jsdoc/check-property-names": "error",
"jsdoc/require-param-description": "warn",
+9 -1
View File
@@ -72,7 +72,15 @@ Code that builds in only one is a bug.
- `build:full` — standalone app, also widget mode.
- `build:embedded` — `@element-hq/element-call-embedded`.
- `build:sdk` — SDK library, entry `sdk/main.ts`.
- `build:sdk` — `@element-hq/matrixrtc-sdk`, sources in `sdk/`, entry `sdk/index.ts`.
Packaged exactly like the component (own pnpm project, run pnpm from the repo
root, types from `sdk/tsconfig.build.json`, externals checked by
`pnpm lint:externals`). Interface only so far; the design is `sdk-plan.md`.
- `build:sdk-target-based-on-call-view-model` — the SDK demo that predates the
package, one bundle of the `CallViewModel`; entry
`sdk-target-based-on-call-view-model/main.ts`. CI's `sdk` package
(`build:sdk:production` / `build:sdk:development`, the Netlify `-sdk` preview)
is still this bundle, not `build:sdk`.
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
(its own pnpm project; run pnpm from the repo root). Host API is in the README;
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
+4
View File
@@ -39,6 +39,9 @@ translations. The browser is Playwright's, so a fresh clone needs
- `playwright/component/` — the component in a host page, via the harness on port
3001 that Playwright starts as a second web server. Catches container-relative
layout, styles escaping the root, two instances on a page, host-bridge reports.
- `playwright/sdk/` — the MatrixRTC SDK through its harness on port 3002, a page
with none of Element Call on it. The smoke test is what the implementation is
built against.
- `playwright/mobile/` — Pixel 7, `mobile` project only.
Test what a user observes: the peer sees the change, it survives a reconnect, it is
@@ -50,4 +53,5 @@ pnpm test # unit + storybook
pnpm backend # Synapse + LiveKit, required for e2e
pnpm test:playwright # or :open
pnpm dev:component # component harness, port 3001
pnpm dev:sdk # SDK harness, port 3002
```
+128
View File
@@ -0,0 +1,128 @@
/*
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 { dirname, resolve, sep } from "node:path";
import { ESLintUtils } from "@typescript-eslint/utils";
/**
* Packages a host supplies and the SDK must not know about: UI, i18n and
* Element Call's design system.
*/
const BANNED_PACKAGES = [
"react",
"react-dom",
"react-i18next",
"i18next",
"@vector-im/",
];
/**
* Packages only a media backend may import. The session speaks MatrixRTC and
* sees media through `sdk/src/media-backend/api.ts`; a backend under
* `sdk/src/media-backend/<name>/` is the one place its protocol's library appears.
*/
const BACKEND_PACKAGES = ["livekit-client", "@livekit/"];
/**
* Keeps the SDK free of Element Call: nothing under `sdk/` may import a file
* outside `sdk/` by relative path (that is `src/`, `component/`,
* `playwright/`) or one of the packages above. The development harness under
* `sdk/dev/` is a host, not part of the library, and is exempt. Keeps the
* session free of any one media backend, too: outside `sdk/src/media-backend/`,
* a backend's packages may only be imported for their types, and only in
* the public API, the errors and the test helpers that have to name them.
*/
const rule = ESLintUtils.RuleCreator(
() => "https://github.com/element-hq/element-call",
)({
name: "sdk-import-boundary",
meta: {
type: "problem",
docs: {
description:
"Disallow the SDK importing Element Call (anything outside sdk/) or host-supplied packages.",
},
messages: {
outsideSdk:
"The SDK must not import '{{specifier}}': it resolves outside sdk/. Copy or move what it needs into sdk/src.",
bannedPackage:
"The SDK must not import '{{specifier}}': a host supplies that, the SDK has no UI.",
backendPackage:
"Only a media backend under sdk/src/media-backend/ may import '{{specifier}}'; the session sees media through media-backend/api.ts.",
},
schema: [],
},
defaultOptions: [],
create(context) {
const filename = context.filename;
const sdkRoot = resolve(context.cwd, "sdk") + sep;
if (!filename.startsWith(sdkRoot)) return {};
if (filename.startsWith(resolve(sdkRoot, "dev") + sep)) return {};
const inBackend = filename.startsWith(
resolve(sdkRoot, "src", "media-backend") + sep,
);
const matchesPackage = (specifier, pkg) =>
specifier === pkg ||
specifier.startsWith(pkg.endsWith("/") ? pkg : `${pkg}/`);
const check = (node, specifier, typeOnly = false) => {
if (typeof specifier !== "string") return;
if (
!inBackend &&
!typeOnly &&
BACKEND_PACKAGES.some((pkg) => matchesPackage(specifier, pkg))
) {
context.report({
node,
messageId: "backendPackage",
data: { specifier },
});
return;
}
if (specifier.startsWith(".")) {
const target = resolve(dirname(filename), specifier);
if (!target.startsWith(sdkRoot))
context.report({
node,
messageId: "outsideSdk",
data: { specifier },
});
} else if (
BANNED_PACKAGES.some((pkg) => matchesPackage(specifier, pkg))
) {
context.report({
node,
messageId: "bannedPackage",
data: { specifier },
});
}
};
return {
ImportDeclaration(node) {
check(
node,
node.source.value,
node.importKind === "type" ||
node.specifiers.every((s) => s.importKind === "type"),
);
},
ExportNamedDeclaration(node) {
if (node.source) check(node, node.source.value);
},
ExportAllDeclaration(node) {
check(node, node.source.value);
},
ImportExpression(node) {
if (node.source.type === "Literal") check(node, node.source.value);
},
};
},
});
export default rule;
+1
View File
@@ -4,5 +4,6 @@ module.exports = {
"no-observablescope-leak": require("./NoObservableScopeLeak").default,
"no-top-level-logger-get-child": require("./NoTopLevelLoggerGetChild")
.default,
"sdk-import-boundary": require("./SdkImportBoundary").default,
},
};
+2
View File
@@ -13,6 +13,8 @@ export default {
"vite.config.ts",
"vite-embedded.config.ts",
"vite-sdk.config.ts",
"vite-sdk-dev.config.ts",
"vite-sdk-target-based-on-call-view-model.config.ts",
"vite-component.config.ts",
"vite-component-dev.config.ts",
],
+9 -5
View File
@@ -7,6 +7,7 @@
"dev:full": "vite",
"dev:embedded": "vite --config vite-embedded.config.js",
"dev:component": "vite --config vite-component-dev.config.ts",
"dev:sdk": "vite --config vite-sdk-dev.config.ts",
"build": "pnpm build:full",
"build:full": "node --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
"build:full:production": "pnpm build:full",
@@ -14,9 +15,12 @@
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
"build:embedded:production": "pnpm build:embedded",
"build:embedded:development": "pnpm build:embedded --mode development",
"build:sdk:development": "pnpm build:sdk --mode development",
"build:sdk": "pnpm build:full --config vite-sdk.config.js",
"build:sdk:production": "pnpm build:sdk",
"build:sdk": "pnpm build:sdk:js && pnpm build:sdk:types",
"build:sdk:js": "pnpm build:full --config vite-sdk.config.js",
"build:sdk:types": "tsc -p sdk/tsconfig.build.json",
"build:sdk-target-based-on-call-view-model": "pnpm build:full --config vite-sdk-target-based-on-call-view-model.config.js",
"build:sdk:production": "pnpm build:sdk-target-based-on-call-view-model",
"build:sdk:development": "pnpm build:sdk-target-based-on-call-view-model --mode development",
"build:component": "pnpm build:component:js && pnpm build:component:types",
"build:component:js": "pnpm build:full --config vite-component.config.js",
"build:component:types": "tsc -p component/tsconfig.build.json",
@@ -26,8 +30,8 @@
"format": "oxfmt",
"format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc",
"lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip && pnpm lint:externals",
"lint:oxlint": "oxlint src component playwright",
"lint:oxlint-fix": "oxlint --fix src component playwright",
"lint:oxlint": "oxlint src component sdk playwright",
"lint:oxlint-fix": "oxlint --fix src component sdk playwright",
"lint:knip": "knip",
"lint:externals": "node scripts/check-component-externals.mjs",
"lint:types": "tsc",
+13
View File
@@ -12,6 +12,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { COMPONENT_HARNESS_URL } from "./playwright/component/harness.ts";
import { SDK_HARNESS_URL } from "./playwright/sdk/harness.ts";
const baseURL = process.env.USE_DOCKER
? "http://localhost:8080"
@@ -158,5 +159,17 @@ export default defineConfig({
timeout: 500,
},
},
{
// The harness that uses the MatrixRTC SDK without Element Call. A Vite
// dev server for the same reason as the component's.
command: "pnpm dev:sdk",
url: SDK_HARNESS_URL,
reuseExistingServer: !process.env.CI,
ignoreHTTPSErrors: true,
gracefulShutdown: {
signal: "SIGTERM",
timeout: 500,
},
},
],
});
+41
View File
@@ -0,0 +1,41 @@
/*
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, test } from "@playwright/test";
import { startPair, tileOf } from "./harness";
test.describe.configure({ timeout: 300_000 });
test("a message sent beside the media reaches the peer, and only the peer", async ({
browser,
}) => {
const { pages, users } = await startPair(browser, "sdkdata");
const [sender, receiver] = pages;
// Both are in the call before anything is sent, so the receiver knows the
// sender as a member and does not drop the message
for (const page of pages)
await expect(page.getByTestId("member")).toHaveCount(2, {
timeout: 60_000,
});
await expect(tileOf(receiver, users[0])).toBeVisible();
await sender.getByPlaceholder("message").fill("hello from the SDK");
await sender.getByRole("button", { name: "Send" }).click();
const message = receiver.getByTestId("message");
await expect(message).toHaveText(
`${users[0].displayName}: hello from the SDK`,
{
timeout: 30_000,
},
);
await expect(message).toHaveAttribute("data-topic", "chat");
await expect(message).toHaveAttribute("data-user-id", users[0].userId);
// Our own messages are not echoed back
await expect(sender.getByTestId("message")).toHaveCount(0);
});
+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 {
type Browser,
expect,
type Locator,
type Page,
} from "@playwright/test";
import { SynapseAdmin } from "../utils/synapse-admin.ts";
/**
* Where the SDK harness is served — `sdk/dev`, a page that uses the SDK the way
* a host would, with none of Element Call in it.
*/
export const SDK_HARNESS_URL = "https://localhost:3002";
const HOMESERVER_URL = "https://synapse.m.localhost";
const PASSWORD = "foobarbaz1!";
/** A registered user, with the session the registration opened. */
export interface User {
username: string;
displayName: string;
userId: string;
deviceId: string;
accessToken: string;
}
/**
* Registers two users through the Synapse admin API and has the first create a
* public room, so that the second can join it by id without an invite. The
* registration logs each user in, which spares the harness a `/login` call
* that the homeserver rate-limits.
*/
export async function createUsersAndRoom(
name: string,
): Promise<{ users: [User, User]; roomId: string }> {
const admin = SynapseAdmin.forHomeserver(HOMESERVER_URL);
const users = (await Promise.all(
["a", "b"].map(async (letter, index): Promise<User> => {
const username = `${name}_${letter}_${Date.now()}`;
const displayName = `${name} ${"AB"[index]}`;
const registration = await admin.registerUser(
username,
PASSWORD,
displayName,
);
return {
username,
displayName,
userId: registration.user_id,
deviceId: registration.device_id,
accessToken: registration.access_token,
};
}),
)) as [User, User];
const response = await fetch(
`${HOMESERVER_URL}/_matrix/client/v3/createRoom`,
{
method: "POST",
headers: {
Authorization: `Bearer ${users[0].accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: `${name}'s session`,
preset: "public_chat",
// Per-participant media keys travel in encrypted to-device messages,
// which only reach devices the sender's crypto tracks, and it tracks
// the members of encrypted rooms
initial_state: [
{
type: "m.room.encryption",
state_key: "",
content: { algorithm: "m.megolm.v1.aes-sha2" },
},
],
// Every member has to be allowed to write its own membership, which
// a public room does not grant by default; the same levels Element
// Call gives a room it creates for a call
power_level_content_override: {
state_default: 0,
events_default: 0,
users_default: 0,
events: { "org.matrix.msc3401.call.member": 0 },
},
}),
},
);
if (!response.ok)
throw new Error(
`Could not create a room: ${response.status} ${await response.text()}`,
);
const { room_id: roomId } = (await response.json()) as { room_id: string };
return { users, roomId };
}
/**
* Opens the harness signed in as the given user and waits until the SDK
* reports the session as connected. The status line carries the SDK's error
* if it does not get there, so the failure says why.
*/
export async function startHarness(
page: Page,
user: User,
roomId: string,
): Promise<void> {
const query = new URLSearchParams({
homeserver: HOMESERVER_URL,
accessToken: user.accessToken,
userId: user.userId,
deviceId: user.deviceId,
room: roomId,
});
await page.goto(`${SDK_HARNESS_URL}/?${query.toString()}`);
await page.getByRole("button", { name: "Start" }).click();
await waitForConnected(page);
}
/**
* A login, a crypto setup and an initial sync happen before the session can
* connect. An error is final, so it is not worth waiting out the timeout for
* "connected" after one.
*/
export async function waitForConnected(page: Page): Promise<void> {
const status = page.getByTestId("status");
await expect(status).toHaveText(/^(connected|Error)/, { timeout: 120_000 });
const text = await status.textContent();
if (text !== "connected")
throw new Error(`The harness did not connect to the session: ${text}`);
}
/**
* A context of its own per user, since a browser profile holds one login. No
* permissions to grant: each browser is launched with fake media that is
* handed out without asking (see playwright.config.ts).
*/
export async function newPage(browser: Browser): Promise<Page> {
const context = await browser.newContext({ ignoreHTTPSErrors: true });
return context.newPage();
}
/** Two users in one session, each on a page of their own. */
export async function startPair(
browser: Browser,
name: string,
): Promise<{ pages: [Page, Page]; users: [User, User]; roomId: string }> {
const { users, roomId } = await createUsersAndRoom(name);
const pages = await Promise.all([newPage(browser), newPage(browser)]);
await Promise.all(
pages.map(async (page, i) => startHarness(page, users[i], roomId)),
);
return { pages: pages as [Page, Page], users, roomId };
}
/** The tile a page shows for a user. */
export function tileOf(page: Page, user: User): Locator {
return page.locator(`[data-testid="member"][data-user-id="${user.userId}"]`);
}
+67
View File
@@ -0,0 +1,67 @@
/*
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, test } from "@playwright/test";
import { startPair, tileOf, waitForConnected } from "./harness.ts";
/**
* Members coming and going: a member that leaves disappears for the peer, one
* that comes back is shown again, and the harness is left in the state its
* status line claims.
*/
test.describe.configure({ timeout: 300_000 });
test("a member that leaves disappears for the peer", async ({ browser }) => {
const {
pages: [leaver, stayer],
users: [leaving, staying],
} = await startPair(browser, "sdkleave");
await expect(stayer.getByTestId("member")).toHaveCount(2, {
timeout: 60_000,
});
await leaver.getByRole("button", { name: "Leave" }).click();
await expect(leaver.getByTestId("status")).toHaveText("Left");
await expect(leaver.getByTestId("member")).toHaveCount(0);
await expect(tileOf(stayer, leaving)).toHaveCount(0, { timeout: 60_000 });
await expect(tileOf(stayer, staying)).toHaveCount(1);
await expect(stayer.getByTestId("status")).toHaveText("connected");
});
test("a member that reloads is shown again with its media", async ({
browser,
}) => {
const {
pages: [reloader, watcher],
users: [reloading],
} = await startPair(browser, "sdkreload");
await expect(watcher.getByTestId("member")).toHaveCount(2, {
timeout: 60_000,
});
// The page comes back on the same device, so the new membership replaces
// the old one rather than sitting next to it
await reloader.reload();
await reloader.getByRole("button", { name: "Start" }).click();
await waitForConnected(reloader);
await expect(tileOf(watcher, reloading)).toHaveCount(1, {
timeout: 120_000,
});
const video = tileOf(watcher, reloading).locator("video");
await expect
.poll(async () => video.evaluate((v: HTMLVideoElement) => v.videoWidth), {
timeout: 60_000,
})
.toBeGreaterThan(0);
await expect(reloader.getByTestId("member")).toHaveCount(2, {
timeout: 60_000,
});
});
+186
View File
@@ -0,0 +1,186 @@
/*
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 Locator, type Page, test } from "@playwright/test";
import { startPair, tileOf, type User } from "./harness.ts";
/**
* What a member's media looks like from the other side: it plays, it is
* encrypted, it follows the mute switches, and the SFU sends only as much as
* the tile on screen can show. Each test reads the labels the harness puts on
* its media elements, which come straight from the SDK's track behaviors.
*
* One session serves every test here: logging two browsers in and connecting
* them takes most of a minute, and none of the tests leaves the session in a
* state the next cannot start from.
*/
test.describe.configure({ mode: "serial", timeout: 300_000 });
let pages: [Page, Page];
let users: [User, User];
test.beforeAll(async ({ browser }) => {
({ pages, users } = await startPair(browser, "sdkmedia"));
for (const page of pages)
await expect(page.getByTestId("member")).toHaveCount(2, {
timeout: 60_000,
});
});
test.afterAll(async () => {
for (const page of pages ?? []) await page.context().close();
});
test("the peer's camera and microphone play", async () => {
const peer = remoteTile(1, 0);
const video = peer.locator("video");
await expect
.poll(async () => video.evaluate((v: HTMLVideoElement) => v.videoWidth), {
timeout: 60_000,
})
.toBeGreaterThan(0);
await expect(video).toHaveJSProperty("paused", false);
const audio = peer.locator("audio");
await expect
.poll(async () =>
audio.evaluate((a: HTMLAudioElement) => a.srcObject !== null),
)
.toBe(true);
await expect(audio).toHaveJSProperty("paused", false);
});
test("the peer's tracks are encrypted", async () => {
const peer = remoteTile(1, 0);
await expect(peer.locator("video")).toHaveAttribute("data-encrypted", "true");
await expect(peer.locator("audio")).toHaveAttribute("data-encrypted", "true");
});
test("muting the microphone and the camera is seen by the peer", async () => {
const [page] = pages;
const peer = remoteTile(1, 0);
const video = peer.locator("video");
const audio = peer.locator("audio");
await expect(video).toHaveAttribute("data-muted", "false");
await expect(audio).toHaveAttribute("data-muted", "false");
await page.getByRole("button", { name: "Microphone" }).click();
await expect(audio).toHaveAttribute("data-muted", "true", {
timeout: 20_000,
});
await expect(video).toHaveAttribute("data-muted", "false");
await page.getByRole("button", { name: "Camera" }).click();
await expect(video).toHaveAttribute("data-muted", "true", {
timeout: 20_000,
});
await page.getByRole("button", { name: "Microphone" }).click();
await page.getByRole("button", { name: "Camera" }).click();
await expect(audio).toHaveAttribute("data-muted", "false", {
timeout: 20_000,
});
await expect(video).toHaveAttribute("data-muted", "false", {
timeout: 20_000,
});
});
test("the peer's microphone is active while it sends sound, and not while muted", async () => {
// Chromium's fake microphone never registers as a speaker at the SFU, and
// Firefox's steady tone does so in about two runs out of three, with or
// without the browser's audio processing. The pipeline is proven when it
// passes; it needs a louder fake device or a lower SFU threshold to be
// relied on.
test.fixme(true, "the fake microphones do not reliably register at the SFU");
const [page] = pages;
const audio = remoteTile(1, 0).locator("audio");
// The SFU only announces a change of speakers, so a tone that has been
// playing since before the peer subscribed is not announced to it. Muting
// and unmuting is the change that is.
await page.getByRole("button", { name: "Microphone" }).click();
await expect(audio).toHaveAttribute("data-muted", "true", {
timeout: 20_000,
});
await expect(audio).toHaveAttribute("data-active", "false");
await page.getByRole("button", { name: "Microphone" }).click();
await expect(audio).toHaveAttribute("data-active", "true", {
timeout: 30_000,
});
await page.getByRole("button", { name: "Microphone" }).click();
await expect(audio).toHaveAttribute("data-active", "false", {
timeout: 20_000,
});
await page.getByRole("button", { name: "Microphone" }).click();
});
test("a larger tile receives a higher resolution", async () => {
const peer = remoteTile(1, 0);
const video = peer.locator("video");
// The tile starts small enough for the lowest simulcast layer
const small = await receivedWidth(video, (width) => width > 0);
await resize(peer, 1280);
const large = await receivedWidth(video, (width) => width > small);
expect(large).toBeGreaterThan(small);
await resize(peer, 240);
await receivedWidth(video, (width) => width < large);
});
test("a hidden tile stops receiving video, and a shown one resumes", async () => {
const peer = remoteTile(1, 0);
const video = peer.locator("video");
await expect(frames(video)).resolves.toBeGreaterThan(0);
await peer.evaluate((tile) => (tile.style.display = "none"));
await expect
.poll(async () => framesStill(video), { timeout: 60_000 })
.toBe(true);
await peer.evaluate((tile) => (tile.style.display = ""));
await expect
.poll(async () => !(await framesStill(video)), { timeout: 60_000 })
.toBe(true);
});
/** The tile on one page for the user of the other page. */
function remoteTile(viewer: 0 | 1, shown: 0 | 1): Locator {
return tileOf(pages[viewer], users[shown]);
}
async function resize(tile: Locator, width: number): Promise<void> {
await tile.evaluate((t, w) => (t.style.width = `${w}px`), width);
}
/** The width of the video as decoded, once it satisfies the condition. */
async function receivedWidth(
video: Locator,
until: (width: number) => boolean,
): Promise<number> {
// Switching simulcast layers takes the SFU a few seconds
let width = 0;
await expect
.poll(
async () => {
width = await video.evaluate((v: HTMLVideoElement) => v.videoWidth);
return until(width);
},
{ timeout: 60_000 },
)
.toBe(true);
return width;
}
async function frames(video: Locator): Promise<number> {
return Number(await video.getAttribute("data-frames"));
}
/** Whether no frame arrived over a couple of seconds. */
async function framesStill(video: Locator): Promise<boolean> {
const before = await frames(video);
await new Promise((resolve) => setTimeout(resolve, 2500));
return (await frames(video)) === before;
}
+32
View File
@@ -0,0 +1,32 @@
/*
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, test } from "@playwright/test";
import { startPair, tileOf } from "./harness.ts";
/**
* The MatrixRTC SDK driven through its harness in `sdk/dev`: no Element Call
* on the page, only the SDK's public API.
*/
// Two browsers each log in, set up crypto and sync before anything is on
// screen, then wait for media to connect
test.describe.configure({ timeout: 300_000 });
test("two browsers see each other in one session", async ({ browser }) => {
const { pages, users } = await startPair(browser, "sdksmoke");
// Each page shows itself and the other, each tile named by its user
for (const page of pages) {
await expect(page.getByTestId("member")).toHaveCount(2, {
timeout: 60_000,
});
for (const user of users)
await expect(tileOf(page, user)).toContainText(user.displayName);
}
});
+90 -75
View File
@@ -6,57 +6,72 @@ Please see LICENSE in the repository root for full details.
*/
/**
* Checks that the component build leaves the packages a host must supply to
* the host.
* Checks that the library builds leave the packages a host must supply to the
* host.
*
* A host application already has React, the Matrix SDK and Compound,
* and a second copy of any of them is worse than dead weight: React would hold
* two sets of hooks, the Matrix client would run two sync loops, and a second
* Compound would style the tooltips it floats into the host's body with class
* names the host's stylesheet does not know. So the
* component build lists them as external — but that list has to name every
* subpath, since the bundler silently ignores the pattern and callback forms
* of the option, and an import it does not cover is bundled with no warning at
* all. That is the failure this guards against.
* names the host's stylesheet does not know. So each library build lists them
* as external — but that list has to name every subpath, since the bundler
* silently ignores the pattern and callback forms of the option, and an import
* it does not cover is bundled with no warning at all. That is the failure
* this guards against.
*
* It reads the list from the build config itself, so there is one copy of it,
* and compares it against every import of those packages in the source.
* It reads each list from the build config itself, so there is one copy of it
* per build, and compares it against every import of those packages in the
* source.
*
* The comparison is deliberately over-approximate: it looks at all of `src`
* rather than only the modules the component actually pulls in, so it will
* sometimes ask for a subpath that only the standalone app imports. Listing
* one the component never imports costs nothing — the bundler ignores it —
* whereas missing one costs a duplicate package.
* rather than only the modules a build actually pulls in, so it will sometimes
* ask for a subpath that only the standalone app imports. Listing one a build
* never imports costs nothing — the bundler ignores it — whereas missing one
* costs a duplicate package.
*/
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { loadConfigFromFile } from "vite";
const CONFIG = "vite-component.config.ts";
const SOURCES = ["src", "component"];
// The development harness is a host, not part of the component: it is the
// one that imports what a host supplies (Compound's stylesheets, say).
const EXCLUDED = ["component/dev"];
/** The packages whose duplication would break a host, rather than merely enlarge it. */
const MUST_BE_EXTERNAL = [
"react",
"react-dom",
"matrix-js-sdk",
"@vector-im/compound-web",
"@vector-im/compound-design-tokens",
/**
* The library builds, each with the directories it is built from and the
* packages whose duplication would break a host rather than merely enlarge it.
* A development harness is a host, not part of the library: it is the one that
* imports what a host supplies (Compound's stylesheets, say).
*/
const TARGETS = [
{
config: "vite-component.config.ts",
what: "the component build",
sources: ["src", "component"],
excluded: ["component/dev"],
mustBeExternal: [
"react",
"react-dom",
"matrix-js-sdk",
"@vector-im/compound-web",
"@vector-im/compound-design-tokens",
],
},
{
config: "vite-sdk.config.ts",
what: "the SDK build",
sources: ["src", "sdk"],
excluded: ["sdk/dev"],
mustBeExternal: ["matrix-js-sdk", "livekit-client", "rxjs"],
},
];
const isTestFile = (name) =>
name.includes(".test.") || name.includes(".stories.");
/** Every source file under the given directories, recursively. */
async function* sourceFiles(dir) {
async function* sourceFiles(dir, excluded) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (EXCLUDED.includes(path)) continue;
if (entry.isDirectory()) yield* sourceFiles(path);
if (excluded.includes(path)) continue;
if (entry.isDirectory()) yield* sourceFiles(path, excluded);
else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name))
yield path;
}
@@ -88,60 +103,60 @@ function imports(source) {
* with the host's copy of anything. Worker sub-builds do not inherit this
* option anyway.
*/
const mustBeExternal = (specifier) =>
const mustBeExternal = (specifier, packages) =>
!specifier.includes("?") &&
MUST_BE_EXTERNAL.some(
(pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`),
);
packages.some((pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`));
const loaded = await loadConfigFromFile(
{ command: "build", mode: "production" },
CONFIG,
);
if (loaded === null) {
console.error(`Could not load ${CONFIG}`);
process.exit(1);
}
const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []);
if (declared.size === 0) {
console.error(
`${CONFIG} declares nothing external. Either the option moved, or the ` +
`list is empty; either way this check is not looking at what it thinks.`,
/** @returns Whether the target's externals list covers its imports. */
async function check({
config,
what,
sources,
excluded,
mustBeExternal: packages,
}) {
const loaded = await loadConfigFromFile(
{ command: "build", mode: "production" },
config,
);
process.exit(1);
}
// Where each missing specifier is imported, so the message can point at it
const missing = new Map();
const seen = new Set();
for (const dir of SOURCES)
for await (const file of sourceFiles(dir)) {
const source = await readFile(file, "utf8");
for (const specifier of imports(source)) {
if (!mustBeExternal(specifier)) continue;
seen.add(specifier);
if (declared.has(specifier)) continue;
const files = missing.get(specifier) ?? [];
files.push(file);
missing.set(specifier, files);
}
if (loaded === null) {
console.error(`Could not load ${config}`);
return false;
}
const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []);
if (declared.size === 0) {
console.error(
`${config} declares nothing external. Either the option moved, or the ` +
`list is empty; either way this check is not looking at what it thinks.`,
);
return false;
}
if (missing.size > 0) {
// Where each missing specifier is imported, so the message can point at it
const missing = new Map();
for (const dir of sources)
for await (const file of sourceFiles(dir, excluded)) {
const source = await readFile(file, "utf8");
for (const specifier of imports(source)) {
if (!mustBeExternal(specifier, packages)) continue;
if (declared.has(specifier)) continue;
const files = missing.get(specifier) ?? [];
files.push(file);
missing.set(specifier, files);
}
}
if (missing.size === 0) return true;
console.error(
`${CONFIG} does not declare these imports external, so the component ` +
`build would bundle its own copy of them:\n`,
`${config} does not declare these imports external, so ${what} ` +
`would bundle its own copy of them:\n`,
);
for (const [specifier, files] of [...missing].sort())
console.error(` ${specifier}\n imported by ${files.join(", ")}`);
console.error(`\nAdd each one to the \`external\` list in ${CONFIG}.`);
process.exit(1);
console.error(`\nAdd each one to the \`external\` list in ${config}.\n`);
return false;
}
// Deliberately no complaint about declarations nothing imports. Some of them
// cannot be seen from the source at all — `react/jsx-runtime` is injected by
// the JSX transform — and an extra declaration is inert, so there is nothing
// to warn about.
console.log(
`${declared.size} external declarations cover all ${seen.size} imports of ${MUST_BE_EXTERNAL.join(", ")}.`,
);
let ok = true;
for (const target of TARGETS) ok = (await check(target)) && ok;
if (!ok) process.exit(1);
@@ -0,0 +1,66 @@
# SDK mode (EXPERIMENTAL, superseded)
> Superseded by the `sdk/` package (`@element-hq/matrixrtc-sdk`) and its harness in
> `sdk/dev`; see `sdk-plan.md`. This folder still builds, with its own script, until
> that package can do what this does.
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
It allows to use matrixRTC in combination with livekit without relying on element call.
This is done by instantiating the call view model and exposing some useful behaviors (observables) and methods.
This folder contains an example index.html file that showcases the sdk in use (hosted on localhost:8123 with a webserver allowing cors (for example `npx serve -l 81234 --cors`)) as a godot engine HTML export template.
## Getting started
To get started run
```
pnpm install
pnpm build:sdk-target-based-on-call-view-model
```
in the repository root. (`pnpm build:sdk` builds the `sdk/` package instead.)
CI's `sdk` package (`build:sdk:production`, `build:sdk:development`, the
`build-output-sdk` artifact and the `pr<N>-sdk` Netlify preview) is still this
bundle, not the `sdk/` package, until its consumers have moved over.
It will create `dist` containing the compiled js file, `matrixrtc-sdk.js`.
This file needs to be hosted. Locally (via `npx serve -l 81234 --cors`) or on a remote server.
Now you just need to add the widget to element web via:
```
/addwidget http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
```
## Widgets
The sdk mode is particularly interesting to be used in widgets. In widgets you do not need to pay attention to matrix login/cs api ...
To create a widget see the example `index.html` file in this folder. And add it to EW via:
`/addwidget <widgetUrl>` (see **url parameters** for more details on `<widgetUrl>`)
### url parameters
The url parameters are needed to pass initial data to the widget. They will automatically be used
by the matrixRTCSdk to start the postmessage widget api (communication between the client (e.g. Element Web) and the widget)
```
widgetId = $matrix_widget_id
userId = $matrix_user_id
deviceId = $org.matrix.msc3819.matrix_device_id
baseUrl = $org.matrix.msc4039.matrix_base_url
```
`parentUrl = // will be inserted automatically`
Full template use as `<widgetUrl>`:
```
http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
```
the `$` prefixed variables will be replaced by EW on widget instantiation. (e.g. `$matrix_user_id` -> `@user:example.com` (url encoding will also be applied automatically by EW) -> `%40user%3Aexample.com`)
@@ -8,7 +8,8 @@ Please see LICENSE in the repository root for full details.
/**
* EXPERIMENTAL
*
* This file is the entrypoint for the sdk build of element call: `pnpm build:sdk`
* This file is the entrypoint for the sdk build of element call:
* `pnpm build:sdk-target-based-on-call-view-model`
* use in widgets.
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
* can be used to join a rtc session and exchange realtime data.
+94 -40
View File
@@ -1,58 +1,112 @@
# SDK mode (EXPERIMENTAL)
# MatrixRTC SDK (EXPERIMENTAL)
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
`@element-hq/matrixrtc-sdk` is the call model under Element Call, on its own:
it joins a MatrixRTC slot in a Matrix room, writes and watches the memberships,
connects to the media transport (LiveKit today), publishes the local media and
exposes every member and their tracks as observables. It has no UI. Element
Call's own `CallViewModel` is one consumer of it; any host can build a different
view on top. The design is in [`SdkArchitecture.md`](./SdkArchitecture.md), what
moved out of Element Call's `src/` in [`SdkMigration.md`](./SdkMigration.md).
It allows to use matrixRTC in combination with livekit without relying on element call.
**Status:** first implementation. `createRTCSlot` and `slot.join()` join the
session, connect to the transport, publish the local media and expose every
member's media; the development harness and its e2e tests in `playwright/sdk`
drive it, and Element Call's own `CallViewModel` is built on it. It depends on
nothing in Element Call's `src/`.
This is done by instantiating the call view model and exposing some useful behaviors (observables) and methods.
## Using it
This folder contains an example index.html file that showcases the sdk in use (hosted on localhost:8123 with a webserver allowing cors (for example `npx serve -l 81234 --cors`)) as a godot engine HTML export template.
```ts
import {
createRTCSlot,
E2eeType,
MatrixRTCMode,
ObservableScope,
trackBySource$,
} from "@element-hq/matrixrtc-sdk";
## Getting started
const scope = new ObservableScope();
const slot = createRTCSlot(
scope,
client, // a matrix-js-sdk MatrixClient, logged in and syncing
room, // the matrix-js-sdk Room, from client.getRoom() once the join has synced
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
matrixRTCMode: MatrixRTCMode.Compatibility,
},
);
const participation = slot.join({
publish: [{ source: "microphone" }, { source: "camera" }],
});
To get started run
// A <video> for each remote member's camera and an <audio> for its microphone.
// A track plays on an element once attached to it; detach before the element
// leaves the DOM. (`tileFor`, `setName` and `removeTilesExcept` are the host's.)
participation.remoteMembers$.subscribe((members) => {
for (const member of members) {
const { video, audio } = tileFor(member.memberId);
member.displayName$.subscribe((name) => setName(member.memberId, name));
trackBySource$(scope, member.tracks$, "camera").subscribe((track) =>
track?.attach(video),
);
trackBySource$(scope, member.tracks$, "microphone").subscribe((track) =>
track?.attach(audio),
);
}
removeTilesExcept(members.map((m) => m.memberId));
});
```
pnpm install
pnpm build:sdk
// later
participation.leave();
scope.end();
```
in the repository root.
`participation.localMember$` is one member of the same shape, null until our own
membership is in the room; its microphone would only echo.
It will create a `dist` folder containing the compiled js file.
[`dev/main.ts`](./dev/main.ts) is this example as a working page.
This file needs to be hosted. Locally (via `npx serve -l 81234 --cors`) or on a remote server.
## Reading the code
Now you just need to add the widget to element web via:
Three files define the surface. Read them in this order.
```
/addwidget http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
- [`src/api.ts`](./src/api.ts): the MatrixRTC side. An `RTCSlot` is the
namespace in a room that memberships join into; `join()` returns an
`RTCParticipation`, which lasts until its `leave()` and exposes `state$`,
`localMember$` and `remoteMembers$`. Members, transports and the participation
state machine live here. `createRTCSlot` in `RTCSlot.ts` is the only way to get
an implementation.
- [`src/media-api.ts`](./src/media-api.ts): media as a host sees it, on
`remoteMembers$` and `localMember$`. `PublishRequest` says what to publish
(microphone, camera, screen share); `MediaTrack` is how it arrives. The
underlying tracks stay hidden: the host hands an element to `attach` and the
SDK plays into it. The local variants carry the controls (`setEnabled`,
`setDevice`, `setProcessor`).
- [`src/media-backend/api.ts`](./src/media-backend/api.ts): internal, the seam
between the session and whatever carries the media. The session speaks
MatrixRTC; a `MediaBackend` speaks one media protocol and hands back the
`MemberMedia` of each member through `mediaFor()`. A host rarely touches it;
it matters when working on the LiveKit backend under
`src/media-backend/livekit/`, the only place that imports `livekit-client`, or
when writing a new backend.
## Development
Everything runs from the repository root. This directory is a pnpm project of its
own only so that a host can install it as a git dependency (see
`pnpm-workspace.yaml`).
```sh
pnpm dev:sdk # the harness, https://localhost:3002
pnpm build:sdk # dist/matrixrtc-sdk.js and dist/types
pnpm test:playwright playwright/sdk # needs `pnpm backend`
```
## Widgets
## Installing
The sdk mode is particularly interesting to be used in widgets. In widgets you do not need to pay attention to matrix login/cs api ...
To create a widget see the example `index.html` file in this folder. And add it to EW via:
`/addwidget <widgetUrl>` (see **url parameters** for more details on `<widgetUrl>`)
### url parameters
The url parameters are needed to pass initial data to the widget. They will automatically be used
by the matrixRTCSdk to start the postmessage widget api (communication between the client (e.g. Element Web) and the widget)
```
widgetId = $matrix_widget_id
userId = $matrix_user_id
deviceId = $org.matrix.msc3819.matrix_device_id
baseUrl = $org.matrix.msc4039.matrix_base_url
```sh
pnpm add github:element-hq/element-call#<ref>&path:/sdk
```
`parentUrl = // will be inserted automatically`
Full template use as `<widgetUrl>`:
```
http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
```
the `$` prefixed variables will be replaced by EW on widget instantiation. (e.g. `$matrix_user_id` -> `@user:example.com` (url encoding will also be applied automatically by EW) -> `%40user%3Aexample.com`)
The host supplies `matrix-js-sdk`, `livekit-client` and `rxjs`; the build leaves
them external.
+824
View File
@@ -0,0 +1,824 @@
# MatrixRTC SDK architecture
`@element-hq/matrixrtc-sdk` is the model under Element Call, on its own: one
`MatrixRTCClient` per room that joins and leaves the MatrixRTC session, discovers
and connects to the media transports, publishes the local media, distributes the
encryption keys, and hands out every member with its media as observables. It has
no UI, reads no configuration and no settings, and never names LiveKit in its
interface.
Element Call becomes one consumer of it: `CallViewModel` keeps what makes a session
a _call_ (ringing, auto-leave, layout, sounds, reactions, settings, the host
bridge) and takes everything else from the client. The dependency direction is
therefore `Element Call → sdk`, never the reverse. What moved out of Element
Call's `src/` to get there is listed in [`SdkMigration.md`](./SdkMigration.md).
## The architecture
```
host application (Element Call, a whiteboard, watch-together, …)
│
│ createMatrixRTCClient(scope, matrixClient, room, options)
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ MatrixRTCClient │
│ │
│ public API, all Behaviors: │
│ join() leave() state$ │
│ localMember$ remoteMembers$ memberCount$ sendData() data$ │
│ keyRotationSuppressed$ connectedTransports$ │
│ │
│ per member RTCMember { id userId deviceId membership$ displayName$ │
│ avatarUrl$ transport$ tracks$ encryptionError$ }│
│ per track MediaTrack { source kind muted$ encrypted stats$ attach() } │
│ │
│ ┌───────────────────────────┐ MediaBackend ┌───────────────────────────┐ │
│ │ session: MatrixRTC │ media-backend/ │ media backend │ │
│ │ │ api.ts │ media-backend/livekit/ │ │
│ │ js-sdk MatrixRTCSession, │ │ today, or options.backend │ │
│ │ later the rust-rtc │ prepareLocal │ │ │
│ │ crate behind a bridge │ Transport ───▶│ auth: MSC4195 CS API or │ │
│ │ memberships from room │ delegateDelayed │ JWT service, by shape │ │
│ │ state and sticky events │ Leave ───────▶│ and role; delegation │ │
│ │ own membership, delayed │ │ ConnectionMap: one │ │
│ │ leave, retries │ mediaKeys$ ────▶│ LiveKit room per │ │
│ │ discovery of the local │ mediaFor$ ─────▶│ transport locator, │ │
│ │ transport (homeserver) │ ◀─ tracks$, │ local published, │ │
│ │ membership state machine │ encryptionError$│ remote subscribed │ │
│ │ (LocalMembership) │ local.publish, │ Publisher: local tracks, │ │
│ │ transport registry │ setPublishing▶│ upstream paused until │ │
│ │ (TransportMetadata) │ ◀─ connections$ │ joined │ │
│ │ data packet → member │ ◀─ data$ │ LivekitMemberMedia: │ │
│ │ │ │ participant → tracks │ │
│ └───────────────────────────┘ └───────────────────────────┘ │
│ │
│ RTCMember = membership + mediaFor$(membership): the backend matches the │
│ member's backendIdentities to the identity it sees on the transport │
└──────────────────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
MatrixClient (sync, room state, to-device, OpenID) LiveKit SFU
```
The left box is MatrixRTC: who is in the session, with which transport, the
keys, the delayed leave, and the membership state machine. The right box is a
media backend: whatever carries the media, behind the `MediaBackend` interface in
`sdk/src/media-backend/api.ts`. The client is the join of the two: a member exists
once its membership does, and gets its media once the backend has something
for that membership on its transport; `state$` is the membership state combined
with the local media connection. LiveKit is the one backend today; a
cascading SFU or full mesh is another folder under `sdk/src/media-backend/`, handed
in through `options.backend`, and nothing in the left box changes.
### How Element Call uses it
```
Element Call, as app, widget or component
┌────────────────────────────────────────────────────────────────┐
│ views: InCallView, tiles, header, footer, settings │
│ ▲ Behaviors │
│ CallViewModel │
│ layout, ringing and notifications, auto-leave, sounds, │
│ reactions, hand raise, settings, audio routing, host bridge │
│ ▲ state$, localMember$, remoteMembers$, tracks$ │
│ MatrixRTCClient @element-hq/matrixrtc-sdk │
└────────────────────────────────────────────────────────────────┘
│ MatrixClient + Room │ livekit-client
▼ ▼
homeserver SFU
```
All three modes already hold a `MatrixClient` and a `Room` by the time a call
starts: standalone after `useLoadGroupCall`, the component from the host's `client`
prop, the widget from `createRoomWidgetClient`. The view model passes them on and
builds its media view models from `RTCMember` and its `tracks$` instead of from
LiveKit participants.
### Joining, in order
```
host MatrixRTCClient / backend homeserver / JWT service LiveKit SFU
│ create ───▶│ │ │
│ │ GET rtc/transports (session) ────────▶│ │
│ │◀── first transport of the backend's type │
│ │ prepareLocalTransport (backend): │ │
│ │ publisher token: CS API get_token, │ │
│ │ or OpenID token then /sfu/get ─────▶│ │
│ │◀── sfu url, jwt, alias, identity ─────│ = resolved$ of the │
│ │ probe delegate_delayed_leave ──────▶│ local transport │
│ │ connect(url, jwt) ───────────────────────────────────────────▶│
│ join() ───▶│ (once the publisher holds its token)│ │
│ │ membership state event + delayed leave ──▶│ │
│ │ delegateDelayedLeave(delayId) (backend) ─▶│ │
│ │ create tracks, resume upstream ──────────────────────────────▶│
│ │ media key to every other member (to-device) ──▶│ │
│ │◀── other memberships (sync) ──────────│◀── participants ────────│
│◀─ state$ connected, localMember$, remoteMembers$ with tracks$ ───────────────│
```
The session discovers the transport from the homeserver and the backend prepares
it, both before `join()`, so that the membership can name it and the first frames
go out as soon as the state event is sent. Preparing is the backend's own
business: for LiveKit the token exchange for the publisher connection, and the
probe that decides whether the service that issued the token can take over the
delayed leave, which picks the delayed leave timings the join uses. The join
waits for the publisher to hold its token, so that the LiveKit room exists
before a subscriber, who lacks the permission to create it, asks for a token
of its own. Until `join()` the tracks exist but their upstream is paused: a host
can show a preview without anyone hearing it. The upstream flows exactly while the
membership is `joined`: a sync outage pauses it, and `leave()` pauses it for good
and sends the leave.
## Vocabulary
The words the SDK is written in. They name the shape after the slot and
participation split in
[`client-into-slot-session-split-plan.md`](./client-into-slot-session-split-plan.md);
the sections below still describe `MatrixRTCClient` until that lands.
| Word | Meaning here | Elsewhere |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| slot | an `RTCSlot`: the namespace in a room that memberships join into, `application` plus `id`. It exists while the room does, with nobody in it too. Holds `members$`, its open or closed `status$`, and `join()` | the js-sdk `MatrixRTCSession` is one per slot; the MSC4143 slot event |
| participation | an `RTCParticipation`: the span between our `join()` of a slot and our `leave()`. Holds the connection and its `state$`, `localMember$`, `remoteMembers$`, the connected transports and the data channel. Made by `slot.join()`, never reused | "session" is not used here, because in the js-sdk it means the slot |
| member | an `RTCMember`: one membership in a slot with what the SDK derives from it, `rtcBackendIdentity`, `userId`, `deviceId`, `memberId`, `displayName$`, `avatarUrl$`, `transport$` and `applicationData$`. No media. What the slot hands out in `members$` | the js-sdk `CallMembership` behind it, which never leaves the SDK; Element Call's old "matrixLivekitMember" |
| local member, remote member | `LocalRTCMember` and `RemoteRTCMember`: an `RTCMember` plus its `MemberMedia`, which only a participation can carry. The local one's tracks carry the controls. What the participation hands out in `localMember$` and `remoteMembers$` | LiveKit `LocalParticipant` and `RemoteParticipant`, which never leave the SDK |
| media | a `MemberMedia`: what a member sends once it has arrived on its transport, `tracks$`, null while waiting, and `encryptionError$` beside it. The media backend supplies exactly these two fields per member | Element Call's `MediaViewModel` is a view of it |
| track | a `MediaTrack`: one publication of a member, audio or video, with `source`, `kind`, `id` and the behaviors on it. `LocalAudioMediaTrack` and `LocalVideoMediaTrack` add the controls over our own | LiveKit `TrackPublication` and `Track`, which never leave the SDK |
| publish request | a `PublishRequest`: a source, where to capture it from and how to encode it. Given at the join in `publish`, or later to `publish` on the participation | |
| attach, detach | handing a `<video>` or `<audio>` element to a track and taking it back. The SDK owns the stream and the observers on the element | LiveKit `Track.attach` |
| transport | an `RTCTransport`: where media is exchanged, a LiveKit service url today. Carries the raw js-sdk `Transport` from the membership and, in `resolved$`, what the backend fetched to connect to it | MSC4143 "focus" |
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation, chosen with `backend` in the join options; LiveKit under `sdk/src/media-backend/livekit/` is the one that exists | "SFU", a LiveKit room, a peer connection |
Names use `RTC` in capitals, as the js-sdk does: `RTCSlot`, `RTCParticipation`,
`RTCMember`, `matrixRTCMode`.
## Reactive primitives
The whole public API is made of `Behavior`s: an rxjs observable that always holds a
current value (`.value`) and replays it on subscription. A consumer subscribes to
what it renders and reads `.value` where it needs the present state. Everything is
bound to an `ObservableScope` the host creates; ending the scope ends the client,
its connections and its tracks. Both come from the SDK (`Behavior`, `constant`,
`ObservableScope`); the host needs rxjs and nothing else.
## What a host must provide
Found the hard way while building the harness; each one fails in a way that looks
like an SDK bug.
- **The room the sync loop maintains.** `client.getRoom(roomId)` once the join has
synced. `joinRoom` returns a detached copy for a room joined just now, and a
client built on it never sees a member.
- **Power to send the membership event.** Every member needs to write
`org.matrix.msc3401.call.member` (and the sticky event in Matrix 2.0 mode). A
plain room only lets its creator send state; Element Call's rooms set the level
to 0.
- **An encrypted room for per-participant keys.** Media keys travel in encrypted
to-device messages, which the crypto only delivers to devices it tracks, which
are the members of encrypted rooms. In an unencrypted room the key never arrives
and every remote track decodes nothing.
- **A logged-in, syncing client with crypto set up**, and the transport advertised
by the homeserver (`rtc/transports`). The SDK has no fallback url of its own.
## The client: `MatrixRTCClient`
Rule of thumb for what belongs here: if it would be the same for a different
application on top of the same MatrixRTC session, it is in the client. If it is
specific to calls, or depends on the window, a setting, a platform, or a React
context, it is in the view model.
### Creating one
```ts
import { type MatrixClient, type Room } from "matrix-js-sdk";
import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc";
export interface MatrixRTCClientOptions {
encryptionSystem: EncryptionSystem;
/** Resolved by the host; the SDK reads neither config.json nor settings. */
matrixRTCMode: MatrixRTCMode;
/** Published at the join; `publish` on the local member adds to it from then on. */
publish: PublishRequest[];
/**
* MSC4075 notification sent with the join. A parameter of the MatrixRTC
* join itself, so it is here even though it is named after calls; reacting
* to a notification (ringing, timeouts, declines) is the application's job.
*/
sendNotificationType?: RTCNotificationType;
/** The application the session belongs to, as named in the membership. Default `m.call`. */
application?: string;
/** The application's slot in the room. Default `ROOM`. */
slot?: string;
/**
* Whatever the application wants to say about itself in the membership.
* Opaque to the SDK. Today the js-sdk carries one key, `m.call.intent`
* (`"audio"` or `"video"`); anything else is dropped until it can.
*/
applicationData?: Record<string, unknown>;
/** Session timings the host has configured; the defaults otherwise. */
timings?: Partial<SessionTimings>;
/** Limits on what is published; LiveKit's defaults otherwise. */
mediaQuality?: MediaQuality;
/** Use this transport instead of asking the homeserver. */
transport?: Transport;
/** Use this transport when the homeserver advertises none of the backend's type. */
fallbackTransport?: Transport;
}
/**
* One thing to publish: a source, where to capture it from and how to encode
* it. Where the device and the settings are left out, the browser's and
* LiveKit's defaults apply.
*/
export type PublishRequest =
| { source: "microphone"; deviceId?: string; capture?: AudioCaptureSettings }
| {
source: "camera";
deviceId?: string;
/** Background blur and the like. */
processor?: VideoProcessor;
capture?: VideoCaptureSettings;
}
| {
source: "screenShare";
/** Whether to capture the screen's audio too. Default true. */
audio?: boolean;
capture?: VideoCaptureSettings;
};
```
Device enumeration, permission prompts and the lobby preview stay with the
host: it hands over publish requests naming the devices it chose.
```ts
/**
* Takes the whole MatrixClient, not a narrow pick, and finds the MatrixRTC
* session itself. The js-sdk is the MatrixRTC implementation today; when the
* rust-rtc crate replaces it, the SDK has to bridge the crate to the js-sdk
* client (events, state, to-device, delayed events, OpenID), and only the SDK
* knows what that bridge needs. Holding the client keeps that change inside
* the SDK. The js-sdk session is not part of the public API.
*
* The room has to be the one the client's sync loop maintains; see "What a
* host must provide".
*/
export function createMatrixRTCClient(
scope: ObservableScope,
client: MatrixClient,
room: Room,
options: MatrixRTCClientOptions,
): MatrixRTCClient;
```
### The client
```ts
/** The homeserver link that is down, in the order they depend on each other. */
export type MatrixDisconnectReason = "sync" | "membership" | "probablyLeft";
/** Why the participation is not connected: the first failing link, in dependency order. */
export type DisconnectReason = MatrixDisconnectReason | "media";
export type ParticipationState =
| { kind: "waitingForTransport" }
| { kind: "connecting" }
| { kind: "connected" }
| { kind: "reconnecting"; reason: DisconnectReason }
/** A failed publication is not fatal: the member can still receive, so it stays on the local member's media. */
| { kind: "failed"; error: MatrixRTCError }
/** `leave()` was called. Terminal. */
| { kind: "left" };
/**
* An error raised by the client. `code` and `category` say what went wrong in
* a form a host can translate; `cause` holds the backend's error.
*/
export class MatrixRTCError extends Error {
code: ErrorCode;
category: ErrorCategory;
}
export interface MatrixRTCClient {
join(): void;
leave(): void;
/**
* The Matrix membership combined with the local media connection. One
* value: a reason exists only while `reconnecting`, an error only once
* `failed`.
*/
state$: Behavior<ParticipationState>;
/** Null until our own membership has been seen in the room. */
localMember$: Behavior<LocalRTCMember | null>;
remoteMembers$: Behavior<RemoteRTCMember[]>;
/** `remoteMembers.length`, plus one for the local member once it exists. One per membership, so one user may count several times. */
memberCount$: Behavior<number>;
/**
* Whether the session has grown large enough that MatrixRTC has stopped
* rotating the media encryption key.
*/
keyRotationSuppressed$: Behavior<boolean>;
/**
* Transports the client currently holds a live connection to. The
* connections themselves stay internal; this is what a developer panel
* needs.
*/
connectedTransports$: Behavior<TransportMetadata[]>;
/**
* Plays the members' audio on this device from now on. Rejects where the
* browser cannot switch to it. A host that routes audio itself, or leaves
* the browser's choice, never calls it.
*/
setAudioOutputDeviceId(deviceId: string): Promise<void>;
/**
* Sends a short text to every member on the local transport, over a
* reliable data channel beside the media. A packet holds about 15 KiB;
* anything larger belongs in a room event. Rejects while the local
* transport is not connected. Encrypted on the wire like the media, but
* not end to end with the media key.
*/
sendData(topic: string, text: string): Promise<void>;
/**
* What remote members sent with `sendData`, on every transport the client
* is connected to. A message from an identity that is not a member is
* dropped, so a host only ever hears from attested members.
*/
data$: Observable<DataMessage>;
}
/** One `sendData` call as it arrives at a remote member. */
export interface DataMessage {
member: RemoteRTCMember;
topic: string;
text: string;
}
```
`state$` is two state machines, one per box in the diagram, combined by one pure
function. `LocalMembership` knows only the Matrix side: `waitingForTransport`,
`joining`, `joined`, `reconnecting` with a `MatrixDisconnectReason`, `failed`,
`left`. The client folds the local media connection in: `joined` with the media
up is `connected`, a media drop after that is `reconnecting: "media"`, a
connection error is `failed`. Each step is computed in priority order from the
previous state, so `left` and `failed` absorb everything after them, and
`reconnecting` is only ever reached from `connected`; a membership that drops
before the media ever came up stays `connecting`. One reason, not a set, because
the links are a chain: without sync the membership is unknowable, and the media
is worth little while the Matrix side is down, so the first broken link in that
order is what a host shows and what Element Call's analytics count. A failed
publication is not in here at all: the member can still receive, so it stays on
the local member's media.
### Members
```ts
/**
* What a membership says, as hosts read it. The js-sdk `CallMembership` is
* behind it; anything beyond this is the js-sdk's API, not the SDK's.
*/
export type RTCMembership = Pick<
CallMembership,
| "userId"
| "deviceId"
| "memberId"
| "rtcBackendIdentity"
| "application"
| "applicationData"
| "getTransport"
| "transports"
| "createdTs"
| "getAbsoluteExpiry"
>;
export interface RTCMember {
local: boolean;
/** The membership's `rtcBackendIdentity`, `${userId}:${deviceId}`: a key and a debug label. */
rtcBackendIdentity: string;
/**
* Every identity the member may publish under on its transport, one per
* authentication flow the transport allows: the hashed identity the
* MSC4195 client-server API assigns, the legacy one the JWT service's
* `/sfu/get` assigns, or both for a transport carrying both a `url` and a
* `livekit_service_url`. The backend matches whichever shows up.
*/
backendIdentities: readonly string[];
userId: string;
deviceId: string;
membership$: Behavior<RTCMembership>;
/**
* Matrix room state rather than MatrixRTC, but the SDK already holds the
* room and every consumer wants them next to the membership. Disambiguated
* among the members of the session.
*/
displayName$: Behavior<string>;
avatarUrl$: Behavior<string | undefined>;
/** Which transport this member is on; undefined when the membership names none. */
transport$: Behavior<TransportMetadata | undefined>;
/**
* The member's tracks, in publication order, once it has shown up on its
* transport; null until then ("waiting for media"). An entry stays the same
* object for as long as the same publication is behind it. Which track is
* which is in its `source`; `trackBySource$` picks one out. Hand raise and
* reactions are room events keyed by member and stay with the host for now;
* they are the obvious next fields here.
*/
tracks$: Behavior<(AudioMediaTrack | VideoMediaTrack)[] | null>;
/** Emits when the SFU reports a key problem for this member. */
encryptionError$: Observable<EncryptionError>;
}
export interface RemoteRTCMember extends RTCMember {
local: false;
}
export interface LocalRTCMember extends RTCMember {
local: true;
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>;
/**
* Publishes a source and resolves with its track once it is in `tracks$`.
* Before the transport is connected the request is remembered and applied
* once it is. Rejects where the device could not be used, including the
* user closing the picker. One publication per source: publishing a source
* again unmutes it.
*/
publish(
request: PublishRequest,
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
/** Removes one of our tracks; a screen share takes its audio with it. */
unpublish(id: string): Promise<void>;
}
```
### Transports
```ts
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
/**
* One transport advertised in a membership. Transport independent: `type` and
* `id` are all the SDK needs; `raw` and `resolved$` are there for a
* backend-specific developer panel and for connection diagnostics. One object
* per transport, however many memberships name it.
*/
export interface TransportMetadata {
/** `"livekit"` today. */
type: string;
/** Stable key, unique per transport in the session: the raw transport, serialised with sorted keys. */
id: string;
/** The transport object as it appears in the membership, e.g. `{ type, livekit_service_url }`. */
raw: Transport;
/**
* What the backend had to fetch before it could connect. Undefined until
* the connection has resolved it, and again after the connection stops or
* fails. The local transport resolves before the join, since the membership
* needs it; remote ones when their connection starts.
*/
resolved$: Behavior<ResolvedTransport | undefined>;
}
export type ResolvedTransport =
| {
type: "livekit";
/** The SFU websocket url, as opposed to the JWT service url in `raw`. */
url: string;
/** A secret: fit for a developer panel, never for a log line. */
token: string;
roomAlias: string;
identity: string;
}
| { type: string; [key: string]: unknown };
```
### Call-specific things kept out of the client
Checked against `CallViewModel` and its options. These stay in Element Call because
they only make sense for a call:
| Today | Why it is call-specific | Where it lives instead |
| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `autoLeave$` / `AutoLeaveReason` (`"allOthersLeft" \| "timeout" \| "decline"`) | hanging up because the other side declined or everyone left is call etiquette, not session semantics | `CallViewModel.leave$`, built from `remoteMembers$` and the room timeline |
| `ringAttempts$`, `createCallNotificationLifecycle$`, `createSentCallNotification$`, `createReceivedDecline$` | ringing, pickup timeouts and decline events are MSC4075 call-notify UX | `src/state/CallViewModel/CallNotificationLifecycle.ts`, fed by `remoteMembers$` and the Matrix room |
| `waitForCallPickup`, `autoLeaveWhenOthersLeft` options | inputs to the above | `CallViewModelOptions` |
| `hideScreensharing` option | a UI policy; the session can always share a screen if the platform can | `CallViewModel` offers no screen share control |
| `ringingVm$`, `ringingStatusLocation` | view models | `CallViewModel` |
| `hostBridge.hangUp$`, `hangup()`, `userHangup$` | widget / host integration | `CallViewModel` |
| `ElementCallError` | carries Element Call translation keys | the view model wraps `MatrixRTCError`, reading `cause` |
| `participantCount$` sound limits, join/leave sounds | call UX | `CallViewModel` |
| `callIntent` | what a membership says about the call | `applicationData["m.call.intent"]`, set by the host |
Kept despite the name: `sendNotificationType`, because the js-sdk join sends the
notification as part of entering the session. Only _reacting_ to it is
call-specific.
## Media backend: `MediaBackend`
The client speaks MatrixRTC: memberships, the transports named in them, the
delayed leave, the keys. What carries the media is a backend behind one
interface, `MediaBackend` in `sdk/src/media-backend/api.ts`, created once per client.
LiveKit under `sdk/src/media-backend/livekit/` is the only backend today, and the only
place in the SDK that imports `livekit-client`; the import boundary lint rule
enforces that outside `sdk/src/media-backend/` the LiveKit packages appear in type
imports only.
The seam sits above the connection manager on purpose. One connection per
transport url, with a subscribe-only connection to every remote SFU, is the
LiveKit multi-SFU strategy, not a MatrixRTC concept: a cascading SFU keeps the
membership transport as it is and only changes which SFUs the client connects to,
and full mesh has one peer connection per member and no token service. So a
backend is handed the whole session's view and gives back media:
```ts
/** The fields of a member the backend supplies, as the member exposes them. */
type MemberMediaFields = Pick<RTCMember, "tracks$" | "encryptionError$">;
export interface MediaBackend {
readonly transportType: string; // "livekit"
/** Authenticate with the transport the client found; the probe for delegation runs here. */
prepareLocalTransport(
transport: Transport,
): Promise<{ canDelegateDelayedLeave: boolean }>;
/** Hand over the delayed leave, each time the membership manager has a new delay id. */
delegateDelayedLeave(delayId: string): Promise<void>;
readonly local: LocalMediaBackend; // tracks$ encryptionError$ connectionState$ setPublishing publishError$ publish unpublish
/** A remote member's media; also how the backend learns which transports to connect to. */
mediaFor$(
scope: ObservableScope,
membership$: Behavior<RTCMembership>,
): MemberMediaFields;
readonly connections$: Behavior<BackendConnection[]>; // transport, state, resolved; diagnostics
sendData(topic: string, text: string): Promise<void>;
readonly data$: Observable<DataPacket>;
}
```
What stays on the client's side: discovery of the local transport from the
homeserver's list (a homeserver endpoint, so not a backend's), the membership
state machine in `LocalMembership.ts` and its combination with
`local.connectionState$` into `state$`, the choice of delayed leave timings,
mapping a data packet's sender to a member, and the transport registry, which keys on the raw
transport serialised with sorted keys and derives `connectedTransports$` and each
`TransportMetadata.resolved$` from `connections$`.
The backend's context carries no Matrix client and no membership list. A backend
that needs the client takes a `Pick` of exactly what it calls in its own options
(`createLivekitBackend(scope, context, { client, mediaQuality })`),
and learns of remote members only through `mediaFor$`, registering each
membership's transport for as long as the member's scope lives. Media keys reach
it as a stream, `mediaKeys$`, replayed on subscribe; the js-sdk session never
does. A host supplies its own backend through `MatrixRTCClientOptions.backend`;
without one, LiveKit is built from the LiveKit-flavoured client options.
### Authenticating with a LiveKit transport
A LiveKit transport comes in three shapes while MSC4195 settles: an SFU `url`,
a JWT service `livekit_service_url`, or both. `media-backend/livekit/auth/`
fronts the two flows and chooses between them per connection, from the shape
and from the role we take on the transport:
| Transport | Publisher | Subscriber |
| -------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `url` only | client-server API `get_token`, no fallback | client-server API `get_token`, no fallback |
| `livekit_service_url` only | JWT service `/sfu/get` (legacy identity) | JWT service `/get_token` (hashed identity) |
| both | JWT service `/sfu/get` (legacy identity) | client-server API, falling back to the JWT service `/get_token` where the homeserver, or a widget host, has no MSC4195 |
A publisher on a transport with a service URL keeps the legacy
`<user_id>:<device_id>` identity, because clients predating MSC4195 match a
membership against that and nothing else; a subscriber is matched by nobody and
takes the more private hashed identity. A publisher never falls back: a
homeserver that advertises a `url` without implementing MSC4195 is misconfigured
and fails with `NoMatrix2AuthorizationService`, so the admin hears about it.
The MatrixRTC mode plays no part in the choice. The delayed leave is delegated
through the same flow that issued the publisher's token, so that the identity
the service watches for on the SFU is the one we publish under, and the
delegation probe before the join asks only that service.
The client-server API needs the name of the homeserver the transport belongs to:
our own homeserver asks that server for the token, over federation where it is
not ours. The backend derives it from the member's user id, and from our own for
the local transport, and keys its connections on the transport plus the server
name where the transport has a `url`. A JWT service is reached directly, so
members of several homeservers advertising the same one share a connection.
## Media: `tracks$` and `MediaTrack`
Every member has media. Today, in Element Call, that media _is_ a `livekit-client`
participant, handed straight through to the view models. In the SDK the participant
becomes the member's `tracks$`, a list of `MediaTrack`s, so that nothing outside
the SDK imports `Participant`, `TrackPublication`, `Track` or `TrackReference`.
The adapter in `sdk/src/media-backend/livekit/` is the only place that reads a
participant. The backend supplies the same two fields through `mediaFor$`, so
there is one shape from the LiveKit participant to the host and nobody has to
subscribe through one Behavior to reach another.
```ts
export type MediaSource =
| "microphone"
| "camera"
| "screenShare"
| "screenShareAudio"
/** Published without a source; the application knows what it is. */
| "unknown";
export type MediaStreamStats =
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined;
/** One published track of a member. */
export interface MediaTrack {
source: MediaSource;
kind: "audio" | "video";
/** Stable for the life of the track. */
id: string;
muted$: Behavior<boolean>;
/** False when the SFU reports the track as unencrypted. Fixed for the life of the track. */
encrypted: boolean;
/**
* Inbound statistics for a remote track, outbound for a local one, polled
* every `intervalMs` while the observable is subscribed and not at all
* otherwise, so a statistics panel costs nothing while closed.
*/
stats$(intervalMs: number): Observable<MediaStreamStats>;
/**
* Rendering. The view hands its <video> or <audio> element over; the SDK
* sets its stream and, for video, registers the size and on-screen
* observers that pick a simulcast layer and pause the subscription while
* the element is hidden. attach is idempotent per element; detach has to be
* called before the element leaves the DOM so those observers are released.
*/
attach(element: HTMLMediaElement): void;
detach(element: HTMLMediaElement): void;
}
export interface AudioMediaTrack extends MediaTrack {
kind: "audio";
/**
* Whether the track carries sound right now, as the backend measures it.
* False while muted. A call reads this on the microphone track and calls it
* "speaking". LiveKit measures it per member, so every audio track of a
* member reports the same value.
*/
isActive$: Behavior<boolean>;
/**
* How loud the track is right now, 0 to 1, as the backend measures it. 0
* while muted. Not what decides `isActive$`: the backend applies its own
* threshold and hysteresis to that.
*/
audioLevel$: Behavior<number>;
/** Route playback through Web Audio, for earpiece pan and gain. Undefined resets. */
setAudioContext(ctx: AudioContext | undefined, plugins?: AudioNode[]): void;
/** No-op for our own audio, which is never played back. */
setVolume(volume: number): void;
}
export interface VideoMediaTrack extends MediaTrack {
kind: "video";
}
/** The controls a member has over a track it publishes itself. */
export interface LocalMediaTrack {
/** Mutes or unmutes. Resolves with the state that resulted. */
setEnabled(enabled: boolean): Promise<boolean>;
/** Captures from another device. Rejects for a screen share, which has none. */
setDevice(deviceId: string): Promise<void>;
}
export interface LocalAudioMediaTrack
extends AudioMediaTrack, LocalMediaTrack {}
export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
/** For mirroring; undefined where the camera does not say which way it faces. */
facingMode$: Behavior<"user" | "environment" | undefined>;
/** Restarts the camera facing the other way; resolves with the device now in use. */
switchFacingMode(): Promise<string | undefined>;
/** Background blur and the like; undefined removes the processor. */
setProcessor(processor: VideoProcessor | undefined): Promise<void>;
}
export type EncryptionError = "MissingKey" | "InvalidKey";
/** The member's first track of a source; undefined while there is none, or no media yet. */
export function trackBySource$<S extends MediaSource>(
scope: ObservableScope,
tracks$: Behavior<MediaTrack[] | null>,
source: S,
): Behavior<TrackOfSource<S> | undefined>;
```
### Rendering: `attach` and `detach`
The view passes its DOM element into the SDK and the SDK does the rest; there is no
`MediaStreamTrack` on the interface. The reason is adaptive stream: Element Call
runs with `adaptiveStream: true`, which means LiveKit decides the simulcast layer
from the size of the attached video element and pauses the subscription at the
SFU while no attached element is on screen. Those size and visibility observers are
set up inside LiveKit's `attach`. Handing the element over is the only way to keep
that without reimplementing the observers outside the SDK. The e2e tests check both
effects: a larger tile receives a higher resolution, a hidden tile stops receiving
frames.
What `attach` covers for free, so the host does not have to: `srcObject` and
`autoplay` setup with the Safari and Firefox black-video workarounds; `play()` and
the autoplay-blocked signal; following the track through mute, reconnect and
re-subscription into every attached element; the Web Audio graph for earpiece pan
and gain; the adaptive stream observers.
The interface is web-only. A non-DOM host would need a different media surface; if
one shows up, a `MediaStreamTrack` accessor is added next to `attach`, not instead
of it.
## Packaging
`sdk/` is packaged exactly as `component/` is: a `package.json` with peer
dependencies (`matrix-js-sdk`, `livekit-client`, `rxjs`), `exports` with types,
type emission through `sdk/tsconfig.build.json`, an externals list in
`vite-sdk.config.ts` that `pnpm lint:externals` enforces, and installation from the
repository as a git dependency with a `path:` suffix. `pnpm build:sdk` produces
`sdk/dist`. It is not a pnpm workspace package yet; the root is not a workspace, and
moving to one later carries the package name, entry point and externals over
unchanged.
Element Call consumes the SDK by its package name from day one, so the swap to a
published dependency is one line: `tsconfig.json` `paths` and the `resolve.alias`
in `vitePluginsConfig` both map `@element-hq/matrixrtc-sdk` to `sdk/index.ts`. The
alias has to live in `vitePluginsConfig`, which every config is built on. Put only
into the app's own config, the harness resolves the name through `sdk/package.json`
to whatever stale `sdk/dist` is lying around.
Layout:
```
sdk/
index.ts the entry point: re-exports the API and the primitives
src/api.ts the public types above: client, members, transports
src/media-api.ts the public media types: publish requests, tracks
src/errors.ts MatrixRTCError and its codes
src/config.ts MatrixRTCMode, session timings, media quality
src/encryption.ts E2eeType, EncryptionSystem
src/MatrixRTCClient.ts createMatrixRTCClient: wires the two folders below
src/participationState.ts the membership state combined with the media connection
src/reactive/ Behavior, ObservableScope, the observable operators
src/matrixrtc/ the MatrixRTC side: the local membership, memberships, member
metadata, transports, discovery, the js-sdk join
src/media-backend/ the MediaBackend interface in api.ts; livekit/ is the one
backend: connections, publisher, JWT, keys, the tracks
src/utils/ mapScoped, network retry, display names, test helpers
dev/ the harness (below)
SdkArchitecture.md this document
SdkMigration.md what moved from Element Call's src/ into the SDK
```
The lint rule `element-call/sdk-import-boundary` keeps it that way: nothing under
`sdk/` may import a file outside `sdk/` (that is `src/`, `component/`,
`playwright/`) or React, i18n or Compound. The harness under `sdk/dev/` is a host
and exempt.
## Dev harness: `sdk/dev/`
A web app that uses the SDK and nothing else: plain DOM, no React, no Compound, no
i18n, imports only `@element-hq/matrixrtc-sdk`, `matrix-js-sdk` and `rxjs`. It is
where the SDK is run during development and what the e2e tests in `playwright/sdk`
drive. If something cannot be done in the harness without reaching into `src/`, the
SDK API is missing something.
What it does: log in with a password, or with an access token the URL hands it (the
tests register users through the admin API and skip `/login`, which the dev
homeserver rate-limits); join the room and wait for the sync to hold it; create the
client with microphone and camera enabled; show one tile per member with the display
name, a `<video>` attached from the member's `"camera"` track and, for remote
members, an `<audio>` from the `"microphone"` track, both picked out of
`tracks$` with `trackBySource$`; toggle the microphone and camera; leave.
Every media element carries the track's state as `data-*` attributes (`muted`,
`encrypted`, `active`, `frameWidth`, `frames`), which is what the tests read to
check mute propagation, encryption, adaptive resolution and paused subscriptions
(the speaker detection check is a `fixme`: the fake microphones do not reliably
register at the SFU). That is more
than the smallest consumer needs, and the price of testing the SDK through its
public API only.
## Open decisions
- **Publishing before `join()`.** The initial requests are published at the
join; creating the tracks earlier, for a lobby preview, is still missing.
- **`applicationData` after the join.** The option is read once; there is no
way to update it on a live membership yet.
- **Raw membership state.** The Matrix side's own state machine
(`MembershipState` in `LocalMembership.ts`) stays internal; `state$` on the
participation is its public view combined with the media connection. A
developer panel may want it as `LocalRTCMember.membershipState$`.
- **Developer panel.** `resolved$` on each connected transport covers what the
panel shows today. Anything beyond that (LiveKit room state, connection
quality) needs an opaque `debug$` per transport.
- **Data beside the media.** `sendData`/`data$` ride on LiveKit's reliable data
packets: topic-agnostic, matched to members by identity, received on every
connection but sent only on the local transport, so a member on another
transport does not hear us. They are transport-encrypted, not end to end:
LiveKit can encrypt data packets with the media key, but that is not wired
through the key provider yet. A host that needs either reach or end-to-end
encryption uses room events.
- **Unencrypted rooms with per-participant keys.** Keys only reach devices the
crypto tracks, which are the members of encrypted rooms. If the SDK is to work
in an unencrypted room, tracking the members' devices becomes its job.
+53
View File
@@ -0,0 +1,53 @@
# What moved from `src/` into the SDK
Element Call's `CallViewModel` used to do everything; now it consumes the SDK and
keeps only what makes a session a call. This is the inventory of what moved out
of `src/` on the way, so that a reader of either side knows where to look. It
names source files, so it ages with the code; the shape they make is in
[`SdkArchitecture.md`](./SdkArchitecture.md).
Everything in the table is gone from `src/`. Each module came with its tests;
`sdk/src/utils/test.ts` and `test-fixtures.ts` are the subset of Element Call's
test helpers they need. `Behavior`, `ObservableScope`, the observable operators,
`E2eeType`, `EncryptionSystem` and `MatrixRTCMode` are re-exported from the SDK
by their old `src/` paths, so the rest of Element Call did not have to change.
| Was in `src/` | Is in `sdk/src/` | What changed on the way |
| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `state/Behavior.ts`, `state/ObservableScope.ts`, `utils/observable.ts` | `reactive/` | as is |
| `state/SessionBehaviors.ts` | `matrixrtc/JsRtcSessionBehaviors.ts` | the memberships and key rotation behaviors only |
| `state/CallViewModel/localMember/HomeserverConnected.ts` | `matrixrtc/HomeserverConnected.ts` | the grace period defaults to `defaultSessionTimings` instead of `Config.get()` |
| `state/CallViewModel/localMember/LocalMember.ts` | `matrixrtc/LocalMembership.ts`, `media-backend/livekit/LocalMedia.ts`, `participationState.ts` | split into the Matrix side, the media side and the state machine that combines them |
| `state/CallViewModel/localMember/LocalTransport.ts`, `RtcTransportAutoDiscovery.ts` | `matrixrtc/LocalTransport.ts`, `TransportAutoDiscovery.ts` | the override and the fallback are transports from the options, not a setting and a resolved config |
| `state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts` | `media-backend/livekit/LivekitBackend.ts` (`mediaFor`) | matches participants to members inside the backend; the SDK hands out `RTCMember`s |
| `state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts`, `utils/displayname.ts` | `matrixrtc/MatrixMemberMetadata.ts`, `utils/displayname.ts` | as is |
| `state/CallViewModel/remoteMembers/Connection.ts` | `media-backend/livekit/Connection.ts` | throws `MatrixRTCError`s; `ConnectionState` is a discriminated union; fetches its own token for its role |
| `state/CallViewModel/remoteMembers/ConnectionFactory.ts` | `media-backend/livekit/ConnectionFactory.ts` | `LivekitConnectionFactory` builds the room options from the publish requests and `mediaQuality`, not from settings |
| `state/CallViewModel/remoteMembers/ConnectionManager.ts` | `media-backend/livekit/ConnectionMap.ts` | keyed by `TransportLocator`; the publisher role is the local transport's |
| `state/CallViewModel/localMember/Publisher.ts` | `media-backend/livekit/Publisher.ts` | rewritten on publish requests instead of `MuteStates` |
| `livekit/openIDSFU.ts`, later `livekit/auth/` | `media-backend/livekit/auth/` | the flow is chosen by the transport's shape and our role; the delegated leave timeout is a parameter |
| `livekit/options.ts` | `media-backend/livekit/livekitOptions.ts` | `buildLiveKitOptions(mediaQuality?)` only; the `Config` singleton is gone |
| `e2ee/matrixKeyProvider.ts`, the key provider selection | `media-backend/livekit/MatrixKeyProvider.ts`, `KeyProvider.ts` | as is; selection keyed off `encryptionSystem` |
| `e2ee/e2eeType.ts`, `EncryptionSystem` from `e2ee/sharedKeyManagement.ts` | `encryption.ts` | the React hooks stayed behind |
| `MatrixRTCMode`, the timings and media quality types from `config/ConfigOptions.ts` | `config.ts` | `defaultSessionTimings` and `defaultMediaQuality` replace `DEFAULT_CONFIG` |
| `utils/errors.ts` | `errors.ts` | `MatrixRTCError` with `code` and `category` replaces `ElementCallError`; messages are plain English, the host translates by code |
| `doNetworkOperationWithRetry` from `utils/matrix.ts` | `utils/network.ts` | takes a `shouldRetry` predicate |
| the join in `CallViewModel.ts` (`enterRTCSession`) | `matrixrtc/joinJsSdkSession.ts` | the timings and the delayed leave variant are parameters |
| `livekit/MatrixAudioRenderer.tsx`, the participant reads in `state/media/` | `media-backend/livekit/LivekitMediaTrack.ts`, `LivekitMemberMedia.ts` | the view models get `MediaTrack`s with `attach`; nothing in `src/` imports a LiveKit participant any more |
## What still binds Element Call to LiveKit
Each is a gap in the SDK's API rather than something to rename; the import goes
away when the SDK grows the piece. The names Element Call owes to the outside
stay as they are: the `livekit.livekit_service_url` key in `config.json`, the
persisted setting keys `extended-livekit-logs` and `custom-transport`, the
Posthog fields `callBackend` and `callReconnectingCountLivekit`, the rageshake
field `call_backend`, and the translated error strings.
| Gap | Where it shows |
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Preview before `join()`: local tracks for the lobby, handed out as `MediaTrack`s | `LobbyView` (`usePreviewTracks`), `VideoPreview` (`LocalVideoTrack`, `facingModeFromLocalTrack`) |
| Device enumeration without LiveKit's helpers | `MediaDevices` (`createMediaDeviceObserver`), `CallView` (`Room.getLocalDevices`) |
| A `logging` option: log level and a sink for the transport's logs | `initializer.tsx`, the `extended-livekit-logs` setting |
| A `capabilities` field: output selection and background processors, asked of the SDK rather than of LiveKit | `CallFooterViewModel` (`supportsAudioOutputSelection`, `supportsBackgroundProcessors`) |
| Element Call's error and analytics types on the SDK's | `PosthogEvents` (`DisconnectReason`), `errors.ts` (`ConnectionError`, `LivekitConnectionError`) |
+78
View File
@@ -0,0 +1,78 @@
<!doctype html>
<!--
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.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MatrixRTC SDK harness</title>
<style>
body {
margin: 1rem;
font-family: system-ui, sans-serif;
}
form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
#members {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
#members section {
width: 240px;
}
#members video {
width: 100%;
aspect-ratio: 4 / 3;
background: #222;
}
#members h2 {
font-size: 1rem;
margin: 0.25rem 0;
}
</style>
</head>
<body>
<form>
<input
name="homeserver"
placeholder="homeserver"
value="https://synapse.m.localhost"
/>
<input name="username" placeholder="username" />
<input name="password" type="password" placeholder="password" />
<input name="room" placeholder="room id or alias" />
<!-- A host that already holds a session passes it instead of a password -->
<input name="accessToken" type="hidden" />
<input name="userId" type="hidden" />
<input name="deviceId" type="hidden" />
<button type="submit">Start</button>
<button type="button" id="microphone" aria-pressed="true" hidden>
Microphone
</button>
<button type="button" id="camera" aria-pressed="true" hidden>
Camera
</button>
<button type="button" id="screenShare" aria-pressed="false" hidden>
Share screen
</button>
<button type="button" id="leave" hidden>Leave</button>
</form>
<p id="status" data-testid="status"></p>
<!-- A line of text to the other members, beside the media -->
<form id="data" hidden>
<input name="text" placeholder="message" />
<button type="submit">Send</button>
</form>
<ul id="messages"></ul>
<main id="members"></main>
<script type="module" src="./main.ts"></script>
</body>
</html>
+289
View File
@@ -0,0 +1,289 @@
/*
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 smallest consumer of the SDK: log in, join a room, show every member's
* camera and play every remote member's microphone, mute, leave. What a host
* writes, and nothing a host would not.
*/
import { logger } from "matrix-js-sdk/lib/logger";
import { combineLatest, type Observable, of, switchMap } from "rxjs";
import {
type AudioMediaTrack,
createRTCSlot,
E2eeType,
type LocalRTCMember,
MatrixRTCMode,
ObservableScope,
type RemoteRTCMember,
type RTCParticipation,
trackBySource$,
type VideoMediaTrack,
} from "@element-hq/matrixrtc-sdk";
import { createSession, joinRoom, type Login } from "./session";
const form = document.querySelector("form")!;
const status = document.getElementById("status")!;
const members = document.getElementById("members")!;
const dataForm = document.getElementById("data") as HTMLFormElement;
const messages = document.getElementById("messages")!;
const buttons = {
microphone: document.getElementById("microphone") as HTMLButtonElement,
camera: document.getElementById("camera") as HTMLButtonElement,
screenShare: document.getElementById("screenShare") as HTMLButtonElement,
leave: document.getElementById("leave") as HTMLButtonElement,
};
// So that a test, or a bookmark, can fill the form from the URL
const params = new URLSearchParams(location.search);
for (const input of form.querySelectorAll("input"))
input.value = params.get(input.name) ?? input.value;
form.addEventListener("submit", (event) => {
event.preventDefault();
const fields = new FormData(form);
const field = (name: string): string => fields.get(name) as string;
const login: Login = field("accessToken")
? {
accessToken: field("accessToken"),
userId: field("userId"),
deviceId: field("deviceId"),
}
: { username: field("username"), password: field("password") };
void start(field("homeserver"), login, field("room"));
});
async function start(
homeserver: string,
login: Login,
roomIdOrAlias: string,
): Promise<void> {
try {
status.textContent = "Logging in";
const client = await createSession(homeserver, login);
const room = await joinRoom(client, roomIdOrAlias);
const scope = new ObservableScope();
const slot = createRTCSlot(scope, client, room, {
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
matrixRTCMode: MatrixRTCMode.Compatibility,
});
const rtcParticipation = slot.join({
publish: [{ source: "microphone" }, { source: "camera" }],
});
rtcParticipation.state$.pipe(scope.bind()).subscribe((state) => {
status.textContent =
state.kind === "failed" ? `Error: ${state.error.message}` : state.kind;
});
showMembers(scope, rtcParticipation);
showMessages(scope, rtcParticipation);
toggle(buttons.microphone, async (enabled) =>
setPublished(rtcParticipation, "microphone", enabled),
);
toggle(buttons.camera, async (enabled) =>
setPublished(rtcParticipation, "camera", enabled),
);
toggle(buttons.screenShare, async (enabled) =>
setPublished(rtcParticipation, "screenShare", enabled),
);
buttons.leave.hidden = false;
buttons.leave.onclick = (): void => {
rtcParticipation.leave();
scope.end();
members.replaceChildren();
messages.replaceChildren();
dataForm.hidden = true;
for (const button of Object.values(buttons)) button.hidden = true;
status.textContent = "Left";
};
} catch (e) {
status.textContent = `Error: ${e}`;
logger.error(e);
}
}
/**
* Turns one of our sources on or off: a microphone or camera is muted and
* unmuted while it exists and published when it does not; a screen share is
* published and unpublished.
*/
async function setPublished(
rtcParticipation: RTCParticipation,
source: "microphone" | "camera" | "screenShare",
enabled: boolean,
): Promise<boolean> {
const track = rtcParticipation.localMember$.value?.tracks$.value?.find(
(t) => t.source === source,
);
if (track && source === "screenShare") {
if (!enabled) await rtcParticipation.unpublish(source);
return enabled;
}
if (track) return track.setEnabled(enabled);
if (!enabled) return false;
await rtcParticipation.publish({ source });
return true;
}
/** A pressed button means the source is on; the SDK says what it could do. */
function toggle(
button: HTMLButtonElement,
set: (enabled: boolean) => Promise<boolean>,
): void {
button.hidden = false;
button.onclick = (): void => {
void set(button.ariaPressed !== "true").then((enabled) => {
button.ariaPressed = String(enabled);
});
};
}
/** Sends what the form holds on the "chat" topic, and lists what arrives. */
function showMessages(
scope: ObservableScope,
rtcParticipation: RTCParticipation,
): void {
dataForm.hidden = false;
dataForm.onsubmit = (event): void => {
event.preventDefault();
const text = new FormData(dataForm).get("text") as string;
rtcParticipation.sendData("chat", text).then(
() => dataForm.reset(),
(e: unknown) => {
status.textContent = `Error: ${e}`;
},
);
};
rtcParticipation.data$
.pipe(scope.bind())
.subscribe(({ member, topic, text }) => {
const line = messages.appendChild(document.createElement("li"));
line.dataset.testid = "message";
line.dataset.topic = topic;
line.dataset.userId = member.userId;
line.textContent = `${member.displayName$.value}: ${text}`;
});
}
function showMembers(
scope: ObservableScope,
rtcParticipation: RTCParticipation,
): void {
const tiles = new Map<string, HTMLElement>();
combineLatest([
rtcParticipation.localMember$,
rtcParticipation.remoteMembers$,
])
.pipe(scope.bind())
.subscribe(([local, remote]) => {
const current = local === null ? remote : [local, ...remote];
for (const [id, tile] of tiles)
if (!current.some((m) => m.memberId === id)) {
tile.remove();
tiles.delete(id);
}
for (const member of current)
if (!tiles.has(member.memberId)) {
const tile = memberTile(scope, member);
tiles.set(member.memberId, tile);
members.append(tile);
}
});
}
function memberTile(
scope: ObservableScope,
member: LocalRTCMember | RemoteRTCMember,
): HTMLElement {
const tile = document.createElement("section");
tile.dataset.testid = "member";
tile.dataset.userId = member.userId;
tile.dataset.local = String(member.local);
const name = tile.appendChild(document.createElement("h2"));
member.displayName$.pipe(scope.bind()).subscribe((n) => {
name.textContent = n;
});
render(
scope,
trackBySource$(scope, member.tracks$, "camera"),
tile.appendChild(document.createElement("video")),
);
// Our own microphone would only echo
if (!member.local)
render(
scope,
trackBySource$(scope, member.tracks$, "microphone"),
tile.appendChild(document.createElement("audio")),
);
return tile;
}
/**
* Plays a track on an element and labels the element with the track's state,
* so that the page shows what the member is sending and how well it arrives.
*/
function render(
scope: ObservableScope,
track$: Observable<AudioMediaTrack | VideoMediaTrack | undefined>,
element: HTMLMediaElement,
): void {
let attached: AudioMediaTrack | VideoMediaTrack | undefined;
track$.pipe(scope.bind()).subscribe((track) => {
attached?.detach(element);
attached = track;
track?.attach(element);
});
scope.onEnd(() => attached?.detach(element));
const label = (
key: string,
value$: Observable<string | number | boolean | undefined>,
): void => {
value$.pipe(scope.bind()).subscribe((value) => {
if (value === undefined) delete element.dataset[key];
else element.dataset[key] = String(value);
});
};
const of$ = <T>(
pick: (track: AudioMediaTrack | VideoMediaTrack) => Observable<T>,
): Observable<T | undefined> =>
track$.pipe(switchMap((track) => (track ? pick(track) : of(undefined))));
label(
"muted",
of$((t) => t.muted$),
);
label(
"encrypted",
of$((t) => of(t.encrypted)),
);
label(
"active",
of$((t) => (t.kind === "audio" ? t.isActive$ : of(undefined))),
);
const stats$ = of$((t) => t.stats$(1000));
label("frameWidth", stats$.pipe(switchMap((s) => of(frames(s)?.frameWidth))));
label("frames", stats$.pipe(switchMap((s) => of(frames(s)?.count))));
}
/** The frame counters an RTP stream reports, from either end of it. */
function frames(
stats: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined,
): { frameWidth: number | undefined; count: number | undefined } | undefined {
if (stats === undefined) return undefined;
return {
frameWidth: stats.frameWidth,
count:
stats.type === "inbound-rtp"
? (stats as RTCInboundRtpStreamStats).framesDecoded
: (stats as RTCOutboundRtpStreamStats).framesEncoded,
};
}
+99
View File
@@ -0,0 +1,99 @@
/*
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 {
ClientEvent,
createClient,
type MatrixClient,
MemoryStore,
type Room,
SyncState,
} from "matrix-js-sdk";
import { KnownMembership } from "matrix-js-sdk/lib/types";
export type Login =
| { username: string; password: string }
| { accessToken: string; userId: string; deviceId: string };
/**
* Returns a client that has finished its first sync, logging in with a
* password unless the caller already holds a token.
*/
export async function createSession(
homeserver: string,
login: Login,
): Promise<MatrixClient> {
const credentials =
"accessToken" in login ? login : await logIn(homeserver, login);
const client = createClient({
baseUrl: homeserver,
...credentials,
store: new MemoryStore(),
useAuthorizationHeader: true,
fallbackICEServerAllowed: true,
});
await client.initRustCrypto({ useIndexedDB: false });
await client.startClient();
await new Promise<void>((resolve) => {
const onSync = (state: SyncState): void => {
if (state !== SyncState.Prepared && state !== SyncState.Syncing) return;
client.off(ClientEvent.Sync, onSync);
resolve();
};
client.on(ClientEvent.Sync, onSync);
});
return client;
}
/**
* Joins a room and returns it as the sync loop maintains it. The room object
* `joinRoom` itself returns for a room joined just now is a detached copy
* that never receives the state the sync delivers, and a session built on
* it would never see a member.
*/
export async function joinRoom(
client: MatrixClient,
roomIdOrAlias: string,
): Promise<Room> {
const { roomId } = await client.joinRoom(roomIdOrAlias);
const joinedRoom = (): Room | undefined => {
const room = client.getRoom(roomId);
return room?.hasMembershipState(client.getUserId()!, KnownMembership.Join)
? room
: undefined;
};
return (
joinedRoom() ??
new Promise<Room>((resolve) => {
const onSync = (): void => {
const room = joinedRoom();
if (room === undefined) return;
client.off(ClientEvent.Sync, onSync);
resolve(room);
};
client.on(ClientEvent.Sync, onSync);
})
);
}
async function logIn(
homeserver: string,
{ username, password }: { username: string; password: string },
): Promise<{ accessToken: string; userId: string; deviceId: string }> {
const login = await createClient({ baseUrl: homeserver }).login(
"m.login.password",
{ identifier: { type: "m.id.user", user: username }, password },
);
return {
accessToken: login.access_token,
userId: login.user_id,
deviceId: login.device_id,
};
}
+70
View File
@@ -0,0 +1,70 @@
/*
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.
*/
/**
* EXPERIMENTAL
*
* MatrixRTC sessions with LiveKit media, without Element Call's UI: the call
* model Element Call's own view model is built on, for hosts that want to
* build a different one. The design is in `SdkArchitecture.md` next to this
* file.
*/
export { type Behavior, constant } from "./src/reactive/Behavior";
export {
Epoch,
mapEpoch,
ObservableScope,
trackEpoch,
} from "./src/reactive/ObservableScope";
export {
filterBehavior,
finalizeValue,
generateItems,
generateItemsWithEpoch,
pauseWhen,
} from "./src/reactive/observable";
export { E2eeType, type EncryptionSystem } from "./src/encryption";
export {
defaultMediaQuality,
defaultSessionTimings,
type DelayedLeaveTimings,
MatrixRTCMode,
type MediaQuality,
type SessionTimings,
} from "./src/config";
export {
ErrorCategory,
ErrorCode,
FailToGetOpenIdToken,
FailToStartLivekitConnection,
InsufficientCapacityError,
LivekitConnectionError,
MatrixRTCTransportMissingError,
MembershipManagerError,
NoMatrix2AuthorizationService,
PeerConnectionTimeoutError,
SFURoomCreationRestrictedError,
UnknownRTCError,
} from "./src/errors";
export * from "./src/api";
export * from "./src/media-api";
export * from "./src/media-backend/api";
export { trackBySource$, type TrackOfSource } from "./src/utils/tracks";
export { createRTCSlot } from "./src/RTCSlot";
export {
createLivekitBackend,
type LivekitBackendOptions,
} from "./src/media-backend/livekit/LivekitBackend";
// 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
export {
getSFUConfig as authenticateWithTransport,
type GetSFUConfigParams as AuthenticateWithTransportParams,
} from "./src/media-backend/livekit/auth";
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@element-hq/matrixrtc-sdk",
"version": "0.0.0",
"description": "MatrixRTC sessions with LiveKit media, without Element Call's UI. Consumed straight from the repository as a git dependency (github:element-hq/element-call#<ref>&path:/sdk): the host's package manager runs `prepare`, which builds `dist/`.",
"license": "SEE LICENSE IN ../README.md",
"repository": {
"type": "git",
"url": "https://github.com/element-hq/element-call",
"directory": "sdk"
},
"type": "module",
"devEngines": {
"packageManager": {
"name": "pnpm"
}
},
"files": [
"dist"
],
"main": "./dist/matrixrtc-sdk.js",
"module": "./dist/matrixrtc-sdk.js",
"types": "./dist/types/sdk/index.d.ts",
"exports": {
".": {
"types": "./dist/types/sdk/index.d.ts",
"default": "./dist/matrixrtc-sdk.js"
}
},
"scripts": {
"prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:sdk"
},
"peerDependencies": {
"livekit-client": "^2.18.1",
"matrix-js-sdk": "*",
"rxjs": "^7.8.1"
}
}
+14
View File
@@ -0,0 +1,14 @@
# Makes `sdk/` a pnpm project of its own rather than a directory inside the
# repository's workspace. That matters when a host installs the SDK straight
# from this repository (`github:element-hq/element-call#…&path:/sdk`): pnpm
# prepares such a dependency by running `pnpm install` in this directory, and
# only a project root gets its `prepare` script (see package.json) run, which is
# what builds `dist/`. Inside the repository's own workspace the install would
# silently target the repository instead and build nothing.
#
# Consequence for development: pnpm commands run from within this directory see
# this project, not the repository; run them from the repository root.
# Nothing to install here: the peers in package.json are the host's, and the
# build runs against the repository's own node_modules (see `prepare`).
autoInstallPeers: false
+397
View File
@@ -0,0 +1,397 @@
/*
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 MatrixClient, type Room, SyncState } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import {
MatrixRTCSessionEvent,
MembershipManagerEvent,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, EMPTY, Subject } from "rxjs";
import { describe, expect, it, type Mock, vi } from "vitest";
import {
type DataMessage,
type ParticipationState,
type RTCMember,
type RTCParticipationOptions,
type RTCSlot,
type TransportMetadata,
} from "./api";
import { defaultSessionTimings, MatrixRTCMode } from "./config";
import { E2eeType } from "./encryption";
import { MatrixRTCError } from "./errors";
import {
type DataPacket,
type MediaBackend,
type MediaBackendContext,
MediaConnectionState,
type MediaKey,
} from "./media-backend/api";
import { type MemberMedia } from "./media-api";
import { constant } from "./reactive/Behavior";
import { ObservableScope } from "./reactive/ObservableScope";
import { createRTCParticipation } from "./RTCParticipation";
import { transportId } from "./matrixrtc/Transports";
import { flushPromises, mockEmitter, MockRTCSession } from "./utils/test";
const transport: Transport = {
type: "livekit",
livekit_service_url: "https://lk.example.org",
};
const userId = "@alice:example.org";
const deviceId = "AAAA";
const legacyMemberId = `${userId}:${deviceId}`;
function metadata(raw: Transport): TransportMetadata {
return {
type: raw.type,
id: transportId(raw),
raw,
resolved$: constant(undefined),
};
}
function member(
overrides: Partial<RTCMember> & Pick<RTCMember, "userId" | "memberId">,
): RTCMember {
return {
local: false,
rtcBackendIdentity: overrides.memberId,
backendIdentities: [overrides.memberId],
deviceId: "DEV",
transport$: constant(metadata(transport)),
applicationData$: constant({}),
displayName$: constant(overrides.userId),
avatarUrl$: constant(undefined),
...overrides,
};
}
const bob = member({ userId: "@bob:example.org", memberId: "bob-member" });
const own = member({ userId, deviceId, memberId: legacyMemberId, local: true });
/** A backend the test drives: it prepares when told, and reports what the participation asked of it. */
interface FakeBackend {
backend: MediaBackend;
context: MediaBackendContext;
prepared: PromiseWithResolvers<{ canDelegateDelayedLeave: boolean }>;
connectionState$: BehaviorSubject<MediaConnectionState | Error>;
data$: Subject<DataPacket>;
setPublishing: Mock<(publish: boolean) => void>;
delegateDelayedLeave: Mock<(delayId: string) => Promise<void>>;
mediaFor: Mock<(scope: ObservableScope, member: RTCMember) => MemberMedia>;
}
function setup(
options: Partial<RTCParticipationOptions> = {},
slotOptions: { matrixRTCMode?: MatrixRTCMode } = {},
): {
scope: ObservableScope;
participation: ReturnType<typeof createRTCParticipation>["participation"];
session: MockRTCSession;
mockedSession: ReturnType<MockRTCSession["asMockedSession"]>;
join: ReturnType<typeof vi.spyOn>;
members$: BehaviorSubject<RTCMember[]>;
fake: FakeBackend;
states: ParticipationState[];
} {
const scope = new ObservableScope();
const room = { roomId: "!room:example.org" } as Room;
const session = new MockRTCSession(room);
const mockedSession = session.asMockedSession();
const join = vi.spyOn(session, "join");
const client = {
...mockEmitter(),
getUserId: () => userId,
getDeviceId: () => deviceId,
getDomain: () => "example.org",
getSyncState: () => SyncState.Syncing,
_unstable_getRTCTransports: vi.fn().mockResolvedValue([transport]),
} as unknown as MatrixClient;
const members$ = new BehaviorSubject<RTCMember[]>([]);
const fake: Omit<FakeBackend, "backend" | "context"> &
Partial<Pick<FakeBackend, "backend" | "context">> = {
prepared: Promise.withResolvers(),
connectionState$: new BehaviorSubject<MediaConnectionState | Error>(
MediaConnectionState.Initialized,
),
data$: new Subject<DataPacket>(),
setPublishing: vi.fn(),
delegateDelayedLeave: vi.fn(async () => Promise.resolve()),
mediaFor: vi.fn(() => ({
tracks$: constant(null),
encryptionError$: EMPTY,
})),
};
const backendFactory = (
backendScope: ObservableScope,
context: MediaBackendContext,
): MediaBackend => {
const backend: MediaBackend = {
transportType: "livekit",
prepareLocalTransport: async () => fake.prepared.promise,
delegateDelayedLeave: fake.delegateDelayedLeave,
local: {
connectionState$: backendScope.behavior(fake.connectionState$),
setPublishing: fake.setPublishing,
publishError$: constant(null),
publish: vi.fn(),
unpublish: vi.fn(),
tracks$: constant(null),
encryptionError$: EMPTY,
},
mediaFor: fake.mediaFor,
connections$: constant([]),
setAudioOutputDeviceId: vi.fn(),
sendData: vi.fn(),
data$: fake.data$,
};
fake.context = context;
fake.backend = backend;
return backend;
};
const { participation } = createRTCParticipation(
scope,
{
client,
room,
slot: {} as RTCSlot,
jsSdkSession: mockedSession,
members$: scope.behavior(members$),
transports: { get: metadata, connected$: constant([]) },
slotOptions: {
matrixRTCMode: MatrixRTCMode.Compatibility,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
...slotOptions,
},
logger,
},
{ publish: [], backend: backendFactory, ...options },
);
const states: ParticipationState[] = [];
participation.state$.subscribe((state) => states.push(state));
return {
scope,
participation,
session,
mockedSession,
join,
members$,
fake: fake as FakeBackend,
states,
};
}
describe("createRTCParticipation", () => {
it("joins the session once the transport is prepared, and is connected once the media is", async () => {
const { participation, join, fake, states } = setup();
expect(states).toEqual([{ kind: "waitingForTransport" }]);
await flushPromises();
expect(join).not.toHaveBeenCalled();
fake.prepared.resolve({ canDelegateDelayedLeave: false });
await flushPromises();
expect(join).toHaveBeenCalledWith(
{ userId, deviceId, memberId: legacyMemberId },
[transport],
expect.objectContaining({
manageMediaKeys: true,
unstableSendStickyEvents: false,
delayedLeaveEventDelayMs: defaultSessionTimings.delayedLeave.delay_ms,
}),
);
expect(participation.state$.value).toEqual({ kind: "connecting" });
fake.connectionState$.next(MediaConnectionState.Connected);
expect(participation.state$.value).toEqual({ kind: "connected" });
fake.connectionState$.next(MediaConnectionState.Reconnecting);
expect(participation.state$.value).toEqual({
kind: "reconnecting",
reason: "media",
});
});
it("publishes only while joined", async () => {
const { participation, session, fake, scope } = setup();
const ended = vi.fn();
scope.onEnd(ended);
await flushPromises();
expect(fake.setPublishing).toHaveBeenLastCalledWith(false);
fake.prepared.resolve({ canDelegateDelayedLeave: false });
await flushPromises();
expect(fake.setPublishing).toHaveBeenLastCalledWith(true);
participation.leave();
expect(fake.setPublishing).toHaveBeenLastCalledWith(false);
expect(participation.state$.value).toEqual({ kind: "left" });
await flushPromises();
expect(session.leave).toHaveBeenCalled();
expect(ended).toHaveBeenCalled();
});
it("fails where the transport cannot be prepared", async () => {
const { participation, join, fake } = setup();
const error = new MatrixRTCError("no token");
fake.prepared.reject(error);
await flushPromises();
expect(participation.state$.value).toEqual({ kind: "failed", error });
expect(join).not.toHaveBeenCalled();
});
it("fails where the media connection fails, and stays failed", async () => {
const { participation, fake } = setup();
fake.prepared.resolve({ canDelegateDelayedLeave: false });
await flushPromises();
const error = new MatrixRTCError("SFU gone");
fake.connectionState$.next(error);
expect(participation.state$.value).toEqual({ kind: "failed", error });
fake.connectionState$.next(MediaConnectionState.Connected);
expect(participation.state$.value).toEqual({ kind: "failed", error });
});
it("joins with the delegated timings where the backend delegates, and hands it each delay id", async () => {
const { session, join, fake } = setup({
timings: { delegatedDelayedLeave: { delay_ms: 7_200_000 } },
});
fake.prepared.resolve({ canDelegateDelayedLeave: true });
await flushPromises();
expect(join).toHaveBeenCalledWith(
expect.anything(),
[transport],
expect.objectContaining({ delayedLeaveEventDelayMs: 7_200_000 }),
);
expect(fake.delegateDelayedLeave).not.toHaveBeenCalled();
// Re-emitted by the js-sdk session with the membership manager appended
(
session as unknown as { emit(event: string, ...args: unknown[]): void }
).emit(MembershipManagerEvent.DelayIdChanged, "delay-1", {});
await flushPromises();
expect(fake.delegateDelayedLeave).toHaveBeenCalledWith("delay-1");
});
it("names the membership `${userId}:${deviceId}` in compatibility mode and by a uuid in Matrix 2.0", () => {
const compatibility = setup();
expect(compatibility.fake.context.ownMembershipIdentity).toEqual({
userId,
deviceId,
memberId: legacyMemberId,
});
compatibility.scope.end();
const matrix2 = setup({}, { matrixRTCMode: MatrixRTCMode.Matrix_2_0 });
const { memberId } = matrix2.fake.context.ownMembershipIdentity;
expect(memberId).not.toBe(legacyMemberId);
expect(memberId).toMatch(/^[0-9a-f-]{36}$/);
matrix2.scope.end();
});
it("passes the slot and the join options on to the backend", () => {
const { fake, scope } = setup({
publish: [{ source: "camera" }],
timings: { networkErrorRetryMs: 250 },
});
expect(fake.context).toMatchObject({
roomId: "!room:example.org",
slotId: "m.call#ROOM",
publish: [{ source: "camera" }],
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
timings: { ...defaultSessionTimings, networkErrorRetryMs: 250 },
});
scope.end();
});
it("gives the backend the keys the session holds and the ones that follow", () => {
const { fake, session, mockedSession, scope } = setup();
const keys: MediaKey[] = [];
fake.context.mediaKeys$.subscribe((key) => keys.push(key));
expect(mockedSession.reemitEncryptionKeys).toHaveBeenCalledOnce();
const key = new Uint8Array([1, 2, 3]);
session.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
key,
2,
{ userId: "@bob:example.org", deviceId: "DEV", memberId: "bob-member" },
"bob-member",
);
expect(keys).toEqual([{ participantId: "bob-member", index: 2, key }]);
scope.end();
});
it("builds the remote members on the backend's media and the local member on our own membership", () => {
const { participation, members$, fake, scope } = setup();
const stale = member({
userId,
deviceId,
memberId: "old-uuid",
local: true,
});
members$.next([own, bob, stale]);
expect(fake.mediaFor).toHaveBeenCalledTimes(1);
expect(fake.mediaFor).toHaveBeenCalledWith(expect.anything(), bob);
expect(participation.remoteMembers$.value.map((m) => m.memberId)).toEqual([
"bob-member",
]);
expect(participation.localMember$.value).toMatchObject({
local: true,
memberId: legacyMemberId,
});
scope.end();
});
it("delivers data from a member with its member and drops the rest", () => {
const { participation, members$, fake, scope } = setup();
members$.next([
member({
userId: "@bob:example.org",
memberId: "bob-member",
backendIdentities: ["bob-hashed", "bob-member"],
}),
]);
const messages: DataMessage[] = [];
participation.data$.subscribe((message) => messages.push(message));
fake.data$.next({ senderId: "bob-hashed", topic: "chat", text: "hi" });
fake.data$.next({ senderId: "stranger", topic: "chat", text: "boo" });
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
topic: "chat",
text: "hi",
member: { memberId: "bob-member" },
});
scope.end();
});
it("leaves at most once", async () => {
const { participation, session, fake } = setup();
fake.prepared.resolve({ canDelegateDelayedLeave: false });
await flushPromises();
participation.leave();
participation.leave();
await flushPromises();
expect(session.leave).toHaveBeenCalledOnce();
});
});
+308
View File
@@ -0,0 +1,308 @@
/*
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 MatrixClient, type Room } from "matrix-js-sdk";
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
type MatrixRTCSession as JsSdkRTCSession,
MatrixRTCSessionEvent,
MembershipManagerEvent,
} from "matrix-js-sdk/lib/matrixrtc";
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { v4 as uuidv4 } from "uuid";
import {
combineLatest,
distinctUntilChanged,
filter,
from,
fromEvent,
map,
Observable,
scan,
} from "rxjs";
import { defaultSessionTimings, MatrixRTCMode } from "./config";
import { E2eeType } from "./encryption";
import { type Behavior } from "./reactive/Behavior";
import { type ObservableScope } from "./reactive/ObservableScope";
import { generateItems } from "./reactive/observable";
import { mapScoped } from "./utils/mapScoped";
import {
type DataMessage,
type LocalRTCMember,
type ParticipationState,
type RemoteRTCMember,
type RTCMember,
type RTCParticipation,
type RTCParticipationOptions,
type RTCSlot,
type RTCSlotOptions,
} from "./api";
import {
type MediaBackend,
type MediaBackendContext,
type MediaKey,
} from "./media-backend/api";
import { createLivekitBackend } from "./media-backend/livekit/LivekitBackend";
import { createHomeserverConnected$ } from "./matrixrtc/HomeserverConnected";
import { joinJsSdkSession } from "./matrixrtc/joinJsSdkSession";
import {
createLocalMembership$,
type PreparedTransport,
} from "./matrixrtc/LocalMembership";
import { discoverLocalTransport } from "./matrixrtc/LocalTransport";
import {
createLocalRTCMember,
createRemoteRTCMember,
} from "./matrixrtc/Members";
import { createKeyRotationSuppressed$ } from "./matrixrtc/JsRtcSessionBehaviors";
import { type TransportRegistry } from "./matrixrtc/Transports";
import { participationState } from "./participationState";
/** What the slot lends a participation. */
export interface ParticipationDeps {
client: MatrixClient;
room: Room;
slot: RTCSlot;
jsSdkSession: JsSdkRTCSession;
members$: Behavior<RTCMember[]>;
transports: TransportRegistry;
slotOptions: Pick<RTCSlotOptions, "matrixRTCMode" | "encryptionSystem">;
logger: Logger;
}
/** The participation, plus what the slot reads from it without exposing it. */
export interface ParticipationInternals {
participation: RTCParticipation;
connections$: MediaBackend["connections$"];
}
/**
* Our span in a slot: the backend, the local transport, the join state
* machine and the members with the media it carries for them. Starts joining
* at once and ends with `leave()`, which also ends `scope`.
*/
export function createRTCParticipation(
scope: ObservableScope,
{
client,
room,
slot,
jsSdkSession,
members$,
transports,
slotOptions: { matrixRTCMode, encryptionSystem },
logger: parentLogger,
}: ParticipationDeps,
options: RTCParticipationOptions,
): ParticipationInternals {
const logger = parentLogger.getChild("[RTCParticipation]");
const userId = client.getUserId()!;
const deviceId = client.getDeviceId()!;
const timings = { ...defaultSessionTimings, ...options.timings };
const ownMembershipIdentity: CallMembershipIdentityParts = {
userId,
deviceId,
// A pre-sticky membership names itself `${userId}:${deviceId}`, and the
// key transport stamps this id into every key event, so a uuid there
// would name a member no peer can resolve
memberId:
matrixRTCMode === MatrixRTCMode.Matrix_2_0
? uuidv4()
: `${userId}:${deviceId}`,
};
const backend = createBackend(scope, client, options, {
roomId: room.roomId,
slotId: jsSdkSession.slotId,
ownMembershipIdentity,
publish: options.publish,
encryptionSystem,
mediaKeys$: mediaKeys$(jsSdkSession),
timings,
logger,
});
const preparedTransport$ = from(
discoverLocalTransport(client, backend.transportType, options, logger).then(
async (transport): Promise<PreparedTransport> => ({
transport,
...(await backend.prepareLocalTransport(transport)),
}),
),
);
const localMembership = createLocalMembership$({
scope,
delegateDelayedLeave: async (delayId) =>
backend.delegateDelayedLeave(delayId),
preparedTransport$,
homeserverConnected: createHomeserverConnected$(
scope,
client,
jsSdkSession,
timings.syncDisconnectGracePeriodMs,
),
joinMatrixRTC: (transport, delayedLeave) =>
joinJsSdkSession(jsSdkSession, ownMembershipIdentity, transport, {
encryptMedia: encryptionSystem.kind !== E2eeType.NONE,
matrixRTCMode,
sendNotificationType: options.sendNotificationType,
applicationData: options.applicationData,
timings,
delayedLeave,
}),
membershipManagerError$: fromEvent(
jsSdkSession,
MatrixRTCSessionEvent.MembershipManagerError,
),
matrixRTCSession: jsSdkSession,
delayId$: scope.behavior(
(
fromEvent(
jsSdkSession,
MembershipManagerEvent.DelayIdChanged,
// The re-emitted event carries the original emitter as the second argument
) as Observable<[string | undefined, IMembershipManager]>
).pipe(map(([delayId]) => delayId ?? null)),
jsSdkSession.delayId ?? null,
),
timings,
logger,
});
const remoteMembers$ = scope.behavior<RemoteRTCMember[]>(
members$.pipe(
generateItems(
"RTCParticipation remoteMembers",
function* (members) {
for (const member of members)
if (!member.local) yield { keys: [member] as const, data: member };
},
(memberScope, member$) =>
createRemoteRTCMember(
member$.value,
backend.mediaFor(memberScope, member$.value),
),
),
),
);
// Matched by the identity we joined with: a stale membership of ours from
// an earlier participation is `local` in the slot but is not this one
const ownMember$ = scope.behavior(
members$.pipe(
map(
(members) =>
members.find(
(member) =>
member.local &&
member.memberId === ownMembershipIdentity.memberId,
) ?? null,
),
),
);
const localMember$ = scope.behavior<LocalRTCMember | null>(
mapScoped(scope, ownMember$, (_memberScope, member) =>
createLocalRTCMember(member, backend.local),
).pipe(map((member) => member ?? null)),
);
// A packet from an identity that is not a member is dropped, so a host only
// ever hears from attested members
const data$ = backend.data$.pipe(
map(({ senderId, topic, text }): DataMessage | null => {
const member = remoteMembers$.value.find((m) =>
m.backendIdentities.includes(senderId),
);
if (member === undefined) {
logger.warn(`Dropping data from ${senderId}: not a member`);
return null;
}
return { member, topic, text };
}),
filter((message) => message !== null),
);
// Nothing leaves this device while it may already have been dropped from
// the session: the member would show as away while still being heard
localMembership.state$
.pipe(
map((membership) => membership.kind === "joined"),
distinctUntilChanged(),
scope.bind(),
)
.subscribe((publish) => backend.local.setPublishing(publish));
const state$ = scope.behavior<ParticipationState>(
combineLatest([
localMembership.state$,
backend.local.connectionState$,
]).pipe(
scan(
(previous, [membership, media]) =>
participationState(previous, membership, media),
{ kind: "waitingForTransport" } as ParticipationState,
),
distinctUntilChanged(deepCompare),
),
);
const leave = (): void => {
if (state$.value.kind === "left") return;
localMembership.leave();
scope.end();
};
return {
connections$: backend.connections$,
participation: {
slot,
leave,
state$,
localMember$,
remoteMembers$,
publish: async (request) => backend.local.publish(request),
unpublish: async (source) => backend.local.unpublish(source),
keyRotationSuppressed$: createKeyRotationSuppressed$(scope, jsSdkSession),
connectedTransports$: transports.connected$,
setAudioOutputDeviceId: async (deviceId) =>
backend.setAudioOutputDeviceId(deviceId),
sendData: async (topic, text) => backend.sendData(topic, text),
data$,
},
};
}
/** The host's backend, or LiveKit configured from the participation options. */
function createBackend(
scope: ObservableScope,
client: MatrixClient,
{ backend, mediaQuality }: RTCParticipationOptions,
context: MediaBackendContext,
): MediaBackend {
if (backend) return backend(scope, context);
return createLivekitBackend(scope, context, { client, mediaQuality });
}
/** The slot's per-participant keys: the ones it already holds on subscribe, then each new one. */
function mediaKeys$(session: JsSdkRTCSession): Observable<MediaKey> {
return new Observable((subscriber) => {
const onKey = (
key: Uint8Array<ArrayBuffer>,
index: number,
_membership: CallMembershipIdentityParts,
participantId: string,
): void => subscriber.next({ participantId, index, key });
session.on(MatrixRTCSessionEvent.EncryptionKeyChanged, onKey);
session.reemitEncryptionKeys();
return () => session.off(MatrixRTCSessionEvent.EncryptionKeyChanged, onKey);
});
}
+71
View File
@@ -0,0 +1,71 @@
/*
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 { type MatrixClient, type Room } from "matrix-js-sdk";
import { EventType } from "matrix-js-sdk/lib/@types/event";
import { type MatrixEvent } from "matrix-js-sdk/lib/models/event";
import {
type RoomState,
RoomStateEvent,
} from "matrix-js-sdk/lib/models/room-state";
import { createRTCSlot } from "./RTCSlot";
import { type RTCSlotOptions } from "./api";
import { MatrixRTCMode } from "./config";
import { E2eeType } from "./encryption";
import { mockEmitter, MockRTCSession, testScope } from "./utils/test";
const options: RTCSlotOptions = {
matrixRTCMode: MatrixRTCMode.Compatibility,
encryptionSystem: { kind: E2eeType.NONE },
};
function setup(): {
client: MatrixClient;
room: Room;
session: MockRTCSession;
} {
const room = {
...mockEmitter(),
roomId: "!room:example.org",
getMembersWithMembership: (): [] => [],
} as unknown as Room;
const session = new MockRTCSession(room);
const client = {
getUserId: (): string => "@alice:example.org",
getDeviceId: (): string => "AAAA",
matrixRTC: { getRoomSession: (): MockRTCSession => session },
} as unknown as MatrixClient;
return { client, room, session };
}
describe("createRTCSlot", () => {
describe("status$", () => {
it("is undefined while the room has no slot event", () => {
const { client, room } = setup();
const slot = createRTCSlot(testScope(), client, room, options);
expect(slot.status$.value).toBeUndefined();
});
it("follows the slot event", () => {
const { client, room, session } = setup();
session.rtcSlot = { status: "open" };
const slot = createRTCSlot(testScope(), client, room, options);
expect(slot.status$.value).toBe("open");
session.rtcSlot = { status: "closed" };
room.emit(
RoomStateEvent.Events,
{ getType: (): string => EventType.RTCSlot } as MatrixEvent,
{} as RoomState,
null,
);
expect(slot.status$.value).toBe("closed");
});
});
});
+195
View File
@@ -0,0 +1,195 @@
/*
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 MatrixClient, type Room } from "matrix-js-sdk";
import { EventType } from "matrix-js-sdk/lib/@types/event";
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
type MatrixRTCSession as JsSdkRTCSession,
MatrixRTCSessionManager,
type SlotDescription,
} from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixEvent } from "matrix-js-sdk/lib/models/event";
import { RoomStateEvent } from "matrix-js-sdk/lib/models/room-state";
import {
BehaviorSubject,
filter,
fromEvent,
map,
of,
startWith,
switchMap,
} from "rxjs";
import { ObservableScope } from "./reactive/ObservableScope";
import { generateItems } from "./reactive/observable";
import {
type RTCMember,
type RTCParticipation,
type RTCParticipationOptions,
type RTCSlot,
type RTCSlotOptions,
} from "./api";
import { MatrixRTCError } from "./errors";
import {
createRTCParticipation,
type ParticipationInternals,
} from "./RTCParticipation";
import { createMemberships$ } from "./matrixrtc/JsRtcSessionBehaviors";
import {
createMatrixMemberMetadata$,
createRoomMembers$,
} from "./matrixrtc/MatrixMemberMetadata";
import {
createRTCMember,
type MemberContext,
membershipKeys,
} from "./matrixrtc/Members";
import { createTransportRegistry } from "./matrixrtc/Transports";
/**
* Takes the whole `MatrixClient` rather than a slice of it, and finds the
* MatrixRTC session itself. The js-sdk is the MatrixRTC implementation today;
* when the rust-rtc crate replaces it, the SDK has to bridge the crate to the
* js-sdk client, and only the SDK knows what that bridge needs. Holding the
* client keeps that change inside the SDK.
*/
export function createRTCSlot(
scope: ObservableScope,
client: MatrixClient,
room: Room,
options: RTCSlotOptions,
): RTCSlot {
const logger = rootLogger.getChild("[RTCSlot]");
const userId = client.getUserId();
const deviceId = client.getDeviceId();
if (!(userId && deviceId))
throw new MatrixRTCError("The client has to be logged in");
const { application = "m.call", id = "ROOM" } = options;
const jsSdkSession = sessionManager(
scope,
client,
{ application, id },
logger,
).getRoomSession(room);
const memberships$ = createMemberships$(scope, jsSdkSession);
const active$ = new BehaviorSubject<ParticipationInternals | null>(null);
const participationScopes = new Set<ObservableScope>();
scope.onEnd(() => {
for (const participationScope of participationScopes)
participationScope.end();
});
const transports = createTransportRegistry(
scope,
scope.behavior(
active$.pipe(switchMap((active) => active?.connections$ ?? of([]))),
),
);
const context: MemberContext = {
metadata: createMatrixMemberMetadata$(
scope,
scope.behavior(memberships$.pipe(map(({ value }) => value))),
createRoomMembers$(scope, room),
),
transports,
own: { userId, deviceId },
};
const members$ = scope.behavior<RTCMember[]>(
memberships$.pipe(
map(({ value }) => value),
generateItems(
"RTCSlot members",
function* (memberships) {
for (const membership of memberships)
yield { keys: membershipKeys(membership), data: membership };
},
(memberScope, membership$) =>
createRTCMember(memberScope, membership$, context),
),
),
);
// The status is undefined while the room has no slot event, and an explicit
// undefined is not an initial value to `behavior`, so it goes in the stream
const status$ = scope.behavior(
fromEvent(room, RoomStateEvent.Events, (event: MatrixEvent) => event).pipe(
filter((event) => event.getType() === EventType.RTCSlot),
map(() => slotStatus(jsSdkSession)),
startWith(slotStatus(jsSdkSession)),
),
);
const slot: RTCSlot = {
roomId: room.roomId,
application,
id,
status$,
members$,
participation$: scope.behavior(
active$.pipe(map((active) => active?.participation ?? null)),
),
join: (participationOptions: RTCParticipationOptions): RTCParticipation => {
if (active$.value !== null)
throw new MatrixRTCError(
"Already in this slot: leave the participation before joining again",
);
const participationScope = new ObservableScope();
participationScopes.add(participationScope);
const internals = createRTCParticipation(
participationScope,
{
client,
room,
slot,
jsSdkSession,
members$,
transports,
slotOptions: options,
logger,
},
participationOptions,
);
participationScope.onEnd(() => {
participationScopes.delete(participationScope);
if (active$.value === internals) active$.next(null);
});
active$.next(internals);
return internals.participation;
},
};
return slot;
}
function slotStatus(
session: Pick<JsSdkRTCSession, "getRtcSlot">,
): "open" | "closed" | undefined {
return session.getRtcSlot()?.status;
}
/**
* The js-sdk keeps one session manager per slot. The client comes with the
* manager for the default slot; any other slot gets a manager of its own,
* which lives as long as the scope.
*/
function sessionManager(
scope: ObservableScope,
client: MatrixClient,
{ application, id }: SlotDescription,
logger: Logger,
): MatrixRTCSessionManager {
if (application === "m.call" && id === "ROOM") return client.matrixRTC;
const manager = new MatrixRTCSessionManager(logger, client, {
application,
id,
});
manager.start();
scope.onEnd(() => manager.stop());
return manager;
}
+300
View File
@@ -0,0 +1,300 @@
/*
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 public types of the SDK: the slot, the participation, their members and
* transports. What a member sends is in `media-api.ts`. `createRTCSlot` in
* `RTCSlot.ts` is the only way to obtain an implementation of them.
*/
import {
type RTCNotificationType,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { type Observable } from "rxjs";
import { type Behavior } from "./reactive/Behavior";
import {
type LocalAudioMediaTrack,
type LocalMemberMedia,
type LocalVideoMediaTrack,
type MediaSource,
type MemberMedia,
type PublishRequest,
} from "./media-api";
import { type MediaBackendFactory } from "./media-backend/api";
import { type EncryptionSystem } from "./encryption";
import {
type MatrixRTCMode,
type MediaQuality,
type SessionTimings,
} from "./config";
import { type MatrixRTCError } from "./errors";
export { MatrixRTCError } from "./errors";
// ---------------------------------------------------------------------------
// Slot
export interface RTCSlotOptions {
/** The application the slot belongs to, as named in the membership. Default `m.call`. */
application?: string;
/** The application's slot in the room. Default `ROOM`. */
id?: string;
/** How memberships and keys in this slot are written. Resolved by the host. */
matrixRTCMode: MatrixRTCMode;
encryptionSystem: EncryptionSystem;
}
/**
* The namespace in a room that memberships join into. It exists while the
* room does, with nobody in it too. The room has to be the one the client's
* sync loop maintains (`client.getRoom(roomId)`, once the join has synced):
* the detached copy `joinRoom` returns for a room joined just now never
* receives the state the members are read from.
*/
export interface RTCSlot {
readonly roomId: string;
readonly application: string;
readonly id: string;
/** From the slot state event; undefined while the room has none. */
status$: Behavior<"open" | "closed" | undefined>;
/**
* Everyone in the slot, one object per membership, kept for as long as the
* membership is. Our own membership is in here too, with `local: true`,
* once the room has seen it. No media: that is the participation's.
*/
members$: Behavior<RTCMember[]>;
/** The participation we are in, null between `leave()` and the next `join()`. */
participation$: Behavior<RTCParticipation | null>;
/**
* Joins the slot and returns the participation that lasts until its
* `leave()`. Throws while `participation$` is not null: a slot has one local
* member.
*/
join(options: RTCParticipationOptions): RTCParticipation; // Maybe MediaSession,
}
// ---------------------------------------------------------------------------
// Participation
export interface RTCParticipationOptions {
/**
* Published at the join; `publish` on the participation adds to it from then
* on. Kept as an option, rather than only the method, because the LiveKit
* backend reads the initial requests to build the room's default capture
* options before the first connection exists.
*/
publish: PublishRequest[];
/**
* LEGACY
*
* Conside already removing it. just skips code in js-sdk. But rust sdk will not have this feature.
* MSC4075 notification sent with the join. A parameter of the MatrixRTC
* join itself, so it is here even though it is named after calls; reacting
* to a notification (ringing, timeouts, declines) is the application's job.
*/
sendNotificationType?: RTCNotificationType;
/**
* Whatever the application wants to say about itself in the membership,
* under namespaced keys. Opaque to the SDK, apart from `m.call.intent`
* (`"audio"` or `"video"`), which the membership format carries as a
* field of its own.
*/
applicationData?: Record<string, unknown>;
/** Timings the host has configured; the defaults otherwise. */
timings?: Partial<SessionTimings>;
/** Limits on what is published; LiveKit's defaults otherwise. */
mediaQuality?: MediaQuality;
/**
* Use this transport instead of asking the homeserver. Any shape the
* backend serves: for LiveKit a `url`, a `livekit_service_url` or both.
*/
transport?: Transport;
/** Use this transport when the homeserver advertises none of the backend's type. */
fallbackTransport?: Transport;
/**
* What carries the media. LiveKit, configured from the options above, when
* left out; see `createLivekitBackend` for building one with other options.
*/
backend?: MediaBackendFactory;
}
/**
* The homeserver link that is down, in the order they depend on each other:
* without sync the membership is unknowable, and without a confirmed
* membership the delayed leave cannot be refreshed.
*/
export type MatrixDisconnectReason = "sync" | "membership" | "probablyLeft";
/**
* Why the participation is not connected: the first failing link, in the
* order they depend on each other. The media connection comes last because
* it is worth little while the Matrix side is down.
*/
export type DisconnectReason = MatrixDisconnectReason | "media";
export type ParticipationState =
/** Asking the homeserver for a transport, or preparing it. */
| { kind: "waitingForTransport" }
/** Transport known; joining the session and connecting the media for the first time. */
| { kind: "connecting" }
/** Membership joined and the local media connection up. */
| { kind: "connected" }
/** Was connected and one link is down. Back to `connected` or on to `failed`. */
| { kind: "reconnecting"; reason: DisconnectReason }
/**
* The membership or the media connection ended with an error. Terminal
* unless `left`. A failed publication is not fatal: the member can still
* receive, so it stays on the local member's media.
*/
| { kind: "failed"; error: MatrixRTCError }
/** `leave()` was called. Terminal. */
| { kind: "left" };
/** The span between our `join()` of a slot and our `leave()`. */
export interface RTCParticipation {
readonly slot: RTCSlot;
/** Sends the leave, tears the connections down and ends the participation. */
leave(): void;
state$: Behavior<ParticipationState>;
/** Null until our own membership has been seen in the room. */
localMember$: Behavior<LocalRTCMember | null>;
/**
* The slot's other members, each with the media this participation carries
* for it. A call view needs nothing but this and `localMember$` to render.
*/
remoteMembers$: Behavior<RemoteRTCMember[]>;
/**
* Publishes a source and resolves with its track, which also shows up in
* `localMember$.tracks$`. Works from the moment `join()` returns: before the
* transport is connected.
*/
publish(
request: PublishRequest,
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
/** Removes one of our publications; a screen share takes its audio with it. */
unpublish(source: MediaSource): Promise<void>;
/**
* Whether the slot has grown large enough that MatrixRTC has stopped
* rotating the media encryption key.
*/
keyRotationSuppressed$: Behavior<boolean>;
/** Transports the participation currently holds a live connection to. */
connectedTransports$: Behavior<TransportMetadata[]>;
/**
* Plays the members' audio on this device from now on. Rejects where the
* browser cannot switch to it. A host that routes audio itself, or leaves
* the browser's choice, never calls it.
*/
setAudioOutputDeviceId(deviceId: string): Promise<void>;
/**
* Sends a short text to every member on the local transport, over a
* reliable data channel beside the media. A packet holds about 15 KiB;
* anything larger belongs in a room event. Rejects while the local
* transport is not connected. Encrypted on the wire like the media, but
* not end to end with the media key.
*/
sendData(topic: string, text: string): Promise<void>;
/**
* What remote members sent with `sendData`, on every transport the
* participation is connected to. A message from an identity that is not a
* member is dropped, so a host only ever hears from attested members.
*/
data$: Observable<DataMessage>;
}
/** One `sendData` call as it arrives at a remote member. */
export interface DataMessage {
member: RemoteRTCMember;
topic: string;
text: string;
}
// ---------------------------------------------------------------------------
// Transports
/**
* One transport advertised in a membership. Transport independent: `type` and
* `id` are all the SDK needs; `raw` and `resolved$` are there for a
* backend-specific developer panel and for connection diagnostics.
*/
export interface TransportMetadata {
/** `"livekit"` today. */
type: string;
/** Stable key, unique per transport in the slot: the raw transport, serialised with sorted keys. */
id: string;
/** The transport object as it appears in the membership. */
raw: Transport;
/**
* What the backend had to fetch before it could connect. Undefined until the
* connection has resolved it, and again after the connection stops.
*/
resolved$: Behavior<ResolvedTransport | undefined>;
}
export type ResolvedTransport =
| {
type: "livekit";
/** The SFU websocket url, as opposed to the JWT service url in `raw`. */
url: string;
/** A secret: fit for a developer panel, never for a log line. */
token: string;
roomAlias: string;
identity: string;
}
| { type: string; [key: string]: unknown };
// ---------------------------------------------------------------------------
// Members
/** A membership in the slot, with what the SDK derives from it. No media. */
export interface RTCMember {
local: boolean;
/**
* The identity this member is known by on its transport for matching
* purposes, read off the membership: `${userId}:${deviceId}`. A stable key
* per membership and a debug label for a host; the SDK matches media
* against `backendIdentities`, not this.
*/
rtcBackendIdentity: string;
/**
* Every identity this member may publish under on its transport: the
* participant identity the SFU sees, and the sender of its data packets.
* One per authentication flow the member's transport allows, so a
* transport with both a `url` and a `livekit_service_url` lists the hashed
* and the legacy identity, and the backend matches whichever shows up.
* Opaque to a host.
*/
backendIdentities: readonly string[];
userId: string;
deviceId: string;
/** The membership's own id; what the Matrix 2.0 identity is derived from. */
memberId: string;
/** Which transport this member is on; undefined when the membership has none. */
transport$: Behavior<TransportMetadata | undefined>;
/** What the member's application says about itself, e.g. `m.call.intent`. */
applicationData$: Behavior<Record<string, unknown>>;
displayName$: Behavior<string>;
avatarUrl$: Behavior<string | undefined>;
}
export interface RemoteRTCMember extends RTCMember, MemberMedia {
local: false;
}
/** Our member: the same as a remote one, with tracks that carry the controls. */
export interface LocalRTCMember extends RTCMember, LocalMemberMedia {
local: true;
}
+101
View File
@@ -0,0 +1,101 @@
/*
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 MatrixRTC mode determines how the client interacts with the MatrixRTC
* backend and other participants. Resolved by the host.
*/
export enum MatrixRTCMode {
/** Multi-SFU transport, membership state events. */
Compatibility = "compatibility",
/**
* Multi-SFU transport with sticky membership events and a uuid member id.
* The mode decides how the membership is written, not how the media
* transport is authenticated with: that follows the transport's shape and
* our role on it.
*/
Matrix_2_0 = "matrix_2_0",
}
export interface DelayedLeaveTimings {
/** How long (in milliseconds) the homeserver waits before sending the delayed leave. */
delay_ms: number;
/** How often (in milliseconds) the client restarts the delayed leave. */
restart_ms?: number;
/** How long (in milliseconds) a restart may take before it counts as failed. */
restart_timeout_ms?: number;
}
/**
* The session's timings. A host that has them in its configuration passes
* them in; these are the defaults a MatrixRTC backend is expected to accept.
*/
export interface SessionTimings {
/** How long the sync may be down before the client counts as disconnected. */
syncDisconnectGracePeriodMs: number;
/** How long to wait before retrying after a network error on any request. */
networkErrorRetryMs: number;
/** How long to wait before rotating the media key when someone leaves. */
waitForKeyRotationMs?: number;
/** When the membership expires if the client stops renewing it. */
membershipEventExpiryMs?: number;
/** The session size at which the media key stops being rotated. */
keyRotationParticipantLimit?: number;
delayedLeave: DelayedLeaveTimings;
/**
* The timings used where the SFU restarts the delayed leave on the
* client's behalf, which lets the client stop renewing it itself.
*/
delegatedDelayedLeave: DelayedLeaveTimings;
}
export const defaultSessionTimings: SessionTimings = {
syncDisconnectGracePeriodMs: 10_000,
networkErrorRetryMs: 1_000,
delayedLeave: { delay_ms: 18_000, restart_ms: 4_000 },
delegatedDelayedLeave: { delay_ms: 3_600_000, restart_ms: 300_000 },
};
/** Media quality settings a host may pass; everything is optional. */
export interface MediaQuality {
/** Video codec preference. The server must also have the codec enabled. Default `vp8`. */
video_codec?: "vp8" | "vp9" | "h264" | "av1";
video?: {
/** Max resolution height in pixels. Default 720. */
max_resolution?: number;
/** Max bitrate in bits per second. Default 1700000. */
max_bitrate?: number;
/** Max framerate. Default 30. */
max_framerate?: number;
/** Simulcast layers, lowest quality first. Default 180p and 360p. */
simulcast_layers?: Array<{ height: number; bitrate: number }>;
};
screen_share?: {
/** Max resolution height in pixels. Default 1080. */
max_resolution?: number;
/** Max bitrate in bits per second. Default 5000000. */
max_bitrate?: number;
/** Max framerate. Default 30. */
max_framerate?: number;
/** Simulcast layers, lowest quality first. Without them LiveKit adds one layer at half resolution. */
simulcast_layers?: Array<{
height: number;
bitrate: number;
framerate?: number;
}>;
};
}
export const defaultMediaQuality = {
video_codec: "vp8",
video: { max_resolution: 720, max_bitrate: 1_700_000, max_framerate: 30 },
screen_share: {
max_resolution: 1080,
max_bitrate: 5_000_000,
max_framerate: 30,
},
} as const satisfies MediaQuality;
+27
View File
@@ -0,0 +1,27 @@
/*
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.
*/
export enum E2eeType {
NONE = 0,
PER_PARTICIPANT = 1,
SHARED_KEY = 2,
}
export interface Unencrypted {
kind: E2eeType.NONE;
}
export interface SharedSecret {
kind: E2eeType.SHARED_KEY;
secret: string;
}
export interface PerParticipantE2EE {
kind: E2eeType.PER_PARTICIPANT;
}
export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
+169
View File
@@ -0,0 +1,169 @@
/*
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 ConnectionError } from "livekit-client";
export enum ErrorCode {
/** No MatrixRTC transport provided by the homeserver and no fallback configured. */
MISSING_MATRIX_RTC_TRANSPORT = "MISSING_MATRIX_RTC_TRANSPORT",
CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR",
INTERNAL_MEMBERSHIP_MANAGER = "INTERNAL_MEMBERSHIP_MANAGER",
FAILED_TO_START_LIVEKIT = "FAILED_TO_START_LIVEKIT",
/** LiveKit indicates that the server has hit its track limits. */
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
OPEN_ID_ERROR = "OPEN_ID_ERROR",
NO_MATRIX_2_AUTHORIZATION_SERVICE = "NO_MATRIX_2_0_AUTHORIZATION_SERVICE",
SFU_ERROR = "SFU_ERROR",
UNKNOWN_ERROR = "UNKNOWN_ERROR",
}
export enum ErrorCategory {
/** Calling is not supported, the server is misconfigured (JWT service missing, no MSC support). */
CONFIGURATION_ISSUE = "CONFIGURATION_ISSUE",
NETWORK_CONNECTIVITY = "NETWORK_CONNECTIVITY",
CLIENT_CONFIGURATION = "CLIENT_CONFIGURATION",
UNKNOWN = "UNKNOWN",
SYSTEM_FAILURE = "SYSTEM_FAILURE",
}
/**
* An error raised by the client. `code` says what went wrong in a form a host
* can translate; `cause` holds the underlying error where there is one.
*/
export class MatrixRTCError extends Error {
public constructor(
message: string,
public readonly code: ErrorCode = ErrorCode.UNKNOWN_ERROR,
public readonly category: ErrorCategory = ErrorCategory.UNKNOWN,
options?: ErrorOptions,
) {
super(message, options);
this.name = "MatrixRTCError";
}
}
/** Wraps whatever a backend threw into the SDK's error type, keeping it as the cause. */
export function toMatrixRTCError(error: unknown): MatrixRTCError {
if (error instanceof MatrixRTCError) return error;
if (error instanceof Error)
return new MatrixRTCError(
error.message || error.name,
ErrorCode.UNKNOWN_ERROR,
ErrorCategory.UNKNOWN,
{ cause: error },
);
return new MatrixRTCError(String(error));
}
export class MatrixRTCTransportMissingError extends MatrixRTCError {
public constructor(public readonly domain: string) {
super(
`The homeserver ${domain} advertises no MatrixRTC transport`,
ErrorCode.MISSING_MATRIX_RTC_TRANSPORT,
ErrorCategory.CONFIGURATION_ISSUE,
);
}
}
export class MembershipManagerError extends MatrixRTCError {
public constructor(cause: Error) {
super(
`The membership manager stopped: ${cause.message}`,
ErrorCode.INTERNAL_MEMBERSHIP_MANAGER,
ErrorCategory.SYSTEM_FAILURE,
{ cause },
);
}
}
export class UnknownRTCError extends MatrixRTCError {
public constructor(cause: Error) {
super(
cause.message || "Unknown error",
ErrorCode.UNKNOWN_ERROR,
ErrorCategory.UNKNOWN,
{
cause,
},
);
}
}
export class FailToGetOpenIdToken extends MatrixRTCError {
public constructor(cause: Error) {
super(
cause instanceof MatrixRTCError
? cause.message
: `Could not authenticate with the transport: ${cause.message}`,
ErrorCode.OPEN_ID_ERROR,
ErrorCategory.CONFIGURATION_ISSUE,
{ cause },
);
}
}
export class NoMatrix2AuthorizationService extends MatrixRTCError {
public constructor(cause: Error) {
super(
"The transport's authorization service does not support Matrix 2.0",
ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE,
ErrorCategory.CONFIGURATION_ISSUE,
{ cause },
);
}
}
export class FailToStartLivekitConnection extends MatrixRTCError {
public constructor(detail?: string) {
super(
`Failed to start the media connection${detail ? `: ${detail}` : ""}`,
ErrorCode.FAILED_TO_START_LIVEKIT,
ErrorCategory.NETWORK_CONNECTIVITY,
);
}
}
export class InsufficientCapacityError extends MatrixRTCError {
public constructor() {
super(
"The media server has no capacity left",
ErrorCode.INSUFFICIENT_CAPACITY_ERROR,
ErrorCategory.UNKNOWN,
);
}
}
export class SFURoomCreationRestrictedError extends MatrixRTCError {
public constructor() {
super(
"The media server did not let this client create the room",
ErrorCode.SFU_ERROR,
ErrorCategory.CONFIGURATION_ISSUE,
);
}
}
export class PeerConnectionTimeoutError extends MatrixRTCError {
public constructor() {
super(
"Timed out establishing the media connection",
ErrorCode.SFU_ERROR,
ErrorCategory.NETWORK_CONNECTIVITY,
);
}
}
export class LivekitConnectionError extends MatrixRTCError {
public constructor(cause: ConnectionError) {
super(
`Could not connect to the media server: ${cause.message}`,
ErrorCode.SFU_ERROR,
ErrorCategory.NETWORK_CONNECTIVITY,
{ cause },
);
}
}
@@ -0,0 +1,369 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "events";
import { ClientEvent, SyncState } from "matrix-js-sdk";
import { MembershipManagerEvent, Status } from "matrix-js-sdk/lib/matrixrtc";
import { ObservableScope } from "../reactive/ObservableScope";
import { createHomeserverConnected$ } from "./HomeserverConnected";
import { testScope, withTestScheduler } from "../utils/test";
/**
* Minimal stub of a Matrix client sufficient for our tests:
```
createHomeserverConnected$(
scope: ObservableScope,
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
matrixRTCSession: NodeStyleEventEmitter &
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
)
```
*/
class MockMatrixClient extends EventEmitter {
private syncState: SyncState;
public constructor(initial: SyncState) {
super();
this.syncState = initial;
}
public setSyncState(state: SyncState): void {
this.syncState = state;
// Matrix's Sync event in createHomeserverConnected$ expects [SyncState]
this.emit(ClientEvent.Sync, [state]);
}
public getSyncState(): SyncState {
return this.syncState;
}
}
/**
* Minimal stub of MatrixRTCSession (membership manager):
```
createHomeserverConnected$(
scope: ObservableScope,
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
matrixRTCSession: NodeStyleEventEmitter &
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
)
```
*/
class MockMatrixRTCSession extends EventEmitter {
public membershipStatus: Status;
public probablyLeft: boolean;
public constructor(props: {
membershipStatus: Status;
probablyLeft: boolean;
}) {
super();
this.membershipStatus = props.membershipStatus;
this.probablyLeft = props.probablyLeft;
}
public setMembershipStatus(status: Status): void {
this.membershipStatus = status;
this.emit(MembershipManagerEvent.StatusChanged);
}
public setProbablyLeft(flag: boolean): void {
this.probablyLeft = flag;
this.emit(MembershipManagerEvent.ProbablyLeft);
}
}
describe("createHomeserverConnected$", () => {
let scope: ObservableScope;
let client: MockMatrixClient;
let session: MockMatrixRTCSession;
beforeEach(() => {
scope = new ObservableScope();
client = new MockMatrixClient(SyncState.Error); // start disconnected
session = new MockMatrixRTCSession({
membershipStatus: Status.Disconnected,
probablyLeft: false,
});
});
afterEach(() => {
scope.end();
});
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
// easy enough to read them so I think they can stay.
// Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
it("reports syncing reason when sync state is not Syncing", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
expect(hsConnected.disconnectReason$.value).toEqual("sync");
});
it("reports membership reason when sync is Syncing but membership is not Connected", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
expect(hsConnected.disconnectReason$.value).toEqual("membership");
});
it("reports probablyLeft reason when membership transitions to Connected but ProbablyLeft is true", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// Make sync loop OK
client.setSyncState(SyncState.Syncing);
// Indicate probable leave before connection
session.setProbablyLeft(true);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
});
it("becomes null (connected) only when all three conditions are satisfied", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// 1. Sync loop connected
client.setSyncState(SyncState.Syncing);
expect(hsConnected.disconnectReason$.value).toEqual("membership"); // not yet membership connected
// 2. Membership connected
session.setMembershipStatus(Status.Connected);
expect(hsConnected.disconnectReason$.value).toEqual(null); // probablyLeft is false
});
it("returns syncing reason when sync loop leaves Syncing", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// Reach connected state
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.disconnectReason$.value).toEqual(null);
// Sync loop error => should report syncing reason
client.setSyncState(SyncState.Error);
expect(hsConnected.disconnectReason$.value).toEqual("sync");
});
it("returns membershipConnected reason when membership status becomes disconnected", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.disconnectReason$.value).toEqual(null);
session.setMembershipStatus(Status.Disconnected);
expect(hsConnected.disconnectReason$.value).toEqual("membership");
});
it("returns certainlyConnected reason when ProbablyLeft is emitted", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.disconnectReason$.value).toEqual(null);
session.setProbablyLeft(true);
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
});
it("recovers to null (connected) if ProbablyLeft becomes false again while other conditions remain true", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.disconnectReason$.value).toEqual(null);
session.setProbablyLeft(true);
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
// Simulate clearing the flag (in realistic scenario membership manager would update)
session.setProbablyLeft(false);
expect(hsConnected.disconnectReason$.value).toEqual(null);
});
it("composite sequence reflects each individual failure reason", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// Initially: sync error + membership disconnected → syncing wins (highest priority)
expect(hsConnected.disconnectReason$.value).toEqual("sync");
// Fix sync only → membershipConnected is now the blocker
client.setSyncState(SyncState.Syncing);
expect(hsConnected.disconnectReason$.value).toEqual("membership");
// Fix membership → all conditions satisfied
session.setMembershipStatus(Status.Connected);
expect(hsConnected.disconnectReason$.value).toEqual(null);
// Introduce probablyLeft → certainlyConnected
session.setProbablyLeft(true);
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
// Restore notProbablyLeft → connected again
session.setProbablyLeft(false);
expect(hsConnected.disconnectReason$.value).toEqual(null);
// Drop sync → syncing reason
client.setSyncState(SyncState.Error);
expect(hsConnected.disconnectReason$.value).toEqual("sync");
});
});
describe("createHomeserverConnected$ - disconnectReason$ reason values", () => {
let scope: ObservableScope;
let client: MockMatrixClient;
let session: MockMatrixRTCSession;
beforeEach(() => {
scope = new ObservableScope();
// Start with sync failing and membership disconnected
client = new MockMatrixClient(SyncState.Error);
session = new MockMatrixRTCSession({
membershipStatus: Status.Disconnected,
probablyLeft: false,
});
});
afterEach(() => {
scope.end();
});
it("is null when all three conditions are satisfied", () => {
const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(disconnectReason$.value).toEqual(null);
});
it("reports syncing when sync loop is not Syncing", () => {
const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
// client starts with SyncState.Error, membership also disconnected
expect(disconnectReason$.value).toEqual("sync");
});
it("reports membershipConnected when sync is fine but membership is not Connected", () => {
const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
client.setSyncState(SyncState.Syncing);
// session still Status.Disconnected
expect(disconnectReason$.value).toEqual("membership");
});
it("reports certainlyConnected when probablyLeft is true", () => {
const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
session.setProbablyLeft(true);
expect(disconnectReason$.value).toEqual("probablyLeft");
});
it("prioritises syncing over membershipConnected when both fail", () => {
const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
// Both sync (Error) and membership (Disconnected) are failing
expect(disconnectReason$.value).toEqual("sync");
});
it("updates reason as conditions change", () => {
const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
// Initially: syncing fails
expect(disconnectReason$.value).toEqual("sync");
// Fix sync → membershipConnected is now the blocker
client.setSyncState(SyncState.Syncing);
expect(disconnectReason$.value).toEqual("membership");
// Fix membership → probablyLeft makes certainlyConnected fail
session.setProbablyLeft(true);
session.setMembershipStatus(Status.Connected);
expect(disconnectReason$.value).toEqual("probablyLeft");
// Clear probablyLeft → all conditions satisfied
session.setProbablyLeft(false);
expect(disconnectReason$.value).toEqual(null);
});
});
describe("createHomeserverConnected$ - Grace Period", () => {
const GRACE_PERIOD = 5;
function marbleTest(
syncStateMarbles: string,
expectedConnectedMarbles: string,
): void {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
const syncState$ = behavior(syncStateMarbles, {
s: SyncState.Syncing,
e: SyncState.Error,
});
const client = new MockMatrixClient(syncState$.value);
schedule(syncStateMarbles, {
s: () => client.setSyncState(SyncState.Syncing),
e: () => client.setSyncState(SyncState.Error),
});
const session = new MockMatrixRTCSession({
membershipStatus: Status.Connected,
probablyLeft: false,
});
const hsConnected = createHomeserverConnected$(
testScope(),
client,
session,
GRACE_PERIOD,
);
expectObservable(hsConnected.disconnectReason$).toBe(
expectedConnectedMarbles,
{
y: null,
n: "sync",
},
);
});
}
it("respects gracePeriodMs: stays true during grace period and flips false after", () => {
// - Initial state: Everything is connected
// - Sync error occurs -> should remain connected due to grace period
// - After grace period, not connected
marbleTest("se", "y-----n");
// If the sync error takes longer to occur, it should take equally long for
// the connection state to change
marbleTest("s--e", "y-------n");
});
it("recovers immediately if sync returns during grace period", () => {
// - Initial state: Connected
// - Sync error occurs
// - Sync recovers BEFORE the grace period expires
// - Connection state remains constant
marbleTest("se--s", "y");
});
it("flips to true IMMEDIATELY even if a grace period was pending", () => {
// - Initial error: connection eventually flips to false
// - Back to Syncing -> Must be connected immediately (synchronously)
marbleTest("e-----s", "y----ny");
});
});
+131
View File
@@ -0,0 +1,131 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
MembershipManagerEvent,
Status,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
import {
fromEvent,
startWith,
map,
tap,
type Observable,
distinctUntilChanged,
switchMap,
of,
delay,
combineLatest,
} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type ObservableScope } from "../reactive/ObservableScope";
import { type Behavior } from "../reactive/Behavior";
import { defaultSessionTimings } from "../config";
import { type MatrixDisconnectReason } from "../api";
type NodeEventHandler = (...args: unknown[]) => void;
interface NodeStyleEventEmitter {
addListener(eventName: string | symbol, handler: NodeEventHandler): this;
removeListener(eventName: string | symbol, handler: NodeEventHandler): this;
}
export interface HomeserverConnected {
/** Null while the homeserver connection is healthy, the first failing link otherwise. */
disconnectReason$: Behavior<MatrixDisconnectReason | null>;
}
/**
* Behavior representing whether we consider ourselves connected to the Matrix homeserver
* for the purposes of a MatrixRTC session.
*
* `disconnectReason$` emits `null` when all conditions are satisfied, or the first failing
* reason (priority: syncing > membershipConnected > certainlyConnected):
* 1. Sync loop is not in SyncState.Syncing (after grace period) → "sync"
* 2. membershipStatus !== Status.Connected → "membership"
* 3. probablyLeft === true → "probablyLeft"
*
* @param scope - The observable scope for lifecycle management.
* @param client - The Matrix client to monitor sync state.
* @param matrixRTCSession - The RTC session to monitor membership.
* @param gracePeriodMs - Grace period in milliseconds to wait before reporting sync disconnect.
* If not provided, uses the config value (default 10000ms).
*/
export function createHomeserverConnected$(
scope: ObservableScope,
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
matrixRTCSession: NodeStyleEventEmitter &
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
gracePeriodMs?: number,
): HomeserverConnected {
const logger = rootLogger.getChild("[HomeserverConnected]");
// Get grace period from parameter or config (default 10000ms)
const graceMs =
gracePeriodMs ?? defaultSessionTimings.syncDisconnectGracePeriodMs;
const syncing$ = (
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
).pipe(
startWith([client.getSyncState()]),
map(([state]) => state === SyncState.Syncing),
distinctUntilChanged(),
switchMap((isSyncing) => {
if (isSyncing || graceMs <= 0) {
return of(isSyncing);
}
return of(false).pipe(delay(graceMs), startWith(true));
}),
distinctUntilChanged(),
);
const rtsSession$ = scope.behavior<Status>(
fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
),
matrixRTCSession.membershipStatus ?? Status.Unknown,
);
const membershipConnected$ = rtsSession$.pipe(
map((status) => status === Status.Connected),
);
// This is basically notProbablyLeft$
//
// probablyLeft is computed by a local timer that mimics the server delayed event.
// If we locally predict our server event timed out. We consider ourselves as probablyLeft
// even though we might not yet have received the delayed event leave.
//
// If that is not the case we certainly still have a valid membership on the matrix network
// independet if the sync currently works.
const certainlyConnected$ = fromEvent(
matrixRTCSession,
MembershipManagerEvent.ProbablyLeft,
).pipe(
startWith(null),
map(() => matrixRTCSession.probablyLeft !== true),
);
const disconnectReason$ = scope.behavior(
combineLatest([syncing$, membershipConnected$, certainlyConnected$]).pipe(
map(([syncing, membership, certainly]): MatrixDisconnectReason | null => {
if (!syncing) return "sync";
if (!membership) return "membership";
if (!certainly) return "probablyLeft";
return null;
}),
tap((reason) => {
logger.info(`Homeserver connected update: ${reason ?? "connected"}`);
}),
),
);
return { disconnectReason$ };
}
@@ -0,0 +1,41 @@
/*
Copyright 2025 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 { MatrixRTCSessionEvent } from "matrix-js-sdk/lib/matrixrtc";
import { EventEmitter } from "events";
import { createKeyRotationSuppressed$ } from "./JsRtcSessionBehaviors";
import { testScope } from "../utils/test";
describe("JsRtcSessionBehaviors", () => {
describe("createKeyRotationSuppressed$", () => {
it("emits initial value from isKeyRotationSuppressed and updates when KeyRotationSuppressedChanged event is emitted", () => {
const scope = testScope();
const emitter = new EventEmitter();
const mockSession = Object.assign(emitter, {
on: vi.fn(),
off: vi.fn(),
isKeyRotationSuppressed: false,
});
const keyRotationSuppressed$ = createKeyRotationSuppressed$(
scope,
mockSession as any,
);
expect(keyRotationSuppressed$.value).toBe(false);
emitter.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, true);
expect(keyRotationSuppressed$.value).toBe(true);
emitter.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, false);
expect(keyRotationSuppressed$.value).toBe(false);
});
});
});
@@ -0,0 +1,53 @@
/*
Copyright 2025 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 CallMembership,
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc";
import { fromEvent } from "rxjs";
import {
Epoch,
trackEpoch,
type ObservableScope,
} from "../reactive/ObservableScope";
import { type Behavior } from "../reactive/Behavior";
export const createMemberships$ = (
scope: ObservableScope,
matrixRTCSession: MatrixRTCSession,
): Behavior<Epoch<CallMembership[]>> => {
return scope.behavior(
fromEvent(
matrixRTCSession,
MatrixRTCSessionEvent.MembershipsChanged,
(_, memberships: CallMembership[]) => memberships,
).pipe(trackEpoch()),
new Epoch(matrixRTCSession.memberships),
);
};
/**
* Whether the session has grown large enough that MatrixRTC has stopped rotating the media
* encryption key. While this is true the key in use is still shared with new joiners, but no new
* key is generated when someone joins or leaves.
*/
export const createKeyRotationSuppressed$ = (
scope: ObservableScope,
matrixRTCSession: MatrixRTCSession,
): Behavior<boolean> => {
return scope.behavior(
fromEvent(
matrixRTCSession,
MatrixRTCSessionEvent.KeyRotationSuppressedChanged,
(suppressed: boolean) => suppressed,
),
matrixRTCSession.isKeyRotationSuppressed,
);
};
+168
View File
@@ -0,0 +1,168 @@
/*
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 { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { BehaviorSubject, type Observable, Subject, throwError } from "rxjs";
import { defaultSessionTimings } from "../config";
import { MatrixRTCError } from "../errors";
import { type HomeserverConnected } from "./HomeserverConnected";
import {
createLocalMembership$,
type LocalMembership,
type PreparedTransport,
} from "./LocalMembership";
import { flushPromises, testScope } from "../utils/test";
const transport: Transport = { type: "livekit", livekit_service_url: "u" };
describe("createLocalMembership$", () => {
it("waits for the transport, joins, and leaves on leave()", async () => {
const { membership, homeserver, prepared$ } = setup();
expect(membership.state$.value).toEqual({ kind: "waitingForTransport" });
homeserver.disconnectReason$.next("membership");
prepared$.next({ transport, canDelegateDelayedLeave: false });
expect(membership.state$.value).toEqual({ kind: "joining" });
homeserver.disconnectReason$.next(null);
expect(membership.state$.value).toEqual({ kind: "joined" });
membership.leave();
await flushPromises();
expect(membership.state$.value).toEqual({ kind: "left" });
});
it("reconnects only after it was joined once", () => {
const { membership, homeserver, prepared$ } = setup();
prepared$.next({ transport, canDelegateDelayedLeave: false });
expect(membership.state$.value).toEqual({ kind: "joined" });
homeserver.disconnectReason$.next("sync");
expect(membership.state$.value).toEqual({
kind: "reconnecting",
reason: "sync",
});
homeserver.disconnectReason$.next("probablyLeft");
expect(membership.state$.value).toEqual({
kind: "reconnecting",
reason: "probablyLeft",
});
homeserver.disconnectReason$.next(null);
expect(membership.state$.value).toEqual({ kind: "joined" });
});
it("joins with the delegated timings and hands over each delay id where the backend can", async () => {
const { prepared$, delayId$, joinMatrixRTC, delegate } = setup();
prepared$.next({ transport, canDelegateDelayedLeave: true });
await flushPromises();
expect(joinMatrixRTC).toHaveBeenCalledWith(
transport,
defaultSessionTimings.delegatedDelayedLeave,
);
delayId$.next("delay-1");
await flushPromises();
expect(delegate).toHaveBeenCalledWith("delay-1");
delayId$.next("delay-2");
await flushPromises();
expect(delegate).toHaveBeenLastCalledWith("delay-2");
});
it("never hands the leave over where the backend cannot take it", async () => {
const { prepared$, delayId$, joinMatrixRTC, delegate } = setup();
prepared$.next({ transport, canDelegateDelayedLeave: false });
delayId$.next("delay-1");
await flushPromises();
expect(joinMatrixRTC).toHaveBeenCalledWith(
transport,
defaultSessionTimings.delayedLeave,
);
expect(delegate).not.toHaveBeenCalled();
});
it("reports a transport that could not be prepared as failed", () => {
const error = new MatrixRTCError("no token");
const { membership } = setup(throwError(() => error));
expect(membership.state$.value).toEqual({ kind: "failed", error });
});
it("reports the membership manager giving up as failed, and stays failed", () => {
const { membership, homeserver, prepared$, membershipManagerError$ } =
setup();
prepared$.next({ transport, canDelegateDelayedLeave: false });
membershipManagerError$.next(new Error("gave up"));
const state = membership.state$.value;
expect(state.kind).toBe("failed");
homeserver.disconnectReason$.next("sync");
homeserver.disconnectReason$.next(null);
expect(membership.state$.value).toBe(state);
});
it("is left after a failure once leave() is called", () => {
const { membership } = setup(throwError(() => new MatrixRTCError("x")));
membership.leave();
expect(membership.state$.value).toEqual({ kind: "left" });
});
});
function setup(preparedTransportWithErrors$?: Observable<PreparedTransport>): {
membership: LocalMembership;
homeserver: FakeHomeserverConnected;
prepared$: Subject<PreparedTransport>;
delayId$: BehaviorSubject<string | null>;
membershipManagerError$: Subject<unknown>;
joinMatrixRTC: ReturnType<typeof vi.fn>;
delegate: ReturnType<typeof vi.fn>;
} {
const prepared$ = new Subject<PreparedTransport>();
const homeserver = fakeHomeserverConnected();
const delayId$ = new BehaviorSubject<string | null>(null);
const membershipManagerError$ = new Subject<unknown>();
const joinMatrixRTC = vi.fn();
const delegate = vi.fn(async () => Promise.resolve());
const membership = createLocalMembership$({
scope: testScope(),
delegateDelayedLeave: delegate,
preparedTransport$: preparedTransportWithErrors$ ?? prepared$,
homeserverConnected: homeserver,
joinMatrixRTC,
membershipManagerError$,
matrixRTCSession: {
leave: vi.fn(async () => Promise.resolve(true)),
},
delayId$,
timings: defaultSessionTimings,
logger,
});
return {
membership,
homeserver,
prepared$,
delayId$,
membershipManagerError$,
joinMatrixRTC,
delegate,
};
}
type FakeHomeserverConnected = {
disconnectReason$: BehaviorSubject<
HomeserverConnected["disconnectReason$"]["value"]
>;
};
function fakeHomeserverConnected(): FakeHomeserverConnected {
return {
disconnectReason$: new BehaviorSubject<
HomeserverConnected["disconnectReason$"]["value"]
>(null),
};
}
+210
View File
@@ -0,0 +1,210 @@
/*
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 {
type MatrixRTCSession as JsSdkRTCSession,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import {
BehaviorSubject,
catchError,
combineLatest,
distinctUntilChanged,
NEVER,
type Observable,
scan,
} from "rxjs";
import { type Behavior } from "../reactive/Behavior";
import { type ObservableScope } from "../reactive/ObservableScope";
import { type DelayedLeaveTimings, type SessionTimings } from "../config";
import { type MatrixDisconnectReason } from "../api";
import {
type MatrixRTCError,
MembershipManagerError,
toMatrixRTCError,
} from "../errors";
import {
type MediaBackend,
type TransportCapabilities,
} from "../media-backend/api";
import { type HomeserverConnected } from "./HomeserverConnected";
export type MembershipState =
/** Asking the homeserver for a transport, or preparing it. */
| { kind: "waitingForTransport" }
/** Transport known; sending the membership for the first time. */
| { kind: "joining" }
/** Syncing, membership confirmed by the server, delayed leave still being refreshed. */
| { kind: "joined" }
/** Was joined and one homeserver link is down; the membership manager is retrying. */
| { kind: "reconnecting"; reason: MatrixDisconnectReason }
/** A transport, join or membership manager error ended the membership. Terminal unless `left`. */
| { kind: "failed"; error: MatrixRTCError }
/** `leave()` was called. Terminal. */
| { kind: "left" };
/** The local transport, found and prepared, with what the join needs to know about it. */
export interface PreparedTransport extends TransportCapabilities {
transport: Transport;
}
interface Props {
scope: ObservableScope;
delegateDelayedLeave: MediaBackend["delegateDelayedLeave"];
preparedTransport$: Observable<PreparedTransport>;
homeserverConnected: HomeserverConnected;
joinMatrixRTC: (
transport: Transport,
delayedLeave: DelayedLeaveTimings,
) => void;
/** The membership manager giving up on keeping the membership alive. */
membershipManagerError$: Observable<unknown>;
matrixRTCSession: Pick<JsSdkRTCSession, "leave">;
/** The id of the delayed leave event, for the backend to take over. */
delayId$: Behavior<string | null>;
timings: SessionTimings;
logger: Logger;
}
export interface LocalMembership {
/** Sends the leave. Final: there is no joining again. */
leave: () => void;
state$: Behavior<MembershipState>;
}
/**
* The Matrix side of the local member: waits for the transport, enters the
* MatrixRTC session, keeps the delayed leave delegated, and leaves on
* `leave()`. Knows nothing about the media.
*/
export function createLocalMembership$({
scope,
delegateDelayedLeave,
preparedTransport$: preparedTransportWithErrors$,
homeserverConnected,
joinMatrixRTC,
membershipManagerError$,
matrixRTCSession,
delayId$,
timings,
logger: parentLogger,
}: Props): LocalMembership {
const logger = parentLogger.getChild("[LocalMembership]");
const transportError$ = new BehaviorSubject<MatrixRTCError | null>(null);
const transport$ = scope.behavior<PreparedTransport | null>(
preparedTransportWithErrors$.pipe(
catchError((e: unknown) => {
transportError$.next(toMatrixRTCError(e));
return NEVER;
}),
),
null,
);
const joined$ = new BehaviorSubject(true);
const matrixError$ = new BehaviorSubject<MatrixRTCError | null>(null);
scope.reconcile(
scope.behavior(combineLatest([transport$, joined$])),
async ([prepared, joined]) => {
if (prepared === null || !joined) return;
try {
joinMatrixRTC(
prepared.transport,
prepared.canDelegateDelayedLeave
? timings.delegatedDelayedLeave
: timings.delayedLeave,
);
} catch (e) {
logger.error("Failed to enter the session", e);
if (matrixError$.value === null) matrixError$.next(toMatrixRTCError(e));
}
return Promise.resolve(async (): Promise<void> => {
try {
await matrixRTCSession.leave(1000);
} catch (e) {
logger.error("Failed to leave the session", e);
}
});
},
);
scope.reconcile(
scope.behavior(combineLatest([transport$, delayId$])),
async ([prepared, delayId]) => {
if (!prepared?.canDelegateDelayedLeave || delayId === null) return;
try {
await delegateDelayedLeave(delayId);
} catch (e) {
logger.error("Failed to delegate the leave", e);
}
},
);
membershipManagerError$.pipe(scope.bind()).subscribe((e) => {
logger.error("The membership manager stopped", e);
if (matrixError$.value === null)
matrixError$.next(
new MembershipManagerError(
e instanceof Error ? e : new Error(String(e)),
),
);
});
const state$ = scope.behavior<MembershipState>(
combineLatest([
joined$,
transportError$,
matrixError$,
transport$,
homeserverConnected.disconnectReason$,
]).pipe(
scan(
(previous, [joined, transportError, matrixError, transport, reason]) =>
membershipState(previous, {
joined,
error: transportError ?? matrixError,
hasTransport: transport !== null,
reason,
}),
{ kind: "waitingForTransport" } as MembershipState,
),
distinctUntilChanged(deepCompare),
),
);
return {
leave: () => joined$.next(false),
state$,
};
}
interface MembershipInputs {
joined: boolean;
error: MatrixRTCError | null;
hasTransport: boolean;
reason: MatrixDisconnectReason | null;
}
/** The next state, in priority order; `left` and `failed` are absorbing. */
function membershipState(
previous: MembershipState,
{ joined, error, hasTransport, reason }: MembershipInputs,
): MembershipState {
if (!joined) return { kind: "left" };
if (previous.kind === "failed") return previous;
if (error !== null) return { kind: "failed", error };
if (!hasTransport) return { kind: "waitingForTransport" };
if (reason === null) return { kind: "joined" };
const wasJoined =
previous.kind === "joined" || previous.kind === "reconnecting";
return wasJoined ? { kind: "reconnecting", reason } : { kind: "joining" };
}
+78
View File
@@ -0,0 +1,78 @@
/*
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 { logger } from "matrix-js-sdk/lib/logger";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { describe, expect, it, vi } from "vitest";
import { MatrixRTCTransportMissingError } from "../errors";
import { discoverLocalTransport } from "./LocalTransport";
const homeserverTransport: Transport = {
type: "livekit",
livekit_service_url: "https://homeserver.example.org",
};
const hostTransport: Transport = {
type: "livekit",
livekit_service_url: "https://host.example.org",
};
function client(
advertised: Transport[],
): Parameters<typeof discoverLocalTransport>[0] {
return {
getDomain: () => "example.org",
_unstable_getRTCTransports: vi.fn().mockResolvedValue(advertised),
};
}
describe("discoverLocalTransport", () => {
it("takes the transport the host names without asking the homeserver", async () => {
const c = client([homeserverTransport]);
await expect(
discoverLocalTransport(
c,
"livekit",
{ transport: hostTransport },
logger,
),
).resolves.toBe(hostTransport);
expect(c._unstable_getRTCTransports).not.toHaveBeenCalled();
});
it("prefers the homeserver's transport over the host's fallback", async () => {
await expect(
discoverLocalTransport(
client([homeserverTransport]),
"livekit",
{ fallbackTransport: hostTransport },
logger,
),
).resolves.toEqual(homeserverTransport);
});
it("falls back to the host's transport where the homeserver advertises none of the type", async () => {
await expect(
discoverLocalTransport(
client([{ type: "not_livekit" }]),
"livekit",
{ fallbackTransport: hostTransport },
logger,
),
).resolves.toEqual(hostTransport);
});
it("fails naming the homeserver where there is no transport at all", async () => {
const promise = discoverLocalTransport(client([]), "livekit", {}, logger);
await expect(promise).rejects.toBeInstanceOf(
MatrixRTCTransportMissingError,
);
await expect(promise).rejects.toHaveProperty("domain", "example.org");
});
});
+40
View File
@@ -0,0 +1,40 @@
/*
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 MatrixClient } from "matrix-js-sdk";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { type RTCParticipationOptions } from "../api";
import { MatrixRTCTransportMissingError } from "../errors";
import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
/**
* The transport the local member advertises: the one the host names, else the
* homeserver's first of the type the backend serves, else the host's fallback.
*/
export async function discoverLocalTransport(
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
transportType: string,
{
transport: override,
fallbackTransport,
}: Pick<RTCParticipationOptions, "transport" | "fallbackTransport">,
logger: Logger,
): Promise<Transport> {
const transport =
override ??
(await new TransportAutoDiscovery({
client,
transportType,
fallbackTransport,
logger,
}).discoverPreferredTransport());
if (transport === null)
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
return transport;
}
@@ -0,0 +1,632 @@
/*
Copyright 2025 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, vi } from "vitest";
import {
type MatrixEvent,
type RoomMember,
type RoomState,
RoomStateEvent,
} from "matrix-js-sdk";
import EventEmitter from "events";
import { it } from "vitest";
import { ObservableScope } from "../reactive/ObservableScope";
import type { Room as MatrixRoom } from "matrix-js-sdk/lib/models/room";
import {
mockRtcMembership,
mockMatrixRoomMember,
withTestScheduler,
} from "../utils/test";
import {
createMatrixMemberMetadata$,
createRoomMembers$,
} from "./MatrixMemberMetadata.ts";
let testScope: ObservableScope;
let mockMatrixRoom: MatrixRoom;
describe("MatrixMemberMetadata", () => {
/*
* To be populated in the test setup.
* Maps userId to a partial/mock RoomMember object.
*/
let fakeMembersMap: Map<string, Partial<RoomMember>>;
beforeEach(() => {
testScope = new ObservableScope();
fakeMembersMap = new Map<string, Partial<RoomMember>>();
const roomEmitter = new EventEmitter();
mockMatrixRoom = {
on: roomEmitter.on.bind(roomEmitter),
off: roomEmitter.off.bind(roomEmitter),
emit: roomEmitter.emit.bind(roomEmitter),
// addListener: roomEmitter.addListener.bind(roomEmitter),
// removeListener: roomEmitter.removeListener.bind(roomEmitter),
getMember: vi.fn().mockImplementation((userId: string) => {
const member = fakeMembersMap.get(userId);
if (member) {
return member as RoomMember;
}
return null;
}),
getMembers: vi.fn().mockImplementation(() => {
const members = Array.from(fakeMembersMap.values());
return members;
}),
getMembersWithMembership: vi.fn().mockImplementation(() => {
const members = Array.from(fakeMembersMap.values());
return members;
}),
} as unknown as MatrixRoom;
});
function fakeMemberWith(data: Partial<RoomMember>): void {
const userId = data.userId || "@alice:example.com";
const member: Partial<RoomMember> = {
userId: userId,
rawDisplayName: data.rawDisplayName ?? userId,
getMxcAvatarUrl:
data.getMxcAvatarUrl ||
vi.fn().mockImplementation(() => {
return `mxc://example.com/${userId}`;
}),
...data,
} as unknown as RoomMember;
fakeMembersMap.set(userId, member);
}
afterEach(() => {
fakeMembersMap.clear();
});
describe("displayname", () => {
function updateDisplayName(
userId: `@${string}:${string}`,
newDisplayName: string,
): void {
const member = fakeMembersMap.get(userId);
if (member) {
member.rawDisplayName = newDisplayName;
// Emit the event to notify listeners
mockMatrixRoom.emit(
RoomStateEvent.Members,
{} as unknown as MatrixEvent,
{} as unknown as RoomState,
member as RoomMember,
);
} else {
throw new Error(`No member found with userId: ${userId}`);
}
}
it("should show our own user if present in rtc session and room", () => {
withTestScheduler(({ scope, behavior, expectObservable }) => {
fakeMemberWith({
userId: "@local:example.com",
rawDisplayName: "it's a me",
});
const memberships$ = behavior("a", {
a: [mockRtcMembership("@local:example.com", "DEVICE1")],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
const dn$ = metadataStore.createDisplayNameBehavior$(
scope,
"@local:example.com",
);
expectObservable(dn$).toBe("a", {
a: "it's a me",
});
expectObservable(metadataStore.displaynameMap$).toBe("a", {
a: new Map<string, string>([["@local:example.com", "it's a me"]]),
});
});
});
function setUpBasicRoom(): void {
fakeMemberWith({
userId: "@local:example.com",
rawDisplayName: "it's a me",
});
fakeMemberWith({ userId: "@alice:example.com", rawDisplayName: "Alice" });
fakeMemberWith({ userId: "@bob:example.com", rawDisplayName: "Bob" });
fakeMemberWith({ userId: "@carl:example.com", rawDisplayName: "Carl" });
fakeMemberWith({ userId: "@evil:example.com", rawDisplayName: "Carl" });
fakeMemberWith({ userId: "@bob:foo.bar", rawDisplayName: "Bob" });
fakeMemberWith({ userId: "@no-name:foo.bar" });
}
it("should get displayName for users", () => {
setUpBasicRoom();
withTestScheduler(({ scope, behavior, expectObservable }) => {
const memberships$ = behavior("a", {
a: [
mockRtcMembership("@alice:example.com", "DEVICE1"),
mockRtcMembership("@bob:example.com", "DEVICE1"),
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
const aliceDispName$ = metadataStore.createDisplayNameBehavior$(
scope,
"@alice:example.com",
);
expectObservable(aliceDispName$).toBe("a", {
a: "Alice",
});
expectObservable(metadataStore.displaynameMap$).toBe("a", {
a: new Map<string, string>([
["@alice:example.com", "Alice"],
["@bob:example.com", "Bob"],
]),
});
});
});
it("should use userId if no display name", () => {
withTestScheduler(({ behavior, expectObservable }) => {
setUpBasicRoom();
const memberships$ = behavior("a", {
a: [mockRtcMembership("@no-name:foo.bar", "D000")],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
expectObservable(metadataStore.displaynameMap$).toBe("a", {
a: new Map<string, string>([
["@no-name:foo.bar", "@no-name:foo.bar"],
]),
});
});
});
it("should disambiguate users with same display name", () => {
withTestScheduler(({ behavior, expectObservable }) => {
setUpBasicRoom();
const memberships$ = behavior("a", {
a: [
mockRtcMembership("@bob:example.com", "DEVICE1"),
mockRtcMembership("@bob:example.com", "DEVICE2"),
mockRtcMembership("@bob:foo.bar", "BOB000"),
mockRtcMembership("@carl:example.com", "C000"),
mockRtcMembership("@evil:example.com", "E000"),
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
expectObservable(metadataStore.displaynameMap$).toBe("a", {
a: new Map<string, string>([
// ["@local:example.com", "it's a me"],
["@bob:example.com", "Bob (@bob:example.com)"],
["@bob:example.com", "Bob (@bob:example.com)"],
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
["@carl:example.com", "Carl (@carl:example.com)"],
["@evil:example.com", "Carl (@evil:example.com)"],
]),
});
});
});
it("should start to disambiguate reactivly when needed", () => {
withTestScheduler(({ behavior, expectObservable }) => {
setUpBasicRoom();
const memberships$ = behavior("ab", {
a: [mockRtcMembership("@bob:example.com", "DEVICE1")],
b: [
mockRtcMembership("@bob:example.com", "DEVICE1"),
mockRtcMembership("@bob:foo.bar", "BOB000"),
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
a: new Map<string, string>([["@bob:example.com", "Bob"]]),
b: new Map<string, string>([
["@bob:example.com", "Bob (@bob:example.com)"],
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
]),
});
});
});
it("should keep disambiguated name when other leave", () => {
withTestScheduler(({ behavior, expectObservable }) => {
setUpBasicRoom();
const memberships$ = behavior("ab", {
a: [
mockRtcMembership("@bob:example.com", "DEVICE1"),
mockRtcMembership("@bob:foo.bar", "BOB000"),
],
b: [mockRtcMembership("@bob:example.com", "DEVICE1")],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
a: new Map<string, string>([
["@bob:example.com", "Bob (@bob:example.com)"],
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
]),
b: new Map<string, string>([
["@bob:example.com", "Bob (@bob:example.com)"],
]),
});
});
});
it("should disambiguate on name change", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
setUpBasicRoom();
const memberships$ = behavior("a", {
a: [
mockRtcMembership("@bob:example.com", "B000"),
mockRtcMembership("@carl:example.com", "C000"),
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
schedule("-a", {
a: () => {
updateDisplayName("@carl:example.com", "Bob");
},
});
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
a: new Map<string, string>([
["@bob:example.com", "Bob"],
["@carl:example.com", "Carl"],
]),
b: new Map<string, string>([
["@bob:example.com", "Bob (@bob:example.com)"],
["@carl:example.com", "Bob (@carl:example.com)"],
]),
});
});
});
it("should track individual member id with createDisplayNameBehavior", () => {
withTestScheduler(({ scope, behavior, schedule, expectObservable }) => {
setUpBasicRoom();
const BOB = "@bob:example.com";
const CARL = "@carl:example.com";
// for this test we build a mock environment that does all possible changes:
// - memberships join/leave
// - room join/leave
// - disambiguate
const memberships$ = behavior("ab-d", {
a: [mockRtcMembership(CARL, "C000")],
b: [
mockRtcMembership(CARL, "C000"),
// bob joins
mockRtcMembership(BOB, "B000"),
],
// c carl gets renamed to BOB
d: [
// carl leaves
mockRtcMembership(BOB, "B000"),
],
});
schedule("--a-", {
a: () => {
// carl renames
updateDisplayName(CARL, "Bob");
},
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
const bob$ = metadataStore.createDisplayNameBehavior$(scope, BOB);
const carl$ = metadataStore.createDisplayNameBehavior$(scope, CARL);
expectObservable(bob$).toBe("abc-", {
a: undefined,
b: "Bob",
c: "Bob (@bob:example.com)",
// bob stays disambiguate even though carl left
// d: "Bob (@bob:example.com)",
});
expectObservable(carl$).toBe("a-cd", {
a: "Carl",
// b: "Carl",
// carl gets renamed and disambiguate
c: "Bob (@carl:example.com)",
d: undefined,
});
});
});
it("should disambiguate users with invisible characters", () => {
withTestScheduler(({ scope, behavior, expectObservable }) => {
const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");
const bobZeroWidthSpaceRtcMember = mockRtcMembership(
"@bob2:example.org",
"BBBB",
);
const bob = mockMatrixRoomMember(bobRtcMember, {
rawDisplayName: "Bob",
});
const bobZeroWidthSpace = mockMatrixRoomMember(
bobZeroWidthSpaceRtcMember,
{
rawDisplayName: "Bo\u200bb",
},
);
fakeMemberWith(bob);
fakeMemberWith(bobZeroWidthSpace);
fakeMemberWith({ userId: "@carol:example.org" });
const memberships$ = behavior("ab", {
a: [mockRtcMembership("@carol:example.org", "1111"), bobRtcMember],
b: [
mockRtcMembership("@carol:example.org", "1111"),
bobRtcMember,
bobZeroWidthSpaceRtcMember,
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
const bob$ = metadataStore.createDisplayNameBehavior$(
scope,
"@bob:example.org",
);
const bob2$ = metadataStore.createDisplayNameBehavior$(
scope,
"@bob2:example.org",
);
const carol$ = metadataStore.createDisplayNameBehavior$(
scope,
"@carol:example.org",
);
expectObservable(bob$).toBe("ab", {
a: "Bob",
b: "Bob (@bob:example.org)",
});
expectObservable(bob2$).toBe("ab", {
a: undefined,
b: "Bo\u200bb (@bob2:example.org)",
});
expectObservable(carol$).toBe("a-", {
a: "@carol:example.org",
});
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
// Carol has no displayname - So userId is used.
a: new Map([
["@carol:example.org", "@carol:example.org"],
["@bob:example.org", "Bob"],
]),
// Other Bob joins, and should handle zero width hacks.
b: new Map([
["@carol:example.org", "@carol:example.org"],
[bobRtcMember.userId, `Bob (@bob:example.org)`],
[
bobZeroWidthSpace.userId,
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
],
]),
});
});
});
it("should strip RTL characters from displayname", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
const daveRTLRtcMember = mockRtcMembership(
"@dave2:example.org",
"DDDD",
);
const dave = mockMatrixRoomMember(daveRtcMember, {
rawDisplayName: "Dave",
});
const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
rawDisplayName: "\u202eevaD",
});
fakeMemberWith({ userId: "@carol:example.org" });
fakeMemberWith(daveRTL);
fakeMemberWith(dave);
const memberships$ = behavior("ab", {
a: [mockRtcMembership("@carol:example.org", "DDDD")],
b: [
mockRtcMembership("@carol:example.org", "DDDD"),
daveRtcMember,
daveRTLRtcMember,
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
// Carol has no displayname - So userId is used.
a: new Map([["@carol:example.org", "@carol:example.org"]]),
// Both Dave's join. Since after stripping
b: new Map([
["@carol:example.org", "@carol:example.org"],
// Not disambiguated
["@dave:example.org", "Dave"],
// This one is, since it's using RTL.
["@dave2:example.org", "evaD (@dave2:example.org)"],
]),
});
});
});
});
describe("avatarUrl", () => {
function updateAvatarUrl(
userId: `@${string}:${string}`,
avatarUrl: string,
): void {
const member = fakeMembersMap.get(userId);
if (member) {
member.getMxcAvatarUrl = vi.fn().mockReturnValue(avatarUrl);
// Emit the event to notify listeners
mockMatrixRoom.emit(
RoomStateEvent.Members,
{} as unknown as MatrixEvent,
{} as unknown as RoomState,
member as RoomMember,
);
} else {
throw new Error(`No member found with userId: ${userId}`);
}
}
it("should use avatar url from room members", () => {
withTestScheduler(({ scope, behavior, expectObservable }) => {
fakeMemberWith({
userId: "@local:example.com",
});
fakeMemberWith({
userId: "@alice:example.com",
getMxcAvatarUrl: vi.fn().mockReturnValue("mxc://custom.url/avatar"),
});
const memberships$ = behavior("a", {
a: [
mockRtcMembership("@local:example.com", "DEVICE1"),
mockRtcMembership("@alice:example.com", "DEVICE1"),
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
const local$ = metadataStore.createAvatarUrlBehavior$(
scope,
"@local:example.com",
);
const alice$ = metadataStore.createAvatarUrlBehavior$(
scope,
"@alice:example.com",
);
expectObservable(local$).toBe("a", {
a: "mxc://example.com/@local:example.com",
});
expectObservable(alice$).toBe("a", {
a: "mxc://custom.url/avatar",
});
expectObservable(metadataStore.avatarMap$).toBe("a", {
a: new Map<string, string>([
["@local:example.com", "mxc://example.com/@local:example.com"],
["@alice:example.com", "mxc://custom.url/avatar"],
]),
});
});
});
it("should update on avatar change and user join/leave", () => {
withTestScheduler(({ scope, behavior, schedule, expectObservable }) => {
fakeMemberWith({ userId: "@carl:example.com" });
fakeMemberWith({ userId: "@bob:example.com" });
const memberships$ = behavior("ab-d", {
a: [mockRtcMembership("@bob:example.com", "B000")],
b: [
mockRtcMembership("@bob:example.com", "B000"),
mockRtcMembership("@carl:example.com", "C000"),
],
d: [mockRtcMembership("@carl:example.com", "C000")],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
schedule("--c-", {
c: () => {
updateAvatarUrl(
"@carl:example.com",
"mxc://updated.me/updatedAvatar",
);
},
});
const bob$ = metadataStore.createAvatarUrlBehavior$(
scope,
"@bob:example.com",
);
const carl$ = metadataStore.createAvatarUrlBehavior$(
scope,
"@carl:example.com",
);
expectObservable(bob$).toBe("a---", {
a: "mxc://example.com/@bob:example.com",
});
expectObservable(carl$).toBe("a-c-", {
a: "mxc://example.com/@carl:example.com",
c: "mxc://updated.me/updatedAvatar",
});
expectObservable(metadataStore.avatarMap$).toBe("a-c-", {
a: new Map<string, string>([
["@bob:example.com", "mxc://example.com/@bob:example.com"],
["@carl:example.com", "mxc://example.com/@carl:example.com"],
]),
// expect an update once we update the avatar URL
c: new Map<string, string>([
["@bob:example.com", "mxc://example.com/@bob:example.com"],
["@carl:example.com", "mxc://updated.me/updatedAvatar"],
]),
});
});
});
});
});
+157
View File
@@ -0,0 +1,157 @@
/*
Copyright 2025 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 RoomMember, RoomStateEvent } from "matrix-js-sdk";
import { combineLatest, fromEvent, map } from "rxjs";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
KnownMembership,
type Room as MatrixRoom,
} from "matrix-js-sdk/lib/matrix";
// eslint-disable-next-line rxjs/no-internal
import { type ObservableScope } from "../reactive/ObservableScope";
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
import { type Behavior } from "../reactive/Behavior";
export type RoomMemberMap = Map<
string,
Pick<RoomMember, "userId" | "getMxcAvatarUrl" | "rawDisplayName">
>;
export function roomToMembersMap(matrixRoom: MatrixRoom): RoomMemberMap {
const members = matrixRoom
.getMembersWithMembership(KnownMembership.Join)
.concat(matrixRoom.getMembersWithMembership(KnownMembership.Invite));
return members.reduce((acc, member) => {
acc.set(member.userId, {
userId: member.userId,
getMxcAvatarUrl: member.getMxcAvatarUrl.bind(member),
rawDisplayName: member.rawDisplayName,
});
return acc;
}, new Map());
}
export function createRoomMembers$(
scope: ObservableScope,
matrixRoom: MatrixRoom,
): Behavior<RoomMemberMap> {
return scope.behavior(
fromEvent(matrixRoom, RoomStateEvent.Members).pipe(
map(() => roomToMembersMap(matrixRoom)),
),
roomToMembersMap(matrixRoom),
);
}
/**
* Displayname for each member of the call. This will disambiguate
* any displayname that clashes with another member. Only members
* joined to the call are considered here.
*
* @returns Map<userId, displayname> uses the Matrix user ID as the key.
*/
// don't do this work more times than we need to. This is achieved by converting to a behavior:
export const memberDisplaynames$ = (
scope: ObservableScope,
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
roomMembers$: Behavior<RoomMemberMap>,
): Behavior<Map<string, string>> => {
const logger = rootLogger.getChild("[MatrixMemberMetadata]");
// This map tracks userIds that at some point needed disambiguation.
// This is a memory leak bound to the number of participants.
// A call application will always increase the memory if there have been more members in a call.
// Its capped by room member participants.
const shouldDisambiguateTrackerMap = new Set<string>();
return scope.behavior(
combineLatest([
// Handle call membership changes
memberships$,
// Additionally handle display name changes (implicitly reacting to them)
roomMembers$,
// TODO: do we need: pauseWhen(this.pretendToBeDisconnected$),
]).pipe(
map(([memberships, roomMembers]) => {
const displaynameMap = new Map<string, string>();
// We only consider RTC members for disambiguation as they are the only visible members.
for (const rtcMember of memberships) {
const member = roomMembers.get(rtcMember.userId);
if (!member) {
logger.error(`Could not find member for user ${rtcMember.userId}`);
continue;
}
const disambiguateComputed = shouldDisambiguate(
member,
memberships,
roomMembers,
);
const disambiguate =
shouldDisambiguateTrackerMap.has(rtcMember.userId) ||
disambiguateComputed;
if (disambiguate) shouldDisambiguateTrackerMap.add(rtcMember.userId);
displaynameMap.set(
rtcMember.userId,
calculateDisplayName(member, disambiguate),
);
}
return displaynameMap;
}),
),
);
};
export const createMatrixMemberMetadata$ = (
scope: ObservableScope,
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
roomMembers$: Behavior<RoomMemberMap>,
): {
createDisplayNameBehavior$: (
scope: ObservableScope,
userId: string,
) => Behavior<string | undefined>;
createAvatarUrlBehavior$: (
scope: ObservableScope,
userId: string,
) => Behavior<string | undefined>;
displaynameMap$: Behavior<Map<string, string>>;
avatarMap$: Behavior<Map<string, string | undefined>>;
} => {
const displaynameMap$ = memberDisplaynames$(
scope,
memberships$,
roomMembers$,
);
const avatarMap$ = scope.behavior(
roomMembers$.pipe(
map((roomMembers) =>
Array.from(roomMembers.keys()).reduce((acc, key) => {
acc.set(key, roomMembers.get(key)?.getMxcAvatarUrl());
return acc;
}, new Map<string, string | undefined>()),
),
),
);
return {
createDisplayNameBehavior$: (scope: ObservableScope, userId: string) =>
scope.behavior(
displaynameMap$.pipe(
map((displaynameMap) => displaynameMap.get(userId)),
),
),
createAvatarUrlBehavior$: (scope: ObservableScope, userId: string) =>
scope.behavior(
roomMembers$.pipe(
map((roomMembers) => roomMembers.get(userId)?.getMxcAvatarUrl()),
),
),
// mostly for testing purposes
displaynameMap$,
avatarMap$,
};
};
+95
View File
@@ -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.
*/
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { distinctUntilChanged, map } from "rxjs";
import { type Behavior } from "../reactive/Behavior";
import { type ObservableScope } from "../reactive/ObservableScope";
import { type createMatrixMemberMetadata$ } from "./MatrixMemberMetadata";
import {
type LocalRTCMember,
type RemoteRTCMember,
type RTCMember,
} from "../api";
import { type LocalMemberMedia, type MemberMedia } from "../media-api";
import { type TransportRegistry } from "./Transports";
/** What every member is built from, besides its own membership. */
export interface MemberContext {
metadata: ReturnType<typeof createMatrixMemberMetadata$>;
transports: TransportRegistry;
/** Whose memberships are `local`. */
own: { userId: string; deviceId: string };
}
/** Everything that tells one membership from another, for keying items. */
export function membershipKeys(
membership: CallMembership,
): [string, string, string, string] {
return [
membership.userId,
membership.deviceId,
membership.memberId,
membership.rtcBackendIdentity,
];
}
/** The slot's view of a membership: identity, room state and transport, no media. */
export function createRTCMember(
scope: ObservableScope,
membership$: Behavior<CallMembership>,
{ metadata, transports, own }: MemberContext,
): RTCMember {
const { userId, deviceId, memberId, rtcBackendIdentity, backendIdentities } =
membership$.value;
return {
local: userId === own.userId && deviceId === own.deviceId,
// Read off the membership for now; the backend will derive them once the
// identity scheme is its own and not the js-sdk's
rtcBackendIdentity,
backendIdentities,
userId,
deviceId,
memberId,
displayName$: scope.behavior(
metadata
.createDisplayNameBehavior$(scope, userId)
.pipe(map((name) => name ?? userId)),
),
avatarUrl$: metadata.createAvatarUrlBehavior$(scope, userId),
transport$: scope.behavior(
membership$.pipe(
map((membership) => membership.getTransport()),
distinctUntilChanged(),
map((transport) => transport && transports.get(transport)),
),
),
applicationData$: scope.behavior(
membership$.pipe(
map((membership) => membership.applicationData),
distinctUntilChanged(deepCompare),
),
),
};
}
/** The participation's view of a remote member: the slot's member plus its media. */
export function createRemoteRTCMember(
member: RTCMember,
media: MemberMedia,
): RemoteRTCMember {
return { ...member, local: false, ...media };
}
export function createLocalRTCMember(
member: RTCMember,
media: LocalMemberMedia,
): LocalRTCMember {
return { ...member, local: true, ...media };
}
@@ -0,0 +1,143 @@
/*
Copyright 2025 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 {
beforeEach,
describe,
expect,
it,
type MockedObject,
vi,
} from "vitest";
import { MatrixError } from "matrix-js-sdk";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import {
TransportAutoDiscovery,
type TransportAutoDiscoveryProps,
} from "./TransportAutoDiscovery.ts";
type DiscoveryClient = TransportAutoDiscoveryProps["client"];
const backendTransport: Transport = {
type: "livekit",
livekit_service_url: "https://backend.example.org",
};
const configTransport: Transport = {
type: "livekit",
livekit_service_url: "https://config.example.org",
};
function makeClient(): MockedObject<DiscoveryClient> {
return {
getDomain: vi.fn().mockReturnValue("example.org"),
baseUrl: "https://matrix.example.org",
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
} as unknown as MockedObject<DiscoveryClient>;
}
describe("TransportAutoDiscovery", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const VALID_TEST_CASES: Array<{ transports: Transport[] }> = [
{ transports: [backendTransport] },
// will pick the first livekit transport in the list, even if there are other non-livekit transports
{ transports: [{ type: "not_livekit" }, backendTransport] },
];
it.each(VALID_TEST_CASES)(
"prefers backend transport other app config $transports",
async ({ transports }) => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue(transports);
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
fallbackTransport: configTransport,
logger: rootLogger,
});
const discoveredTransport = await discovery.discoverPreferredTransport();
expect(discoveredTransport).toStrictEqual(backendTransport);
expect(discoveredTransport).not.toStrictEqual(configTransport);
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(1);
},
);
it("Retries limit_exceeded backend transport", async () => {
const client = makeClient();
client._unstable_getRTCTransports
.mockRejectedValueOnce(
new MatrixError(
{
errcode: "M_LIMIT_EXCEEDED",
error: "Too many requests",
retry_after_ms: 100,
},
429,
),
)
.mockResolvedValue([backendTransport]);
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
fallbackTransport: configTransport,
logger: rootLogger,
});
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
backendTransport,
);
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(2);
});
const INVALID_TEST_CASES: Array<{ transports: Transport[] }> = [
{ transports: [] },
{ transports: [{ type: "not_livekit" }] },
];
it.each(INVALID_TEST_CASES)(
"falls back to config when backend has no (valid) livekit transports $transports",
async ({ transports }) => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue(transports);
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
fallbackTransport: configTransport,
logger: rootLogger,
});
const discoveredTransport = await discovery.discoverPreferredTransport();
expect(discoveredTransport).not.toStrictEqual(backendTransport);
expect(discoveredTransport).toStrictEqual(configTransport);
},
);
it("returns null when backend and config are all unavailable", async () => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue([]);
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
fallbackTransport: undefined,
logger: rootLogger,
});
await expect(discovery.discoverPreferredTransport()).resolves.toBeNull();
});
});
@@ -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.
*/
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { doNetworkOperationWithRetry } from "../utils/network";
type TransportDiscoveryClient = Pick<
MatrixClient,
"getDomain" | "_unstable_getRTCTransports"
>;
export interface TransportAutoDiscoveryProps {
client: TransportDiscoveryClient;
/** The transport type the media backend serves. */
transportType: string;
/** A transport to use when the homeserver advertises none of the type. */
fallbackTransport?: Transport;
logger: Logger;
}
export class TransportAutoDiscovery {
private readonly client: TransportDiscoveryClient;
private readonly transportType: string;
private readonly fallbackTransport: Transport | undefined;
private readonly logger: Logger;
public constructor({
client,
transportType,
fallbackTransport,
logger,
}: TransportAutoDiscoveryProps) {
this.client = client;
this.transportType = transportType;
this.fallbackTransport = fallbackTransport;
this.logger = logger.getChild("[TransportAutoDiscovery]");
}
public async discoverPreferredTransport(): Promise<Transport | null> {
// 1) backend transports
const backendTransport = await this.tryBackendTransports();
if (backendTransport) {
this.logger.info(
`Found backend transport: ${JSON.stringify(backendTransport)}`,
);
return backendTransport;
}
// 2) the host's fallback
if (this.fallbackTransport) {
this.logger.info(
`Found fallback transport: ${JSON.stringify(this.fallbackTransport)}`,
);
return this.fallbackTransport;
}
return null;
}
/**
* Fetches the first transport of the backend's type from the homeserver.
* This will not throw errors, but instead just log them and return null if the expected config is not found or malformed.
* @private
*/
private async tryBackendTransports(): Promise<Transport | null> {
const client = this.client;
// MSC4143: Attempt to fetch transports from backend.
this.logger.info("First try to use getRTCTransports end point ...");
try {
const transportList = await doNetworkOperationWithRetry(async () =>
client._unstable_getRTCTransports(),
);
const first = transportList.find(
(transport) => transport.type === this.transportType,
);
if (first) {
return first;
} else {
this.logger.info(
`No ${this.transportType} transport found in getRTCTransports end point`,
transportList,
);
}
} catch (ex) {
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
}
return null;
}
}
+71
View File
@@ -0,0 +1,71 @@
/*
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 Transport } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject } from "rxjs";
import { describe, expect, it } from "vitest";
import { type ResolvedTransport } from "../api";
import {
type BackendConnection,
MediaConnectionState,
} from "../media-backend/api";
import { testScope } from "../utils/test";
import { createTransportRegistry, transportId } from "./Transports";
const sfu: Transport = { type: "livekit", livekit_service_url: "https://sfu" };
const resolved: ResolvedTransport = {
type: "livekit",
url: "wss://sfu",
token: "jwt",
roomAlias: "room",
identity: "me",
};
describe("transportId", () => {
it("does not depend on the order a sender wrote the keys in", () => {
expect(transportId({ type: "livekit", livekit_service_url: "u" })).toBe(
transportId({ livekit_service_url: "u", type: "livekit" }),
);
});
it("tells transports of the same type apart", () => {
expect(transportId(sfu)).not.toBe(
transportId({ ...sfu, livekit_service_url: "https://other" }),
);
});
});
describe("createTransportRegistry", () => {
it("hands out one metadata object per transport", () => {
const registry = createTransportRegistry(
testScope(),
new BehaviorSubject<BackendConnection[]>([]),
);
expect(registry.get(sfu)).toBe(registry.get({ ...sfu }));
expect(registry.get(sfu).id).toBe(transportId(sfu));
});
it("derives the connected transports and what was resolved from the backend's connections", () => {
const connections$ = new BehaviorSubject<BackendConnection[]>([]);
const registry = createTransportRegistry(testScope(), connections$);
const metadata = registry.get(sfu);
expect(registry.connected$.value).toEqual([]);
expect(metadata.resolved$.value).toBeUndefined();
connections$.next([
{ transport: sfu, state: MediaConnectionState.Connecting, resolved },
]);
expect(registry.connected$.value).toEqual([]);
expect(metadata.resolved$.value).toBe(resolved);
connections$.next([
{ transport: sfu, state: MediaConnectionState.Connected, resolved },
]);
expect(registry.connected$.value).toEqual([metadata]);
});
});
+85
View File
@@ -0,0 +1,85 @@
/*
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 Transport } from "matrix-js-sdk/lib/matrixrtc";
import { distinctUntilChanged, map } from "rxjs";
import { type Behavior } from "../reactive/Behavior";
import { type ObservableScope } from "../reactive/ObservableScope";
import { type TransportMetadata } from "../api";
import { type MediaBackend, MediaConnectionState } from "../media-backend/api";
export interface TransportRegistry {
/** The one `TransportMetadata` for a transport, however many memberships name it. */
get(raw: Transport): TransportMetadata;
/** Transports with a live connection. */
connected$: Behavior<TransportMetadata[]>;
}
export function createTransportRegistry(
scope: ObservableScope,
connections$: MediaBackend["connections$"],
): TransportRegistry {
const transports = new Map<string, TransportMetadata>();
const get = (raw: Transport): TransportMetadata => {
const id = transportId(raw);
let transport = transports.get(id);
if (transport === undefined) {
transport = createTransportMetadata(scope, connections$, raw, id);
transports.set(id, transport);
}
return transport;
};
// Deduplicated: a backend may hold more than one connection to a transport,
// one per homeserver it has to authenticate through
const connected$ = scope.behavior(
connections$.pipe(
map((connections) => [
...new Set(
connections
.filter(({ state }) => state === MediaConnectionState.Connected)
.map(({ transport }) => get(transport)),
),
]),
),
);
return { get, connected$ };
}
function createTransportMetadata(
scope: ObservableScope,
connections$: MediaBackend["connections$"],
raw: Transport,
id: string,
): TransportMetadata {
return {
type: raw.type,
id,
raw,
resolved$: scope.behavior(
connections$.pipe(
map(
(connections) =>
connections.find((c) => transportId(c.transport) === id)?.resolved,
),
distinctUntilChanged(),
),
),
};
}
/**
* Transports are plain JSON out of membership events, so the same transport
* serialises the same way wherever it appears. Keys are sorted so that the
* order a sender wrote them in does not matter.
*/
export function transportId(raw: Transport): string {
return JSON.stringify(raw, Object.keys(raw).sort());
}
+113
View File
@@ -0,0 +1,113 @@
/*
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 MatrixRTCSession,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { describe, expect, it, type Mock, vi } from "vitest";
import { defaultSessionTimings, MatrixRTCMode } from "../config";
import { ownMemberMock } from "../utils/test";
import { joinJsSdkSession } from "./joinJsSdkSession";
const transport: Transport = {
type: "livekit",
livekit_service_url: "https://lk.example.org",
};
/** A session that only records the join; nothing else of it is touched. */
function session(): MatrixRTCSession & {
join: Mock<MatrixRTCSession["join"]>;
} {
return { join: vi.fn() } as unknown as MatrixRTCSession & {
join: Mock<MatrixRTCSession["join"]>;
};
}
describe("joinJsSdkSession", () => {
it("joins with the identity, the transport and the delayed leave timings it was given", () => {
const s = session();
const timings = {
...defaultSessionTimings,
waitForKeyRotationMs: 500,
membershipEventExpiryMs: 60_000,
keyRotationParticipantLimit: 20,
};
joinJsSdkSession(s, ownMemberMock, transport, {
encryptMedia: true,
matrixRTCMode: MatrixRTCMode.Compatibility,
sendNotificationType: "ring",
timings,
delayedLeave: {
delay_ms: 3_600_000,
restart_ms: 300_000,
restart_timeout_ms: 5_000,
},
});
expect(s.join).toHaveBeenCalledWith(ownMemberMock, [transport], {
notificationType: "ring",
callIntent: undefined,
applicationData: {},
manageMediaKeys: true,
delayedLeaveEventRestartMs: 300_000,
delayedLeaveEventDelayMs: 3_600_000,
delayedLeaveEventRestartLocalTimeoutMs: 5_000,
networkErrorRetryMs: 1_000,
makeKeyDelay: 500,
membershipEventExpiryMs: 60_000,
keyRotationParticipantLimit: 20,
unstableSendStickyEvents: false,
// The sync grace period is the shorter wait: 10 s at 1 s per retry, plus one
maximumNetworkErrorRetryCount: 11,
});
});
it("gives up on the network once the delayed leave has probably been sent", () => {
const s = session();
joinJsSdkSession(s, ownMemberMock, transport, {
encryptMedia: false,
matrixRTCMode: MatrixRTCMode.Compatibility,
timings: { ...defaultSessionTimings, networkErrorRetryMs: 4_000 },
delayedLeave: { delay_ms: 18_000 },
});
expect(s.join).toHaveBeenCalledWith(
ownMemberMock,
[transport],
expect.objectContaining({
manageMediaKeys: false,
maximumNetworkErrorRetryCount: 4,
}),
);
});
it("lifts the call intent out of the application data and sends sticky events in Matrix 2.0 mode", () => {
const s = session();
joinJsSdkSession(s, ownMemberMock, transport, {
encryptMedia: true,
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
applicationData: { "m.call.intent": "audio", custom: 1 },
timings: defaultSessionTimings,
delayedLeave: defaultSessionTimings.delayedLeave,
});
expect(s.join).toHaveBeenCalledWith(
ownMemberMock,
[transport],
expect.objectContaining({
callIntent: "audio",
applicationData: { custom: 1 },
unstableSendStickyEvents: true,
}),
);
});
});
+77
View File
@@ -0,0 +1,77 @@
/*
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 MatrixRTCSession as JsSdkRTCSession,
type RTCCallIntent,
type RTCNotificationType,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import {
type DelayedLeaveTimings,
MatrixRTCMode,
type SessionTimings,
} from "../config";
interface Options {
encryptMedia: boolean;
matrixRTCMode: MatrixRTCMode;
sendNotificationType?: RTCNotificationType;
applicationData?: Record<string, unknown>;
timings: SessionTimings;
/** The delayed leave timings to use: delegated to the SFU or not. */
delayedLeave: DelayedLeaveTimings;
}
/**
* Sends the membership and starts the membership manager, which keeps it
* alive and retries on failure.
* @throws If the join could not be started.
*/
export function joinJsSdkSession(
session: JsSdkRTCSession,
ownMembershipIdentity: CallMembershipIdentityParts,
transport: Transport,
{
encryptMedia,
matrixRTCMode,
sendNotificationType,
applicationData,
timings,
delayedLeave,
}: Options,
): void {
// The membership format carries the intent as a field of its own
const { "m.call.intent": intent, ...rest } = applicationData ?? {};
const callIntent =
typeof intent === "string" ? (intent as RTCCallIntent) : undefined;
// Give up on the network as soon as either the sync has been down for the
// grace period or the delayed leave has probably been sent, whichever is
// sooner
const maxWaitMs = Math.min(
timings.syncDisconnectGracePeriodMs,
delayedLeave.delay_ms,
);
session.join(ownMembershipIdentity, [transport], {
notificationType: sendNotificationType,
callIntent,
applicationData: rest,
manageMediaKeys: encryptMedia,
delayedLeaveEventRestartMs: delayedLeave.restart_ms,
delayedLeaveEventDelayMs: delayedLeave.delay_ms,
delayedLeaveEventRestartLocalTimeoutMs: delayedLeave.restart_timeout_ms,
networkErrorRetryMs: timings.networkErrorRetryMs,
makeKeyDelay: timings.waitForKeyRotationMs,
membershipEventExpiryMs: timings.membershipEventExpiryMs,
keyRotationParticipantLimit: timings.keyRotationParticipantLimit,
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
maximumNetworkErrorRetryCount:
Math.ceil(maxWaitMs / timings.networkErrorRetryMs) + 1,
});
}
+187
View File
@@ -0,0 +1,187 @@
/*
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 public media types of the SDK: what a member publishes, the tracks it
* arrives as and how a host renders them. The client and member types are in
* `api.ts`.
*/
import { type Observable } from "rxjs";
import { type Behavior } from "./reactive/Behavior";
export interface AudioCaptureSettings {
echoCancellation?: boolean;
noiseSuppression?: boolean;
autoGainControl?: boolean;
}
export type VideoCodec = "vp8" | "h264" | "vp9" | "av1" | "h265";
export interface VideoCaptureSettings {
resolution?: { width: number; height: number; frameRate?: number };
maxBitrate?: number;
maxFramerate?: number;
codec?: VideoCodec;
}
/**
* Transforms a camera track before it is published: background blur and the
* like. `init` resolves with the track to publish in place of the captured
* one; `restart` does the same for a new capture, where the camera changes.
*/
export interface VideoProcessor {
name: string;
init(
track: MediaStreamTrack,
element?: HTMLMediaElement,
): Promise<MediaStreamTrack>;
restart(
track: MediaStreamTrack,
element?: HTMLMediaElement,
): Promise<MediaStreamTrack>;
destroy(): Promise<void>;
}
/**
* One thing to publish: a source, where to capture it from and how to encode
* it. Where the device and the settings are left out, the browser's and
* LiveKit's defaults apply.
*/
export type PublishRequest =
| { source: "microphone"; deviceId?: string; capture?: AudioCaptureSettings }
| {
source: "camera";
deviceId?: string;
/** Background blur and the like, applied before the first frame is published. */
processor?: VideoProcessor;
capture?: VideoCaptureSettings;
}
| {
source: "screenShare";
/** Whether to capture the screen's audio too. Default true. */
audio?: boolean;
capture?: VideoCaptureSettings;
};
export type MediaSource =
| "microphone"
| "camera"
| "screenShare"
| "screenShareAudio"
/** Published without a source; the application knows what it is. */
| "unknown";
export type MediaStreamStats =
| RTCInboundRtpStreamStats
| RTCOutboundRtpStreamStats
| undefined;
/** One published track of a member. */
export interface MediaTrack {
source: MediaSource;
kind: "audio" | "video";
/** Stable for the life of the track. */
id: string;
muted$: Behavior<boolean>;
/**
* False when the SFU reports the track as unencrypted. Fixed for the life
* of the track: a publisher that changes its encryption publishes anew.
*/
encrypted: boolean;
/**
* Inbound statistics for a remote track, outbound for a local one, polled
* every `intervalMs` while the observable is subscribed and not at all
* otherwise. Each subscription runs its own poll, so a view shares one.
*/
stats$(intervalMs: number): Observable<MediaStreamStats>;
/**
* Rendering. The view hands its `<video>` or `<audio>` element over; the SDK
* sets its stream and, for video, registers the size and on-screen observers
* that pick a simulcast layer and pause the subscription while the element is
* hidden. `attach` is idempotent per element; `detach` has to be called
* before the element leaves the DOM so those observers are released.
*/
attach(element: HTMLMediaElement): void;
detach(element: HTMLMediaElement): void;
}
export interface AudioMediaTrack extends MediaTrack {
kind: "audio";
/**
* Whether the track carries sound right now, as the backend measures it.
* False while muted. A call reads this on the microphone track and calls it
* "speaking".
* This is different to audioLevel = 0. The backend might run some hysteresis on it.
*/
isActive$: Behavior<boolean>;
/**
* How loud the track is right now, 0 to 1, as the backend measures it. 0
* while muted. Not what decides `isActive$`: the backend applies its own
* threshold and hysteresis to that, so a level above 0 may not be active and
* an active track may briefly sit at 0.
*/
audioLevel$: Behavior<number>;
/** Route playback through Web Audio, for earpiece pan and gain. Undefined resets. */
setAudioContext(ctx: AudioContext | undefined, plugins?: AudioNode[]): void;
setVolume(volume: number): void;
}
export interface VideoMediaTrack extends MediaTrack {
kind: "video";
}
/** The controls a member has over a track it publishes itself. */
export interface LocalMediaTrack {
/**
* Mutes or unmutes. Resolves with the state that resulted, which differs
* from the request where the device could not be used.
*/
setEnabled(enabled: boolean): Promise<boolean>;
/** Captures from another device. Rejects for a screen share, which has none. */
setDevice(deviceId: string): Promise<void>;
}
export interface LocalAudioMediaTrack
extends AudioMediaTrack, LocalMediaTrack {}
export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
/** For mirroring; undefined where the camera does not say which way it faces. */
facingMode$: Behavior<"user" | "environment" | undefined>;
/**
* Restarts the camera facing the other way, on devices with a front and a
* back camera, and resolves with the id of the device now in use. Does
* nothing where the facing mode is unknown.
*/
switchFacingMode(): Promise<string | undefined>;
/** Background blur and the like; undefined removes the processor. */
setProcessor(processor: VideoProcessor | undefined): Promise<void>;
}
export type EncryptionError = "MissingKey" | "InvalidKey";
/**
* What a member sends, once it has arrived on its transport: its tracks, null
* while nothing has arrived for it, and the key errors beside them. The media
* backend supplies exactly these two fields per member.
*/
export interface MemberMedia {
/**
* The member's tracks, in publication order, once it has shown up on its
* transport; null until then ("waiting for media"). An entry stays the same
* object for as long as the same publication is behind it. Which track is
* which is in its `source`; `trackBySource$` picks one out.
*/
tracks$: Behavior<(AudioMediaTrack | VideoMediaTrack)[] | null>;
/** Emits when the SFU reports a key problem for this member. */
encryptionError$: Observable<EncryptionError>;
}
export interface LocalMemberMedia extends MemberMedia {
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>;
}
+196
View File
@@ -0,0 +1,196 @@
/*
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 seam between the session and whatever carries its media. The session
* speaks MatrixRTC: memberships, transports as they appear in them, the
* delayed leave. A backend speaks one media protocol and hands back the
* media of each member. `media-backend/livekit/` is the one backend today.
*/
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Observable } from "rxjs";
import { type ResolvedTransport, type RTCMember } from "../api";
import {
type LocalAudioMediaTrack,
type LocalMemberMedia,
type LocalVideoMediaTrack,
type MediaSource,
type MemberMedia,
type PublishRequest,
} from "../media-api";
import { type SessionTimings } from "../config";
import { type EncryptionSystem } from "../encryption";
import { type Behavior } from "../reactive/Behavior";
import { type ObservableScope } from "../reactive/ObservableScope";
export enum MediaConnectionState {
/** No connection attempt yet, or no connection at all. */
Initialized = "initialized",
/** Fetching whatever the connection needs first; the SFU token for LiveKit. */
Preparing = "preparing",
Connecting = "connecting",
Connected = "connected",
Reconnecting = "reconnecting",
Disconnected = "disconnected",
Stopped = "stopped",
}
/** One per-participant media key, as the session's key exchange delivers it. */
export interface MediaKey {
/** The `rtcBackendIdentity` of the member the key belongs to. */
participantId: string;
index: number;
key: Uint8Array<ArrayBuffer>;
}
export interface MediaBackendContext {
roomId: string;
/** The slot the session is in, `application#id`; a token is issued for it. */
slotId: string;
ownMembershipIdentity: CallMembershipIdentityParts;
/** What the local member publishes at the join. */
publish: PublishRequest[];
encryptionSystem: EncryptionSystem;
/**
* Every media key the session knows: the current ones replayed on
* subscribe, new ones as they arrive. Empty for a shared key, which is in
* `encryptionSystem`.
*/
mediaKeys$: Observable<MediaKey>;
timings: SessionTimings;
logger: Logger;
}
/**
* The type of the `backend` option: the session calls it once with its scope
* and the context. A backend's own factory keeps the shape of every other
* factory here, `createLivekitBackend(scope, context, options)`, and a host
* closes over its options. The Matrix client is not in the context: a backend
* that needs it takes a `Pick` of exactly what it calls in its own options.
*/
export type MediaBackendFactory = (
scope: ObservableScope,
context: MediaBackendContext,
) => MediaBackend;
/** What preparing the local transport established; what the MatrixRTC join needs from the media side. */
export interface TransportCapabilities {
/**
* Whether the backend can restart the delayed leave on the client's behalf,
* so that a client that vanishes is removed by the backend rather than by
* the timeout. Decides which delayed leave timings the join uses, and
* whether `delegateDelayedLeave` is ever called.
*/
canDelegateDelayedLeave: boolean;
}
/** One connection a backend holds, for diagnostics. */
export interface BackendConnection {
/** The transport as it appears in memberships; what the registry keys on. */
transport: Transport;
state: MediaConnectionState | Error;
/**
* What the backend fetched to connect; undefined until it has, and again
* after the connection stops. One object per fetch, so that a change of
* state does not read as a change of this.
*/
resolved: ResolvedTransport | undefined;
}
export interface DataPacket {
/** The backend identity of the sender. */
senderId: string;
topic: string;
text: string;
}
export interface MediaBackend {
/** The membership transport type this backend serves: "livekit". */
readonly transportType: string;
/**
* Makes `transport` usable as the one the local member publishes on. The
* session found it; the backend does whatever has to succeed before it is
* advertised in the membership. Rejects with the error the session reports
* as fatal. Called once per session.
*/
prepareLocalTransport(transport: Transport): Promise<TransportCapabilities>;
/**
* Hands the delayed leave event to the backend, which restarts it from
* then on. Called each time the membership manager reports a new delay id,
* only when `canDelegateDelayedLeave` is true. A rejection is logged by
* the session, not fatal.
*/
delegateDelayedLeave(delayId: string): Promise<void>;
readonly local: LocalMediaBackend;
/**
* The media of one remote member: its tracks, null while nothing has
* arrived for it on its transport, and its key errors. Built in `scope`,
* which the caller ends with the member or the participation, whichever
* goes first. This is also how the backend learns which remote transports
* exist: it follows the member's `transport$` for as long as `scope` lives,
* and connects to every transport some member is on.
*/
mediaFor(scope: ObservableScope, member: RTCMember): MemberMedia;
/** Every connection the backend holds. For debugging and devtool purposes.*/
readonly connections$: Behavior<BackendConnection[]>;
/**
* Plays the members' audio on this device: on the local transport's
* connection, whether it is connected yet or not, and on every connection
* created from then on. A connection to a remote transport that already
* exists keeps the device it was created with.
*/
setAudioOutputDeviceId(deviceId: string): Promise<void>;
/**
* A text channel beside the media: sent on the local transport, received on every one.
* This could well be just another track kind. Not needed for element call. just for the sdk target.
* Keeping it on the top level for visibility. Should be tackled in a future refactor.
*/
sendData(topic: string, text: string): Promise<void>;
readonly data$: Observable<DataPacket>;
}
/** `tracks$` is null until the connection carries a local participant. */
export interface LocalMediaBackend extends LocalMemberMedia {
/**
* State of the connection the local member publishes on. `Initialized`
* while there is none; an `Error` is the connection's failure.
*/
readonly connectionState$: Behavior<MediaConnectionState | Error>;
/**
* Whether the local tracks reach the transport. The session sets it to
* "joined and the homeserver is reachable". Remembered across connections:
* the tracks are created on the first `true`, and their upstream is paused
* while `false`, so the local preview stays live while nothing is heard.
*/
setPublishing(publish: boolean): void;
/** The first failure to publish, kept until the session ends. Not fatal. */
readonly publishError$: Behavior<Error | null>;
/**
* Publishes a source and resolves with its track once it is in `tracks$`.
* Remembered across connections, so a request before the
* transport is up is applied once it is, and republished after a
* reconnection. Rejects where the device could not be used.
*/
publish(
request: PublishRequest,
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
/** Removes one of our publications; a screen share takes its audio with it. */
unpublish(source: MediaSource): Promise<void>;
}
@@ -0,0 +1,713 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
afterEach,
describe,
expect,
it,
type MockedObject,
onTestFinished,
vi,
} from "vitest";
import {
type LocalParticipant,
type RemoteParticipant,
type RemoteTrack,
type RemoteTrackPublication,
type Room as LivekitRoom,
RoomEvent,
Track,
ConnectionState as LivekitConnectionState,
} from "livekit-client";
import fetchMock from "fetch-mock";
import EventEmitter from "events";
import { MatrixError } from "matrix-js-sdk";
import { logger, type Logger } from "matrix-js-sdk/lib/logger";
import {
type LivekitGetTokenResponse,
type UnstableLivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc";
import {
Connection,
type ConnectionState,
type ConnectionOpts,
} from "./Connection.ts";
import { ObservableScope } from "../../reactive/ObservableScope";
import { type ClientGetTokenParts, type ClientOpenIDParts } from "./auth";
import { MatrixRTCError } from "../../errors";
import { testJWTToken } from "../../utils/test-fixtures";
import { ownMemberMock } from "../../utils/test";
import { mockRemoteParticipant } from "./test";
let testScope: ObservableScope;
let client: MockedObject<ClientGetTokenParts & ClientOpenIDParts>;
let fakeLivekitRoom: MockedObject<LivekitRoom>;
let localParticipantEventEmiter: EventEmitter;
let fakeLocalParticipant: MockedObject<LocalParticipant>;
const ROOM_ID = "!roomID:example.org";
const SLOT_ID = "m.call#ROOM";
const transport: UnstableLivekitTransport = {
type: "livekit",
url: "wss://matrix-rtc.m.localhost/livekit/sfu",
};
/** The options of a subscriber connection to `transport`. */
function connectionOpts(): ConnectionOpts {
return {
client,
roomId: ROOM_ID,
slotId: SLOT_ID,
role: "subscriber",
transport,
serverName: "m.localhost",
scope: testScope,
ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom,
};
}
function setupTest(): void {
testScope = new ObservableScope();
client = vi.mocked<ClientGetTokenParts & ClientOpenIDParts>({
getOpenIdToken: vi.fn().mockResolvedValue({
access_token: "rYsmGUEwNjKgJYyeNUkZseJN",
token_type: "Bearer",
matrix_server_name: "example.org",
expires_in: 3600,
}),
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: testJWTToken }),
});
localParticipantEventEmiter = new EventEmitter();
fakeLocalParticipant = vi.mocked<LocalParticipant>({
identity: "@me:example.org",
isMicrophoneEnabled: vi.fn().mockReturnValue(true),
getTrackPublication: vi.fn().mockReturnValue(undefined),
on: localParticipantEventEmiter.on.bind(localParticipantEventEmiter),
off: localParticipantEventEmiter.off.bind(localParticipantEventEmiter),
addListener: localParticipantEventEmiter.addListener.bind(
localParticipantEventEmiter,
),
removeListener: localParticipantEventEmiter.removeListener.bind(
localParticipantEventEmiter,
),
removeAllListeners: localParticipantEventEmiter.removeAllListeners.bind(
localParticipantEventEmiter,
),
} as unknown as LocalParticipant);
const fakeRoomEventEmitter = new EventEmitter();
fakeLivekitRoom = vi.mocked<LivekitRoom>({
connect: vi.fn(),
disconnect: vi.fn(),
remoteParticipants: new Map(),
localParticipant: fakeLocalParticipant,
state: LivekitConnectionState.Disconnected,
on: fakeRoomEventEmitter.on.bind(fakeRoomEventEmitter),
off: fakeRoomEventEmitter.off.bind(fakeRoomEventEmitter),
addListener: fakeRoomEventEmitter.addListener.bind(fakeRoomEventEmitter),
removeListener:
fakeRoomEventEmitter.removeListener.bind(fakeRoomEventEmitter),
removeAllListeners:
fakeRoomEventEmitter.removeAllListeners.bind(fakeRoomEventEmitter),
setE2EEEnabled: vi.fn().mockResolvedValue(undefined),
emit: (eventName: string | symbol, ...args: unknown[]) => {
fakeRoomEventEmitter.emit(eventName, ...args);
},
} as unknown as LivekitRoom);
}
function setupRemoteConnection(): Connection {
const opts = connectionOpts();
fakeLivekitRoom.connect.mockImplementation(async (): Promise<void> => {
const changeEv = RoomEvent.ConnectionStateChanged;
fakeLivekitRoom.state = LivekitConnectionState.Connecting;
fakeLivekitRoom.emit(changeEv, fakeLivekitRoom.state);
fakeLivekitRoom.state = LivekitConnectionState.Connected;
fakeLivekitRoom.emit(changeEv, fakeLivekitRoom.state);
return Promise.resolve();
});
return new Connection(opts, logger);
}
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
fetchMock.reset();
});
describe("Start connection states", () => {
it("start in initialized state", () => {
setupTest();
const opts = connectionOpts();
const connection = new Connection(opts, logger);
expect(connection.state$.getValue()).toEqual({ state: "initialized" });
});
it("fail to get SFU token then error state", async () => {
setupTest();
vi.useFakeTimers();
const opts = connectionOpts();
const connection = new Connection(opts, logger);
const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => {
capturedStates.push(value);
});
onTestFinished(() => s.unsubscribe());
const deferred = Promise.withResolvers<LivekitGetTokenResponse>();
client._unstable_getLivekitToken.mockImplementation(
async (): Promise<LivekitGetTokenResponse> => {
return await deferred.promise;
},
);
connection.start().catch(() => {
// expected to throw
});
let capturedState = capturedStates.pop();
expect(capturedState).toBeDefined();
expect(capturedState).toEqual({ state: "authenticating" });
deferred.reject(new MatrixRTCError("Failed to get token"));
await vi.runAllTimersAsync();
capturedState = capturedStates.pop();
if (capturedState?.state === "error") {
expect(capturedState.error.message).toEqual("Failed to get token");
expect(connection.transport).toEqual(transport);
} else {
expect.fail(
"Expected error state but got " + JSON.stringify(capturedState),
);
}
});
it("fail to get JWT token and error state", async () => {
setupTest();
vi.useFakeTimers();
const opts = connectionOpts();
const connection = new Connection(opts, logger);
const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => {
capturedStates.push(value);
});
onTestFinished(() => s.unsubscribe());
const deferredSFU = Promise.withResolvers<void>();
client._unstable_getLivekitToken.mockImplementation(async () => {
await deferredSFU.promise;
throw new MatrixError(
{
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
500,
);
});
connection.start().catch(() => {
// expected to throw
});
let capturedState = capturedStates.pop();
expect(capturedState).toBeDefined();
expect(capturedState).toEqual({ state: "authenticating" });
deferredSFU.resolve();
await vi.runAllTimersAsync();
capturedState = capturedStates.pop();
if (
capturedState?.state === "error" &&
capturedState.error.cause instanceof Error
) {
expect(capturedState.error.cause.message).toContain(
"Failed to look up user info from homeserver",
);
expect(connection.transport).toEqual(transport);
} else {
expect.fail(
"Expected error state but got " + JSON.stringify(capturedState),
);
}
});
it("fail to connect to livekit error state", async () => {
setupTest();
vi.useFakeTimers();
const opts = connectionOpts();
const connection = new Connection(opts, logger);
const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => {
capturedStates.push(value);
});
onTestFinished(() => s.unsubscribe());
const deferredSFU = Promise.withResolvers<void>();
fakeLivekitRoom.connect.mockImplementation(async () => {
await deferredSFU.promise;
throw new Error("Failed to connect to livekit");
});
connection.start().catch(() => {
// expected to throw
});
let capturedState = capturedStates.pop();
expect(capturedState).toBeDefined();
expect(capturedState).toEqual({ state: "authenticating" });
deferredSFU.resolve();
await vi.runAllTimersAsync();
capturedState = capturedStates.pop();
if (
capturedState?.state === "error" &&
capturedState.error.cause instanceof Error
) {
expect(capturedState.error.cause.message).toContain(
"Failed to connect to livekit",
);
expect(connection.transport).toEqual(transport);
} else {
expect.fail(
"Expected FailedToStart state but got " + JSON.stringify(capturedState),
);
}
});
it("connection states happy path", async () => {
vi.useFakeTimers();
setupTest();
const connection = setupRemoteConnection();
const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => {
capturedStates.push(value);
});
onTestFinished(() => s.unsubscribe());
await connection.start();
await vi.runAllTimersAsync();
const initialState = capturedStates.shift();
expect(initialState).toEqual({ state: "initialized" });
const fetchingState = capturedStates.shift();
expect(fetchingState).toEqual({ state: "authenticating" });
const disconnectedState = capturedStates.shift();
expect(disconnectedState).toEqual({
state: "authenticated",
livekitState: LivekitConnectionState.Disconnected,
});
const connectingState = capturedStates.shift();
expect(connectingState).toEqual({
state: "authenticated",
livekitState: LivekitConnectionState.Connecting,
});
const connectedState = capturedStates.shift();
expect(connectedState).toEqual({
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
});
});
it("authenticates a publisher on a livekit_service_url transport through the JWT service", async () => {
setupTest();
const serviceUrl = "https://matrix-rtc.example.org/livekit/jwt";
fetchMock.post(`${serviceUrl}/sfu/get`, {
status: 200,
body: { url: "wss://sfu", jwt: testJWTToken },
});
fakeLivekitRoom.connect.mockResolvedValue(undefined);
const connection = new Connection(
{
...connectionOpts(),
role: "publisher",
transport: { type: "livekit", livekit_service_url: serviceUrl },
},
logger,
);
await connection.start();
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
expect(fetchMock.called(`${serviceUrl}/sfu/get`)).toBe(true);
expect(fakeLivekitRoom.connect).toHaveBeenCalledWith(
"wss://sfu",
testJWTToken,
);
expect(connection.livekitAlias).toBe("!example_room_id");
});
it("stopping while connecting does not report an error", async () => {
setupTest();
const connection = setupRemoteConnection();
const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => {
capturedStates.push(value);
});
onTestFinished(() => s.unsubscribe());
// livekit-client rejects a pending connect() when disconnect() is called.
const pendingConnect = Promise.withResolvers<void>();
fakeLivekitRoom.connect.mockReturnValue(pendingConnect.promise);
fakeLivekitRoom.disconnect.mockResolvedValue(undefined);
const started = connection.start();
await vi.waitFor(() => expect(fakeLivekitRoom.connect).toHaveBeenCalled());
const stopping = connection.stop();
pendingConnect.reject(new Error("Client initiated disconnect"));
await stopping;
// start() resolves rather than rejecting (it is not awaited by the caller,
// so a rejection would be unhandled).
await expect(started).resolves.toBeUndefined();
expect(capturedStates.at(-1)).toEqual({ state: "stopped" });
expect(capturedStates.some((st) => st.state === "error")).toBe(false);
});
it("shutting down the scope should stop the connection", async () => {
setupTest();
vi.useFakeTimers();
const connection = setupRemoteConnection();
await connection.start();
const stopSpy = vi.spyOn(connection, "stop");
testScope.end();
expect(stopSpy).toHaveBeenCalled();
expect(fakeLivekitRoom.disconnect).toHaveBeenCalled();
});
});
describe("remote participants", () => {
it("emits the list of remote participants", () => {
setupTest();
const connection = setupRemoteConnection();
const observedParticipants: RemoteParticipant[][] = [];
const s = connection.remoteParticipants$.subscribe((participants) => {
observedParticipants.push(participants);
});
onTestFinished(() => s.unsubscribe());
// The remoteParticipants$ observable is derived from the current members of the
// livekitRoom and the rtc membership in order to publish the members that are publishing
// on this connection.
let participants: RemoteParticipant[] = [
mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
// Mock Dan to have no published tracks. We want him to still show show up
// in the participants list.
mockRemoteParticipant({
identity: "@dan:example.org:DEV333",
getTrackPublication: () => undefined,
getTrackPublications: () => [],
}),
];
// Let's simulate 3 members on the livekitRoom
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get").mockImplementation(
() => new Map(participants.map((p) => [p.identity, p])),
);
participants.forEach((p) =>
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
);
// At this point there should be ~~no~~ publishers
// We do have publisher now, since we do not filter for publishers anymore (to also have participants with only data tracks)
// The filtering we do is just based on the matrixRTC member events.
expect(observedParticipants.pop()!.length).toEqual(4);
participants = [
mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
mockRemoteParticipant({ identity: "@dan:example.org:DEV333" }),
];
participants.forEach((p) =>
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
);
// At this point there should be no publishers
expect(observedParticipants.pop()!.length).toEqual(4);
});
it("should be scoped to parent scope", (): void => {
setupTest();
const connection = setupRemoteConnection();
let observedParticipants: RemoteParticipant[][] = [];
const s = connection.remoteParticipants$.subscribe((participants) => {
observedParticipants.push(participants);
});
onTestFinished(() => s.unsubscribe());
let participants: RemoteParticipant[] = [
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
];
// Let's simulate 3 members on the livekitRoom
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get").mockImplementation(
() => new Map(participants.map((p) => [p.identity, p])),
);
for (const participant of participants) {
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant);
}
// We should have bob as a participant now
const ps = observedParticipants.pop();
expect(ps?.length).toEqual(1);
expect(ps?.[0]?.identity).toEqual("@bob:example.org:DEV111");
// end the parent scope
testScope.end();
observedParticipants = [];
// SHOULD NOT emit any more participants as the scope is ended
participants = participants.filter(
(p) => p.identity !== "@bob:example.org:DEV111",
);
fakeLivekitRoom.emit(
RoomEvent.ParticipantDisconnected,
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
);
expect(observedParticipants.length).toEqual(0);
});
});
describe("remote track logging", () => {
it("logs remote participant and track events on the connection logger", () => {
setupTest();
const info = vi.fn();
const warn = vi.fn();
const testLogger = {
getChild: (): unknown => testLogger,
info,
debug: vi.fn(),
warn,
error: vi.fn(),
} as unknown as Logger;
new Connection(connectionOpts(), testLogger);
info.mockClear(); // drop the constructor log line
const bob = mockRemoteParticipant({
identity: "@bob:example.org:DEV111",
sid: "PA_bob",
});
const pub = {
kind: "audio",
source: "microphone",
trackSid: "TR_mic",
isMuted: false,
isEncrypted: true,
} as unknown as RemoteTrackPublication;
const messages = (): string[] => info.mock.calls.map((c) => c[0] as string);
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, bob);
fakeLivekitRoom.emit(
RoomEvent.TrackSubscribed,
{} as RemoteTrack,
pub,
bob,
);
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, bob);
fakeLivekitRoom.emit(
RoomEvent.TrackStreamStateChanged,
pub,
Track.StreamState.Paused,
bob,
);
expect(messages()).toEqual([
"Participant connected: @bob:example.org:DEV111 (PA_bob)",
"Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true muted=false",
"Muted: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
"Stream paused: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
]);
// Encryption status changes are logged; cryptor errors are warnings
fakeLivekitRoom.emit(
RoomEvent.ParticipantEncryptionStatusChanged,
false,
bob,
);
expect(messages().at(-1)).toBe(
"Encryption status of @bob:example.org:DEV111: encrypted=false",
);
const cryptorError = new Error("missing key at index 3");
fakeLivekitRoom.emit(RoomEvent.EncryptionError, cryptorError, bob);
expect(warn).toHaveBeenCalledWith(
"Encryption error for @bob:example.org:DEV111:",
cryptorError,
);
// Local mute events are already logged by the Publisher
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, {
...fakeLocalParticipant,
isLocal: true,
} as unknown as LocalParticipant);
expect(messages().filter((m) => m.startsWith("Muted"))).toHaveLength(1);
// Listeners are removed when the scope ends
testScope.end();
fakeLivekitRoom.emit(RoomEvent.ParticipantDisconnected, bob);
expect(
messages().filter((m) => m.startsWith("Participant disconnected")),
).toHaveLength(0);
});
});
//
// NOT USED ANYMORE ?
//
// This setup look like sth for the Publisher. Not a connection.
// describe("PublishConnection", () => {
// // let fakeBlurProcessor: ProcessorWrapper<BackgroundOptions>;
// let roomFactoryMock: Mock<() => LivekitRoom>;
// let muteStates: MockedObject<MuteStates>;
// function setUpPublishConnection(): void {
// setupTest();
// roomFactoryMock = vi.fn().mockReturnValue(fakeLivekitRoom);
// muteStates = mockMuteStates();
// // fakeBlurProcessor = vi.mocked<ProcessorWrapper<BackgroundOptions>>({
// // name: "BackgroundBlur",
// // restart: vi.fn().mockResolvedValue(undefined),
// // setOptions: vi.fn().mockResolvedValue(undefined),
// // getOptions: vi.fn().mockReturnValue({ strength: 0.5 }),
// // isRunning: vi.fn().mockReturnValue(false)
// // });
// }
// describe("Livekit room creation", () => {
// function createSetup(): void {
// setUpPublishConnection();
// const fakeTrackProcessorSubject$ = new BehaviorSubject<ProcessorState>({
// supported: true,
// processor: undefined,
// });
// const opts: ConnectionOpts = {
// client: client,
// transport: livekitFocus,
// scope: testScope,
// livekitRoomFactory: roomFactoryMock,
// };
// const audioInput = {
// available$: of(new Map([["mic1", { id: "mic1" }]])),
// selected$: new BehaviorSubject({ id: "mic1" }),
// select(): void {},
// };
// const videoInput = {
// available$: of(new Map([["cam1", { id: "cam1" }]])),
// selected$: new BehaviorSubject({ id: "cam1" }),
// select(): void {},
// };
// const audioOutput = {
// available$: of(new Map([["speaker", { id: "speaker" }]])),
// selected$: new BehaviorSubject({ id: "speaker" }),
// select(): void {},
// };
// // TODO understand what is wrong with our mocking that requires ts-expect-error
// const fakeDevices = mockMediaDevices({
// // @ts-expect-error Mocking only
// audioInput,
// // @ts-expect-error Mocking only
// videoInput,
// // @ts-expect-error Mocking only
// audioOutput,
// });
// new Connection(
// opts,
// fakeDevices,
// muteStates,
// undefined,
// fakeTrackProcessorSubject$,
// );
// }
// it("should create room with proper initial audio and video settings", () => {
// createSetup();
// expect(roomFactoryMock).toHaveBeenCalled();
// const lastCallArgs =
// roomFactoryMock.mock.calls[roomFactoryMock.mock.calls.length - 1];
// const roomOptions = lastCallArgs.pop() as unknown as RoomOptions;
// expect(roomOptions).toBeDefined();
// expect(roomOptions!.videoCaptureDefaults?.deviceId).toEqual("cam1");
// expect(roomOptions!.audioCaptureDefaults?.deviceId).toEqual("mic1");
// expect(roomOptions!.audioOutput?.deviceId).toEqual("speaker");
// });
// it("respect controlledAudioDevices", () => {
// // TODO: Refactor the code to make it testable.
// // The UrlParams module is a singleton has a cache and is very hard to test.
// // This breaks other tests as well if not handled properly.
// // vi.mock(import("./../UrlParams"), () => {
// // return {
// // getUrlParams: vi.fn().mockReturnValue({
// // controlledAudioDevices: true
// // })
// // };
// // });
// });
// });
// });
+414
View File
@@ -0,0 +1,414 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
connectedParticipantsObserver,
connectionStateObserver,
} from "@livekit/components-core";
import {
ConnectionError,
ConnectionErrorReason,
type ConnectionState as LivekitConnectionState,
type Participant,
type RemoteParticipant,
type RemoteTrackPublication,
type Room as LivekitRoom,
RoomEvent,
type SubscriptionError,
type Track,
type TrackPublication,
} from "livekit-client";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import {
type ClientGetTokenParts,
type ClientOpenIDParts,
type ConnectionRole,
getSFUConfig,
type SFUConfig,
} from "./auth";
import { type Behavior } from "../../reactive/Behavior";
import { type ObservableScope } from "../../reactive/ObservableScope";
import {
MatrixRTCError,
InsufficientCapacityError,
LivekitConnectionError,
PeerConnectionTimeoutError,
SFURoomCreationRestrictedError,
UnknownRTCError,
} from "../../errors";
export interface ConnectionOpts {
/** The identity parts to use on this connection */
ownMembershipIdentity: CallMembershipIdentityParts;
/** Whether we publish on this connection or only subscribe. */
role: ConnectionRole;
/** The media transport to connect to. */
transport: UnstableLivekitTransport;
/** The homeserver the transport belongs to. */
serverName: string;
/** The Matrix client, for the token exchange. */
client: ClientGetTokenParts & ClientOpenIDParts;
/** The Matrix room the session is in. */
roomId: string;
/** The MatrixRTC slot the session is in. */
slotId: string;
/** The observable scope to use for this connection. */
scope: ObservableScope;
/** Optional factory to create the LiveKit room, mainly for testing purposes. */
livekitRoomFactory: () => LivekitRoom;
}
export type ConnectionState =
/** Created, nothing started yet. */
| { state: "initialized" }
/** `start` was called; the SFU token is being fetched. */
| { state: "authenticating" }
/** The token is in hand; the LiveKit room's own state from here. */
| { state: "authenticated"; livekitState: LivekitConnectionState }
| { state: "error"; error: MatrixRTCError }
| { state: "stopped" };
/**
* A connection to a Matrix RTC LiveKit backend.
*
* Expose observables for participants and connection state.
*/
export class Connection {
// Private Behavior
private readonly _state$ = new BehaviorSubject<ConnectionState>({
state: "initialized",
});
/**
* The current state of the connection to the media transport.
*/
public readonly state$: Behavior<ConnectionState> = this._state$;
/** Whether we publish on this connection or only subscribe. */
public readonly role: ConnectionRole;
/**
* The media transport to connect to.
*/
public readonly transport: UnstableLivekitTransport;
/** The homeserver the transport belongs to. */
public readonly serverName: string;
public readonly livekitRoom: LivekitRoom;
private scope: ObservableScope;
/**
* The remote LiveKit participants that are visible on this connection.
*
* Note that this may include participants that are connected only to
* subscribe, or publishers that are otherwise unattested in MatrixRTC state.
* It is therefore more low-level than what should be presented to the user.
*/
public readonly remoteParticipants$: Behavior<RemoteParticipant[]>;
/**
* The alias of the LiveKit room.
*/
public get livekitAlias(): string | undefined {
return this._livekitAlias;
}
private _livekitAlias?: string;
/**
* Whether the connection has been stopped.
* @see Connection.stop
* */
protected stopped = false;
private readonly client: ClientGetTokenParts & ClientOpenIDParts;
private readonly roomId: string;
private readonly slotId: string;
private readonly logger: Logger;
private readonly ownMembershipIdentity: CallMembershipIdentityParts;
/**
* Creates a new connection to a matrix RTC LiveKit backend.
*
* @param opts - Connection options {@link ConnectionOpts}.
*
* @param logger - The logger to use.
*/
public constructor(opts: ConnectionOpts, logger: Logger) {
this.ownMembershipIdentity = opts.ownMembershipIdentity;
this.roomId = opts.roomId;
this.slotId = opts.slotId;
this.logger = logger.getChild(
`[Connection ${JSON.stringify(opts.transport)}]`,
);
this.logger.debug(`constructor called`);
const { role, transport, serverName, client, scope } = opts;
this.scope = scope;
this.livekitRoom = opts.livekitRoomFactory();
this.role = role;
this.transport = transport;
this.serverName = serverName;
this.client = client;
this.remoteParticipants$ = scope.behavior(
// Only tracks remote participants
connectedParticipantsObserver(this.livekitRoom),
);
this.logRemoteTrackEvents();
scope.onEnd(() => {
this.logger.info(`Connection scope ended, stopping connection`);
void this.stop();
});
}
/**
* Logs the lifecycle of remote participants and their tracks as seen by
* livekit-client, so that rageshakes show what a tile should have been
* rendering (published, subscribed, muted, paused) for each remote member.
*/
private logRemoteTrackEvents(): void {
const room = this.livekitRoom;
const log = this.logger.getChild("[RemoteTracks]");
// The encryption flag matters: if the publisher encrypts but this client
// believes the track is unencrypted, the cryptor is bypassed and raw
// ciphertext reaches the decoder (audible as loud noise bursts).
const track = (pub: TrackPublication, p: Participant): string =>
`${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity} encrypted=${pub.isEncrypted}`;
const onParticipantConnected = (p: RemoteParticipant): void =>
log.info(`Participant connected: ${p.identity} (${p.sid})`);
const onParticipantDisconnected = (p: RemoteParticipant): void =>
log.info(`Participant disconnected: ${p.identity} (${p.sid})`);
const onTrackPublished = (
pub: RemoteTrackPublication,
p: RemoteParticipant,
): void => log.info(`Published: ${track(pub, p)} muted=${pub.isMuted}`);
const onTrackUnpublished = (
pub: RemoteTrackPublication,
p: RemoteParticipant,
): void => log.info(`Unpublished: ${track(pub, p)}`);
const onTrackSubscribed = (
_t: Track,
pub: RemoteTrackPublication,
p: RemoteParticipant,
): void => log.info(`Subscribed: ${track(pub, p)} muted=${pub.isMuted}`);
const onTrackUnsubscribed = (
_t: Track,
pub: RemoteTrackPublication,
p: RemoteParticipant,
): void => log.info(`Unsubscribed: ${track(pub, p)}`);
const onTrackSubscriptionFailed = (
trackSid: string,
p: RemoteParticipant,
reason?: SubscriptionError,
): void =>
log.warn(
`Subscription failed: ${trackSid} of ${p.identity} reason=${reason}`,
);
// Mute events also fire for the local participant, which the Publisher
// already logs.
const onTrackMuted = (pub: TrackPublication, p: Participant): void => {
if (!p.isLocal) log.info(`Muted: ${track(pub, p)}`);
};
const onTrackUnmuted = (pub: TrackPublication, p: Participant): void => {
if (!p.isLocal) log.info(`Unmuted: ${track(pub, p)}`);
};
const onTrackStreamStateChanged = (
pub: RemoteTrackPublication,
state: Track.StreamState,
p: RemoteParticipant,
): void => log.info(`Stream ${state}: ${track(pub, p)}`);
const onEncryptionStatusChanged = (
encrypted: boolean,
p?: Participant,
): void =>
log.info(
`Encryption status of ${p?.identity ?? "unknown participant"}: encrypted=${encrypted}`,
);
// livekit-client throttles these per cryptor; they indicate frames being
// dropped (missing/invalid key).
const onEncryptionError = (error: Error, p?: Participant): void =>
log.warn(
`Encryption error for ${p?.identity ?? "unknown participant"}:`,
error,
);
room
.on(RoomEvent.ParticipantConnected, onParticipantConnected)
.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
.on(RoomEvent.TrackPublished, onTrackPublished)
.on(RoomEvent.TrackUnpublished, onTrackUnpublished)
.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
.on(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
.on(RoomEvent.TrackMuted, onTrackMuted)
.on(RoomEvent.TrackUnmuted, onTrackUnmuted)
.on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
.on(
RoomEvent.ParticipantEncryptionStatusChanged,
onEncryptionStatusChanged,
)
.on(RoomEvent.EncryptionError, onEncryptionError);
this.scope.onEnd(() => {
room
.off(RoomEvent.ParticipantConnected, onParticipantConnected)
.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
.off(RoomEvent.TrackPublished, onTrackPublished)
.off(RoomEvent.TrackUnpublished, onTrackUnpublished)
.off(RoomEvent.TrackSubscribed, onTrackSubscribed)
.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
.off(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
.off(RoomEvent.TrackMuted, onTrackMuted)
.off(RoomEvent.TrackUnmuted, onTrackUnmuted)
.off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
.off(
RoomEvent.ParticipantEncryptionStatusChanged,
onEncryptionStatusChanged,
)
.off(RoomEvent.EncryptionError, onEncryptionError);
});
}
/**
* Starts the connection.
*
* This will:
* 1. Authenticate with the transport for our role, through the homeserver
* or the JWT service.
* 2. Connect to the LiveKit room the token is for.
*
* The errors are also represented as a state in the `state$` observable.
* It is safe to ignore those errors and handle them accordingly via the `state$` observable.
* @throws {InsufficientCapacityError} if the LiveKit server indicates that it has insufficient capacity to accept the connection.
* @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created.
*/
// TODO consider an autostart pattern...
public async start(): Promise<void> {
this.logger.debug("Starting Connection");
this.stopped = false;
try {
this._state$.next({ state: "authenticating" });
const { url, jwt, livekitAlias } = await this.getSFUConfig();
this.logger.debug(
"Starting Connection - jwt: ",
jwt,
"wss: ",
url,
"livekitAlias: ",
livekitAlias,
);
this._livekitAlias = livekitAlias;
// If we were stopped while fetching the config, don't proceed to connect
if (this.stopped) return;
// Setup observer once we are done with getSFUConfig
connectionStateObserver(this.livekitRoom)
.pipe(this.scope.bind())
.subscribe((livekitState) =>
this._state$.next({ state: "authenticated", livekitState }),
);
try {
this.logger.info(`livekitRoom.connect ${url}`);
await this.livekitRoom.connect(url, jwt);
this.logger.info(`livekitRoom.connect SUCCESS ${url}`);
} catch (e) {
this.logger.info(`livekitRoom.connect FAILED ${url}`, e);
// LiveKit uses 503 to indicate that the server has hit its track limits.
// https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
// It also errors with a status code of 200 (yes, really) for room
// participant limits.
// LiveKit Cloud uses 429 for connection limits.
// Either way, all these errors can be explained as "insufficient capacity".
if (e instanceof ConnectionError) {
if (e.status === 503 || e.status === 200 || e.status === 429) {
throw new InsufficientCapacityError();
}
if (e.status === 404) {
// error msg is "Failed to create call"
// error description is "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists."
// The room does not exist. There are two different modes of operation for the SFU:
// - the room is created on the fly when connecting (livekit `auto_create` option)
// - Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service)
// In the first case there will not be a 404, so we are in the second case.
throw new SFURoomCreationRestrictedError();
}
if (e.reason === ConnectionErrorReason.Timeout) {
// Unabled to establish peer connection within the timeout
throw new PeerConnectionTimeoutError();
}
throw new LivekitConnectionError(e);
}
throw e;
}
// If we were stopped while connecting, don't proceed to update state.
if (this.stopped) return;
} catch (error) {
if (this.stopped) {
// stop() was called while we were connecting, which makes the pending
// connect reject. That is the abort we asked for, not a failure, so
// don't record an error state on a stopped connection or rethrow it
// (start() is not necessarily awaited by the caller, so a throw here
// becomes an unhandled promise rejection).
this.logger.debug(`Connect aborted because the connection was stopped`);
return;
}
this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
this._state$.next({
state: "error",
error:
error instanceof MatrixRTCError
? error
: error instanceof Error
? new UnknownRTCError(error)
: new UnknownRTCError(new Error(`${error}`)),
});
// Its okay to ignore the throw. The error is part of the state.
throw error;
}
}
protected async getSFUConfig(): Promise<SFUConfig> {
return await getSFUConfig({
client: this.client,
membership: this.ownMembershipIdentity,
role: this.role,
transport: this.transport,
serverName: this.serverName,
roomId: this.roomId,
slotId: this.slotId,
logger: this.logger,
});
}
/**
* Stops the connection.
*
* This will disconnect from the LiveKit room.
* If the connection is already stopped, this is a no-op.
*/
public async stop(): Promise<void> {
this.logger.debug("stop: disconnecting from lk room");
if (this.stopped) return;
// Mark as stopped before disconnecting so that a connect() aborted by the
// disconnect sees the flag and does not report the abort as an error.
this.stopped = true;
await this.livekitRoom.disconnect();
this._state$.next({ state: "stopped" });
this.logger.debug("stop: DONE disconnecting from lk room");
}
}
@@ -0,0 +1,173 @@
/*
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 {
ConnectionState as LivekitConnectionState,
type RoomOptions,
} from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { describe, expect, it, vi } from "vitest";
import { type MediaQuality } from "../../config";
import { type PublishRequest } from "../../media-api";
import { constant } from "../../reactive/Behavior";
import { ownMemberMock, testScope } from "../../utils/test";
import { testJWTToken } from "../../utils/test-fixtures";
import { type ClientGetTokenParts, type ClientOpenIDParts } from "./auth";
import {
LivekitConnectionFactory,
type ResolvedConnection,
} from "./ConnectionFactory";
import { exampleTransport } from "./test";
const urlTransport: UnstableLivekitTransport = {
type: "livekit",
url: "wss://sfu.example.org",
};
function client(): ClientGetTokenParts & ClientOpenIDParts {
return {
getOpenIdToken: vi.fn(),
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: testJWTToken }),
};
}
/**
* Builds a subscriber connection and hands back the options its LiveKit room
* was built with. The room is a real one: it never connects, and its options
* are what the factory gave it, filled in with LiveKit's defaults.
*/
function create(
publish: PublishRequest[],
mediaQuality?: MediaQuality,
audioOutputDeviceId?: string,
transport = exampleTransport,
): { connection: ResolvedConnection; options: RoomOptions } {
const factory = new LivekitConnectionFactory(
client(),
"!room:example.org",
"m.call#ROOM",
publish,
constant(audioOutputDeviceId),
undefined,
mediaQuality,
);
const connection = factory.createConnection(
testScope(),
"subscriber",
transport,
"example.org",
ownMemberMock,
logger,
) as ResolvedConnection;
return { connection, options: connection.livekitRoom.options };
}
describe("LivekitConnectionFactory", () => {
it("builds the room on the defaults where nothing is requested", () => {
const { options } = create([]);
expect(options.publishDefaults).toMatchObject({ videoCodec: "vp8" });
expect(options.videoCaptureDefaults).toMatchObject({
resolution: expect.objectContaining({ height: 720 }),
});
expect(options.audioOutput).toEqual({ deviceId: undefined });
expect(options.e2ee).toBeUndefined();
});
it("makes the initial microphone request the room's audio defaults", () => {
const { options } = create([
{
source: "microphone",
deviceId: "mic-1",
capture: {
echoCancellation: false,
noiseSuppression: true,
autoGainControl: false,
},
},
]);
expect(options.audioCaptureDefaults).toMatchObject({
deviceId: "mic-1",
echoCancellation: false,
noiseSuppression: true,
autoGainControl: false,
});
});
it("makes the initial camera request the room's video and publish defaults", () => {
const { options } = create(
[
{
source: "camera",
deviceId: "cam-1",
capture: {
resolution: { width: 1920, height: 1080, frameRate: 60 },
maxBitrate: 4_000_000,
maxFramerate: 60,
codec: "vp9",
},
},
],
{ video_codec: "h264" },
);
expect(options.videoCaptureDefaults).toMatchObject({
deviceId: "cam-1",
resolution: { width: 1920, height: 1080, frameRate: 60 },
});
// The request wins over the media quality settings
expect(options.publishDefaults).toMatchObject({
videoEncoding: { maxBitrate: 4_000_000, maxFramerate: 60 },
videoCodec: "vp9",
});
});
it("applies the media quality settings where the requests leave the defaults", () => {
const { options } = create([{ source: "camera" }], {
video_codec: "av1",
video: { max_bitrate: 900_000, max_framerate: 24 },
});
expect(options.publishDefaults).toMatchObject({
videoEncoding: { maxBitrate: 900_000, maxFramerate: 24 },
videoCodec: "av1",
});
});
it("plays on the output device selected when the room is built", () => {
const { options } = create([], undefined, "speaker");
expect(options.audioOutput).toEqual({ deviceId: "speaker" });
});
it("reports what it fetched while connected, and nothing before or after", async () => {
const { connection } = create([], undefined, undefined, urlTransport);
// The room must not reach for the network; its state is left as it is
vi.spyOn(connection.livekitRoom, "connect").mockResolvedValue(undefined);
vi.spyOn(connection.livekitRoom, "disconnect").mockResolvedValue(undefined);
expect(connection.resolved$.value).toBeUndefined();
await connection.start();
expect(connection.state$.value).toEqual({
state: "authenticated",
livekitState: LivekitConnectionState.Disconnected,
});
expect(connection.resolved$.value).toEqual({
type: "livekit",
url: "wss://sfu.example.org",
token: testJWTToken,
roomAlias: "!example_room_id",
identity: "@me:example.org:ABCDEF",
});
await connection.stop();
expect(connection.resolved$.value).toBeUndefined();
});
});
@@ -0,0 +1,184 @@
/*
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 BaseKeyProvider,
Room as LivekitRoom,
type RoomOptions,
} from "livekit-client";
// Inline so that the worker also loads when the SDK is served from another
// origin than the page
import E2EEWorker from "livekit-client/e2ee-worker?worker&inline";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { BehaviorSubject, combineLatest, map } from "rxjs";
import { type ResolvedTransport } from "../../api";
import { type MediaQuality } from "../../config";
import { type PublishRequest } from "../../media-api";
import { type Behavior } from "../../reactive/Behavior";
import { type ObservableScope } from "../../reactive/ObservableScope";
import {
type ClientGetTokenParts,
type ClientOpenIDParts,
type ConnectionRole,
type SFUConfig,
} from "./auth";
import { Connection, type ConnectionOpts } from "./Connection";
import { buildLiveKitOptions } from "./livekitOptions";
import {
audioCaptureOptions,
type PublishRequestFor,
videoCaptureOptions,
videoPublishOptions,
} from "./publishOptions";
export interface ConnectionFactory {
createConnection(
scope: ObservableScope,
role: ConnectionRole,
transport: UnstableLivekitTransport,
serverName: string,
ownMembershipIdentity: CallMembershipIdentityParts,
logger: Logger,
): Connection;
}
/**
* A connection that keeps what it fetched when authenticating, so that the
* transport can report it as `resolved$`. Nothing while the token is still
* being fetched, and nothing again once the connection has stopped or failed.
*/
export class ResolvedConnection extends Connection {
private readonly sfuConfig$ = new BehaviorSubject<SFUConfig | undefined>(
undefined,
);
public readonly resolved$: Behavior<ResolvedTransport | undefined>;
public constructor(opts: ConnectionOpts, logger: Logger) {
super(opts, logger);
// One object per fetch, so that a change of state does not read as a
// change of what was fetched
const resolved$ = this.sfuConfig$.pipe(
map((config) => config && resolvedTransport(config)),
);
this.resolved$ = opts.scope.behavior(
combineLatest([resolved$, this.state$]).pipe(
map(([resolved, state]) =>
state.state === "stopped" || state.state === "error"
? undefined
: resolved,
),
),
);
}
protected override async getSFUConfig(): Promise<SFUConfig> {
const config = await super.getSFUConfig();
this.sfuConfig$.next(config);
return config;
}
}
function resolvedTransport(config: SFUConfig): ResolvedTransport {
return {
type: "livekit",
url: config.url,
token: config.jwt,
roomAlias: config.livekitAlias,
identity: config.livekitIdentity,
};
}
/**
* Creates a `ResolvedConnection` per transport, each with a LiveKit room of
* its own that captures from the devices the host selected.
*/
export class LivekitConnectionFactory implements ConnectionFactory {
public constructor(
private readonly client: ClientGetTokenParts & ClientOpenIDParts,
private readonly roomId: string,
private readonly slotId: string,
private readonly publish: PublishRequest[],
private readonly audioOutputDeviceId$: Behavior<string | undefined>,
private readonly keyProvider: BaseKeyProvider | undefined,
private readonly mediaQuality: MediaQuality | undefined,
) {}
public createConnection(
scope: ObservableScope,
role: ConnectionRole,
transport: UnstableLivekitTransport,
serverName: string,
ownMembershipIdentity: CallMembershipIdentityParts,
logger: Logger,
): Connection {
return new ResolvedConnection(
{
roomId: this.roomId,
slotId: this.slotId,
role,
transport,
serverName,
client: this.client,
scope,
livekitRoomFactory: () =>
new LivekitRoom(
roomOptions(
this.publish,
this.audioOutputDeviceId$.value,
this.keyProvider,
this.mediaQuality,
),
),
ownMembershipIdentity,
},
logger,
);
}
}
/** The initial requests become the room's defaults, so that one permission prompt covers both. */
function roomOptions(
publish: PublishRequest[],
audioOutputDeviceId: string | undefined,
keyProvider: BaseKeyProvider | undefined,
mediaQuality: MediaQuality | undefined,
): RoomOptions {
const base = buildLiveKitOptions(mediaQuality);
const microphone = initialRequest(publish, "microphone");
const camera = initialRequest(publish, "camera");
return {
...base,
videoCaptureDefaults: {
...base.videoCaptureDefaults,
...(camera && videoCaptureOptions(camera)),
},
publishDefaults: {
...base.publishDefaults,
...(camera && videoPublishOptions(camera.capture)),
},
audioCaptureDefaults: {
...base.audioCaptureDefaults,
...(microphone && audioCaptureOptions(microphone)),
},
audioOutput: { deviceId: audioOutputDeviceId },
// Every room needs a worker of its own: one gets confused by streams from
// several rooms
e2ee: keyProvider && { keyProvider, worker: new E2EEWorker() },
};
}
function initialRequest<S extends "microphone" | "camera">(
publish: PublishRequest[],
source: S,
): PublishRequestFor<S> | undefined {
return publish.find(
(request): request is PublishRequestFor<S> => request.source === source,
);
}
@@ -0,0 +1,493 @@
/*
Copyright 2025 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, test, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type RemoteParticipant } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import {
Epoch,
mapEpoch,
ObservableScope,
} from "../../reactive/ObservableScope";
import {
createConnectionMap$,
ConnectionMap,
keyFor,
} from "./ConnectionMap.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { type Connection } from "./Connection.ts";
import { ownMemberMock, withTestScheduler } from "../../utils/test";
import { type Behavior } from "../../reactive/Behavior";
import { type TransportLocator } from "./auth";
const TRANSPORT_1: TransportLocator = {
transport: {
type: "livekit",
livekit_service_url: "https://lk.example.org",
},
serverName: "example.org",
};
const TRANSPORT_2: TransportLocator = {
transport: {
type: "livekit",
livekit_service_url: "https://lk.sample.com",
},
serverName: "sample.com",
};
function findConnection(
transport: TransportLocator,
connections: Connection[],
): Connection | undefined {
const key = keyFor(transport);
return connections.find((c) => keyFor(c) === key);
}
let fakeConnectionFactory: ConnectionFactory;
let testScope: ObservableScope;
// Can be useful to track all created connections in tests, even the disposed ones
let allCreatedConnections: Connection[];
beforeEach(() => {
testScope = new ObservableScope();
allCreatedConnections = [];
fakeConnectionFactory = {} as unknown as ConnectionFactory;
vi.mocked(fakeConnectionFactory).createConnection = vi
.fn()
.mockImplementation(
(
scope: ObservableScope,
_role: unknown,
transport: UnstableLivekitTransport,
serverName: string,
) => {
const mockConnection = {
transport,
serverName,
remoteParticipants$: new BehaviorSubject([]),
} as unknown as Connection;
vi.mocked(mockConnection).start = vi.fn();
vi.mocked(mockConnection).stop = vi.fn();
// Tie the connection's lifecycle to the scope to test scope lifecycle management
scope.onEnd(() => {
void mockConnection.stop();
});
allCreatedConnections.push(mockConnection);
return mockConnection;
},
);
});
afterEach(() => {
testScope.end();
});
describe("connections$ stream", () => {
test("Should create and start new connections for each transports", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const connectionMap$ = createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: TRANSPORT_1,
remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
expectObservable(
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("a", {
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
const connections = e.value;
expect(connections.length).toBe(2);
expect(
vi.mocked(fakeConnectionFactory).createConnection,
).toHaveBeenCalledTimes(2);
const conn1 = findConnection(TRANSPORT_1, connections);
expect(conn1).toBeDefined();
expect(conn1!.start).toHaveBeenCalled();
const conn2 = findConnection(TRANSPORT_2, connections);
expect(conn2).toBeDefined();
expect(conn2!.start).toHaveBeenCalled();
return true;
}),
});
});
});
test("Tags the local transport as the publisher and the rest as subscribers", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const connectionMap$ = createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: TRANSPORT_1,
remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_2], 0),
}),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
expectObservable(connectionMap$).toBe("a", {
a: expect.toSatisfy(() => {
const { createConnection } = vi.mocked(fakeConnectionFactory);
expect(createConnection).toHaveBeenCalledWith(
expect.anything(),
"publisher",
TRANSPORT_1.transport,
TRANSPORT_1.serverName,
ownMemberMock,
expect.anything(),
);
expect(createConnection).toHaveBeenCalledWith(
expect.anything(),
"subscriber",
TRANSPORT_2.transport,
TRANSPORT_2.serverName,
ownMemberMock,
expect.anything(),
);
return true;
}),
});
});
});
test("Shares one connection between homeservers advertising the same JWT service", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const sameServiceElsewhere: TransportLocator = {
transport: TRANSPORT_1.transport,
serverName: "elsewhere.org",
};
const connectionMap$ = createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: TRANSPORT_1,
remoteTransports$: behavior("a", {
a: new Epoch([sameServiceElsewhere], 0),
}),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
expectObservable(
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("a", {
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
expect(e.value.length).toBe(1);
// The JWT service is reached directly, so the server name plays no
// part and the local locator, which came first, is the one used
expect(
vi.mocked(fakeConnectionFactory).createConnection,
).toHaveBeenCalledExactlyOnceWith(
expect.anything(),
"publisher",
TRANSPORT_1.transport,
"example.org",
expect.anything(),
expect.anything(),
);
return true;
}),
});
});
});
test("Keeps one connection per homeserver for transports with a url", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const urlTransport: TransportLocator = {
transport: { type: "livekit", url: "wss://sfu.example.org" },
serverName: "example.org",
};
const sameUrlElsewhere: TransportLocator = {
transport: urlTransport.transport,
serverName: "elsewhere.org",
};
const connectionMap$ = createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: urlTransport,
remoteTransports$: behavior("a", {
a: new Epoch([sameUrlElsewhere], 0),
}),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
expectObservable(
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("a", {
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
// Our homeserver asks the named server for the token, so each
// server name needs its own connection
expect(e.value.length).toBe(2);
expect(findConnection(urlTransport, e.value)).toBeDefined();
expect(findConnection(sameUrlElsewhere, e.value)).toBeDefined();
return true;
}),
});
});
});
test("Should start connection only once", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const connectionMap$ = createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: TRANSPORT_1,
remoteTransports$: behavior("abcdef", {
a: new Epoch([TRANSPORT_1], 0),
b: new Epoch([TRANSPORT_1], 1),
c: new Epoch([TRANSPORT_1], 2),
d: new Epoch([TRANSPORT_1], 3),
e: new Epoch([TRANSPORT_1], 4),
f: new Epoch([TRANSPORT_1, TRANSPORT_2], 5),
}),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
expectObservable(
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("xxxxxa", {
x: expect.anything(),
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
const connections = e.value;
expect(connections.length).toBe(2);
expect(
vi.mocked(fakeConnectionFactory).createConnection,
).toHaveBeenCalledTimes(2);
const conn2 = findConnection(TRANSPORT_2, connections);
expect(conn2).toBeDefined();
const conn1 = findConnection(TRANSPORT_1, connections);
expect(conn1).toBeDefined();
expect(conn1!.start).toHaveBeenCalledOnce();
return true;
}),
});
});
});
test("Should cleanup connections when not needed anymore", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const connectionMap$ = createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: TRANSPORT_1,
remoteTransports$: behavior("abc", {
a: new Epoch([TRANSPORT_1], 0),
b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1),
c: new Epoch([TRANSPORT_1], 2),
}),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
expectObservable(
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("xab", {
x: expect.anything(),
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
const connections = e.value;
expect(connections.length).toBe(2);
return true;
}),
b: expect.toSatisfy((e: Epoch<Connection[]>) => {
const connections = e.value;
expect(connections.length).toBe(1);
// The second connection should have been stopped has it is no longer needed.
const connection2 = findConnection(
TRANSPORT_2,
allCreatedConnections,
);
expect(connection2).toBeDefined();
expect(connection2!.stop).toHaveBeenCalled();
// The first connection should still be active
const conn1 = connections[0];
expect(conn1.stop).not.toHaveBeenCalledOnce();
return true;
}),
});
});
});
});
describe("ConnectionMap", () => {
test("warns when a second connection to the same URL is merged", () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const data = new ConnectionMap(logger);
const connection = TRANSPORT_1 as unknown as Connection;
const p = (identity: string): RemoteParticipant =>
({ identity }) as unknown as RemoteParticipant;
data.add(connection, [p("a")]);
data.add({ ...connection } as Connection, [p("b")]);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
'second connection to [null,"https://lk.example.org",null]: existing [a], adding [b]',
),
);
expect(data.getParticipantsForTransport(TRANSPORT_1)).toHaveLength(2);
});
});
describe("connectionMap$", () => {
// Used in test to control fake connections' remoteParticipants$ streams
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
beforeEach(() => {
fakeRemoteParticipantsStreams = new Map();
function getRemoteParticipantsFor(
transport: TransportLocator,
): Behavior<RemoteParticipant[]> {
return (
fakeRemoteParticipantsStreams.get(keyFor(transport)) ??
new BehaviorSubject([])
);
}
// need a more advanced fake connection factory
vi.mocked(fakeConnectionFactory).createConnection = vi
.fn()
.mockImplementation(
(
scope: ObservableScope,
_role: unknown,
transport: UnstableLivekitTransport,
serverName: string,
) => {
const fakeRemoteParticipants$ = new BehaviorSubject<
RemoteParticipant[]
>([]);
const mockConnection = {
transport,
serverName,
remoteParticipants$: getRemoteParticipantsFor({
transport,
serverName,
}),
} as unknown as Connection;
vi.mocked(mockConnection).start = vi.fn();
vi.mocked(mockConnection).stop = vi.fn();
// Tie the connection's lifecycle to the scope to test scope lifecycle management
scope.onEnd(() => {
void mockConnection.stop();
});
fakeRemoteParticipantsStreams.set(
keyFor({ transport, serverName }),
fakeRemoteParticipants$,
);
return mockConnection;
},
);
});
test("Should report connections with the remote participants", () => {
withTestScheduler(({ expectObservable, behavior }) => {
// Setup the fake participants streams behavior
// ==============================
fakeRemoteParticipantsStreams.set(
keyFor(TRANSPORT_1),
behavior("oa-b", {
o: [],
a: [{ identity: "user1A" } as RemoteParticipant],
b: [
{ identity: "user1A" } as RemoteParticipant,
{ identity: "user1B" } as RemoteParticipant,
],
}),
);
fakeRemoteParticipantsStreams.set(
keyFor(TRANSPORT_2),
behavior("o-a", {
o: [],
a: [{ identity: "user2A" } as RemoteParticipant],
}),
);
// ==============================
const connectionMap$ = createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: TRANSPORT_1,
remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}),
logger,
ownMembershipIdentity: ownMemberMock,
});
expectObservable(connectionMap$).toBe("abcd", {
a: expect.toSatisfy((e) => {
const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(0);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
return true;
}),
b: expect.toSatisfy((e) => {
const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
expect(
data.getParticipantsForTransport(TRANSPORT_1)[0].identity,
).toBe("user1A");
return true;
}),
c: expect.toSatisfy((e) => {
const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
expect(
data.getParticipantsForTransport(TRANSPORT_1)[0].identity,
).toBe("user1A");
expect(
data.getParticipantsForTransport(TRANSPORT_2)[0].identity,
).toBe("user2A");
return true;
}),
d: expect.toSatisfy((e) => {
const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
expect(
data.getParticipantsForTransport(TRANSPORT_1)[0].identity,
).toBe("user1A");
expect(
data.getParticipantsForTransport(TRANSPORT_1)[1].identity,
).toBe("user1B");
expect(
data.getParticipantsForTransport(TRANSPORT_2)[0].identity,
).toBe("user2A");
return true;
}),
});
});
});
});
@@ -0,0 +1,194 @@
/*
Copyright 2025-2026 Element Creations Ltd.
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type RemoteParticipant } from "livekit-client";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { combineLatest, map, of, switchMap, tap } from "rxjs";
import { type Behavior } from "../../reactive/Behavior";
import {
Epoch,
mapEpoch,
type ObservableScope,
} from "../../reactive/ObservableScope";
import { generateItemsWithEpoch } from "../../reactive/observable";
import { type ConnectionRole, type TransportLocator } from "./auth";
import { type Connection } from "./Connection";
import { type ConnectionFactory } from "./ConnectionFactory";
// Branded, so that only values returned by `keyFor` can be used as keys
export type TransportKey = string & { __transportKey: true };
/**
* The key a locator is identified by in a map or set. Locators with the same
* key share a connection: the server name only takes part in authentication
* for a transport with a `url`, where our homeserver asks that server for the
* token. A JWT service is reached directly, so members of several homeservers
* advertising the same one share a connection rather than each get their own.
*/
export function keyFor({
transport,
serverName,
}: TransportLocator): TransportKey {
const hasUrl = "url" in transport;
// Serialize the fields in a consistent order
return JSON.stringify([
hasUrl ? transport.url : null,
"livekit_service_url" in transport ? transport.livekit_service_url : null,
hasUrl ? serverName : null,
]) as TransportKey;
}
function removeDuplicateTransports(
transports: TransportLocator[],
): TransportLocator[] {
// The first locator wins, so the local one stays first in the list
const map = new Map<TransportKey, TransportLocator>();
for (const transport of transports) {
const key = keyFor(transport);
if (!map.has(key)) map.set(key, transport);
}
return [...map.values()];
}
/** The connections at one moment, with the remote participants seen on each. */
export class ConnectionMap {
private readonly map = new Map<
TransportKey,
{ connection: Connection; participants: RemoteParticipant[] }
>();
public constructor(private readonly logger?: Logger) {}
public add(connection: Connection, participants: RemoteParticipant[]): void {
const key = keyFor(connection);
const existing = this.map.get(key);
if (!existing) {
this.map.set(key, { connection, participants });
} else {
// Transports are deduplicated by key upstream, so this should never
// happen; if it does, members may be matched against the wrong room.
this.logger?.warn(
`Merging participants from a second connection to ${key}: existing [${existing.participants.map((p) => p.identity).join(", ")}], adding [${participants.map((p) => p.identity).join(", ")}]`,
);
existing.participants.push(...participants);
}
}
public getConnections(): Connection[] {
return Array.from(this.map.values()).map(({ connection }) => connection);
}
public getConnectionForTransport(
transport: TransportLocator,
): Connection | null {
return this.map.get(keyFor(transport))?.connection ?? null;
}
public getParticipantsForTransport(
transport: TransportLocator,
): RemoteParticipant[] {
return this.map.get(keyFor(transport))?.participants ?? [];
}
}
interface Props {
scope: ObservableScope;
connectionFactory: ConnectionFactory;
/** The transport we publish on; the one connection with the publisher role. */
localTransport: TransportLocator;
/** Every transport some member is on, deduplicated against the local one. */
remoteTransports$: Behavior<Epoch<TransportLocator[]>>;
logger: Logger;
ownMembershipIdentity: CallMembershipIdentityParts;
}
/**
* One connection per transport in use by one or more session members, with
* the remote participants each one sees. The connections start as they are
* created and stop as their transport drops out of the list.
*/
export function createConnectionMap$({
scope,
connectionFactory,
localTransport,
remoteTransports$,
logger: parentLogger,
ownMembershipIdentity,
}: Props): Behavior<Epoch<ConnectionMap>> {
const logger = parentLogger.getChild("[ConnectionMap]");
const localAndRemoteTransports$: Behavior<Epoch<TransportLocator[]>> =
scope.behavior(
remoteTransports$.pipe(
mapEpoch((remoteTransports) =>
removeDuplicateTransports([localTransport, ...remoteTransports]),
),
tap((transports) =>
logger.debug("localAndRemoteTransports$ = ", transports),
),
),
);
const localTransportKey = keyFor(localTransport);
const connections$ = scope.behavior(
localAndRemoteTransports$.pipe(
generateItemsWithEpoch(
"ConnectionMap connections$",
function* (transports) {
for (const transport of transports) {
const role: ConnectionRole =
keyFor(transport) === localTransportKey
? "publisher"
: "subscriber";
// The locator is an object, so a stable key stands in for it
yield { keys: [role, keyFor(transport)], data: transport };
}
},
(scope, locator$, role, _key) => {
const { transport, serverName } = locator$.value;
const connection = connectionFactory.createConnection(
scope,
role,
transport,
serverName,
ownMembershipIdentity,
logger,
);
// The state carries the progress, errors included
void connection.start();
return connection;
},
),
),
);
return scope.behavior(
connections$.pipe(
switchMap(({ value: connections, epoch }) => {
if (connections.length === 0)
return of(new Epoch(new ConnectionMap(), epoch));
return combineLatest(
connections.map((connection) =>
connection.remoteParticipants$.pipe(
map((participants) => ({ connection, participants })),
),
),
(...list) => {
const map = new ConnectionMap(logger);
for (const item of list)
map.add(item.connection, item.participants);
return new Epoch(map, epoch);
},
);
}),
),
);
}
@@ -0,0 +1,143 @@
/*
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 { EventEmitter } from "events";
import {
ConnectionState as LivekitConnectionState,
type RemoteParticipant,
RoomEvent,
} from "livekit-client";
import { BehaviorSubject } from "rxjs";
import { describe, expect, it, vi } from "vitest";
import { Epoch } from "../../reactive/ObservableScope";
import { constant } from "../../reactive/Behavior";
import { type DataPacket } from "../api";
import { type Connection, type ConnectionState } from "./Connection";
import { ConnectionMap } from "./ConnectionMap";
import { createDataChannel$ } from "./DataChannel";
import { testScope } from "../../utils/test";
import { exampleTransport } from "./test";
const encode = (text: string): Uint8Array => new TextEncoder().encode(text);
const connected: ConnectionState = {
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
};
function fakeConnection(
url: string,
state: ConnectionState = connected,
): {
connection: Connection;
room: EventEmitter;
publishData: ReturnType<typeof vi.fn>;
} {
const room = new EventEmitter();
const publishData = vi.fn(async () => Promise.resolve());
const connection = {
transport: { ...exampleTransport, livekit_service_url: url },
serverName: "example.org",
state$: constant(state),
livekitRoom: Object.assign(room, { localParticipant: { publishData } }),
} as unknown as Connection;
return { connection, room, publishData };
}
function setup(connections: Connection[]): {
received: DataPacket[];
connections$: BehaviorSubject<Epoch<ConnectionMap>>;
sendData: (topic: string, text: string) => Promise<void>;
} {
const data = new ConnectionMap();
for (const connection of connections) data.add(connection, []);
const connections$ = new BehaviorSubject(new Epoch(data));
const { data$, sendData } = createDataChannel$({
scope: testScope(),
connectionMap$: connections$,
connection$: constant(connections[0] ?? null),
});
const received: DataPacket[] = [];
data$.subscribe((message) => received.push(message));
return { received, connections$, sendData };
}
describe("data$", () => {
it("delivers a packet with its sender, topic and text", () => {
const { connection, room } = fakeConnection("https://sfu");
const { received } = setup([connection]);
room.emit(
RoomEvent.DataReceived,
encode("hello"),
{ identity: "alice" } as RemoteParticipant,
undefined,
"chat",
);
expect(received).toEqual([
{ senderId: "alice", topic: "chat", text: "hello" },
]);
});
it("drops packets from the server", () => {
const { connection, room } = fakeConnection("https://sfu");
const { received } = setup([connection]);
room.emit(RoomEvent.DataReceived, encode("server"), undefined);
expect(received).toEqual([]);
});
it("listens on every connection, including ones added later", () => {
const first = fakeConnection("https://sfu-1");
const second = fakeConnection("https://sfu-2");
const { received, connections$ } = setup([first.connection]);
const data = new ConnectionMap();
data.add(first.connection, []);
data.add(second.connection, []);
connections$.next(new Epoch(data, 1));
first.room.emit(RoomEvent.DataReceived, encode("one"), {
identity: "alice",
} as RemoteParticipant);
second.room.emit(RoomEvent.DataReceived, encode("two"), {
identity: "bob",
} as RemoteParticipant);
expect(received.map((m) => [m.senderId, m.text])).toEqual([
["alice", "one"],
["bob", "two"],
]);
});
});
describe("sendData", () => {
it("publishes the text as a reliable packet on the local connection", async () => {
const { connection, publishData } = fakeConnection("https://sfu");
const { sendData } = setup([connection]);
await sendData("chat", "hello");
expect(publishData).toHaveBeenCalledWith(encode("hello"), {
reliable: true,
topic: "chat",
});
});
it("rejects while the local transport is not connected", async () => {
const { connection, publishData } = fakeConnection("https://sfu", {
state: "initialized",
});
const { sendData } = setup([connection]);
await expect(sendData("chat", "hello")).rejects.toThrow("not connected");
expect(publishData).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
/*
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 {
ConnectionState as LivekitConnectionState,
type RemoteParticipant,
type Room as LivekitRoom,
RoomEvent,
} from "livekit-client";
import {
distinctUntilChanged,
filter,
fromEvent,
map,
merge,
type Observable,
share,
switchMap,
} from "rxjs";
import { type Behavior } from "../../reactive/Behavior";
import {
type Epoch,
type ObservableScope,
} from "../../reactive/ObservableScope";
import { type DataPacket } from "../api";
import { type Connection } from "./Connection";
import { type ConnectionMap } from "./ConnectionMap";
interface Props {
scope: ObservableScope;
connectionMap$: Behavior<Epoch<ConnectionMap>>;
/** The local member's connection, which carries what we send. */
connection$: Behavior<Connection | null>;
}
export interface DataChannel {
data$: Observable<DataPacket>;
sendData: (topic: string, text: string) => Promise<void>;
}
/**
* A text channel beside the media: LiveKit's reliable data packets, received
* on every connection the client holds and sent on the local member's. The
* sender is reported by its identity; the packet's own claim of who sent it
* never reaches the host.
*/
export function createDataChannel$({
scope,
connectionMap$,
connection$,
}: Props): DataChannel {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const rooms$ = connectionMap$.pipe(
map(({ value }) => value.getConnections().map((c) => c.livekitRoom)),
distinctUntilChanged(
(a, b) => a.length === b.length && a.every((room, i) => room === b[i]),
),
);
const data$ = rooms$.pipe(
switchMap((rooms) => merge(...rooms.map(receivedOn))),
map(({ payload, participant, topic }): DataPacket | null =>
// Only the server sends without a participant; a host has no use for it
participant === undefined
? null
: {
senderId: participant.identity,
topic: topic ?? "",
text: decoder.decode(payload),
},
),
filter((packet) => packet !== null),
scope.bind(),
share(),
);
const sendData = async (topic: string, text: string): Promise<void> => {
const connection = connection$.value;
const state = connection?.state$.value;
if (
connection === null ||
state?.state !== "authenticated" ||
state.livekitState !== LivekitConnectionState.Connected
)
throw new Error("Cannot send data: the local transport is not connected");
await connection.livekitRoom.localParticipant.publishData(
encoder.encode(text),
{ reliable: true, topic },
);
};
return { data$, sendData };
}
interface Received {
payload: Uint8Array;
participant: RemoteParticipant | undefined;
topic: string | undefined;
}
function receivedOn(room: LivekitRoom): Observable<Received> {
return fromEvent(
room,
RoomEvent.DataReceived,
(
payload: Uint8Array,
participant?: RemoteParticipant,
_kind?: unknown,
topic?: string,
): Received => ({ payload, participant, topic }),
);
}
@@ -0,0 +1,38 @@
/*
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 BaseKeyProvider, ExternalE2EEKeyProvider } from "livekit-client";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type Observable } from "rxjs";
import { E2eeType, type EncryptionSystem } from "../../encryption";
import { type MediaKey } from "../api";
import { MatrixKeyProvider } from "./MatrixKeyProvider";
/** The LiveKit key provider for the encryption the host asked for, or none. */
export function createKeyProvider(
encryptionSystem: EncryptionSystem,
mediaKeys$: Observable<MediaKey>,
logger: Logger,
): BaseKeyProvider | undefined {
switch (encryptionSystem.kind) {
case E2eeType.NONE:
return undefined;
case E2eeType.PER_PARTICIPANT: {
const keyProvider = new MatrixKeyProvider();
keyProvider.setMediaKeys(mediaKeys$);
return keyProvider;
}
case E2eeType.SHARED_KEY: {
const keyProvider = new ExternalE2EEKeyProvider();
keyProvider
.setKey(encryptionSystem.secret)
.catch((e) => logger.error("Failed to set the shared E2EE key", e));
return keyProvider;
}
}
}
@@ -0,0 +1,507 @@
/*
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 {
ConnectionState as LivekitConnectionState,
type Room as LivekitRoom,
type RemoteParticipant,
} from "livekit-client";
import fetchMock from "fetch-mock";
import { logger } from "matrix-js-sdk/lib/logger";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, NEVER } from "rxjs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { E2eeType } from "../../encryption";
import { MatrixRTCError, NoMatrix2AuthorizationService } from "../../errors";
import { constant } from "../../reactive/Behavior";
import { ObservableScope } from "../../reactive/ObservableScope";
import { type RTCMember } from "../../api";
import { defaultSessionTimings } from "../../config";
import {
flushPromises,
mockEmitter,
ownMemberMock,
testScope,
} from "../../utils/test";
import { MediaConnectionState } from "../api";
import * as auth from "./auth";
import { type TransportLocator } from "./auth";
import { type Connection, type ConnectionState } from "./Connection";
import * as connectionFactory from "./ConnectionFactory";
import { toMediaConnectionState } from "./connectionState";
import {
createLivekitBackend,
createTransportRegistry,
supportsDelegation,
} from "./LivekitBackend";
import {
exampleServiceUrl,
exampleTransport,
mockRemoteParticipant,
} from "./test";
const locator = (
transport: UnstableLivekitTransport,
serverName = "example.org",
): TransportLocator => ({ transport, serverName });
const otherTransport: UnstableLivekitTransport = {
type: "livekit",
livekit_service_url: "https://other.example.org",
};
describe("createTransportRegistry", () => {
it("connects to a transport once however many members are on it, and drops it with the last", () => {
const registry = createTransportRegistry(testScope());
const transports = (): UnstableLivekitTransport[] =>
registry.transports$.value.value.map((t) => t.transport);
const alice = new ObservableScope();
const bob = new ObservableScope();
registry.follow(alice, new BehaviorSubject(locator(exampleTransport)));
registry.follow(bob, new BehaviorSubject(locator(exampleTransport)));
expect(transports()).toEqual([exampleTransport]);
alice.end();
expect(transports()).toEqual([exampleTransport]);
bob.end();
expect(transports()).toEqual([]);
});
it("follows a member that changes transport", () => {
const registry = createTransportRegistry(testScope());
const transport$ = new BehaviorSubject<TransportLocator | undefined>(
locator(exampleTransport),
);
registry.follow(testScope(), transport$);
transport$.next(locator(otherTransport));
expect(registry.transports$.value.value).toEqual([locator(otherTransport)]);
transport$.next(undefined);
expect(registry.transports$.value.value).toEqual([]);
});
it("is safe to release after the registry's own scope has ended", () => {
const scope = new ObservableScope();
const registry = createTransportRegistry(scope);
const member = new ObservableScope();
registry.follow(member, new BehaviorSubject(locator(exampleTransport)));
scope.end();
expect(() => member.end()).not.toThrow();
});
});
describe("supportsDelegation", () => {
afterEach(() => fetchMock.reset());
const endpoint = `${exampleServiceUrl}/delegate_delayed_leave`;
it.each([
["anything but a 404", 401, true],
["a 404", 404, false],
])("reads %s as the answer", async (_name, status, expected) => {
fetchMock.post(endpoint, status);
await expect(
supportsDelegation(endpoint, "transport", logger),
).resolves.toBe(expected);
});
it("assumes no support where the probe itself fails", async () => {
fetchMock.post(endpoint, { throws: new TypeError("CORS") });
await expect(
supportsDelegation(endpoint, "transport", logger),
).resolves.toBe(false);
});
});
describe("toMediaConnectionState", () => {
it("maps every state and passes an error through", () => {
expect(toMediaConnectionState({ state: "authenticating" })).toBe(
MediaConnectionState.Preparing,
);
expect(
toMediaConnectionState({
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
}),
).toBe(MediaConnectionState.Connected);
expect(
toMediaConnectionState({
state: "authenticated",
livekitState: LivekitConnectionState.SignalReconnecting,
}),
).toBe(MediaConnectionState.Reconnecting);
expect(toMediaConnectionState({ state: "stopped" })).toBe(
MediaConnectionState.Stopped,
);
const error = new MatrixRTCError("gone");
expect(toMediaConnectionState({ state: "error", error })).toBe(error);
});
});
describe("createLivekitBackend", () => {
const homeserver = "https://matrix.example.org";
const homeserverEndpoint = `${homeserver}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`;
const urlTransport: UnstableLivekitTransport = {
type: "livekit",
url: "wss://sfu.example.org",
};
const bothTransport: UnstableLivekitTransport = {
...urlTransport,
...exampleTransport,
};
const client = {
baseUrl: homeserver,
getOpenIdToken: vi.fn(),
_unstable_getLivekitToken: vi.fn(),
_unstable_delegateDelayedLeave: vi.fn(),
};
/** The connections the stubbed factory handed out, with their participants to drive. */
let connections: {
connection: Connection;
participants$: BehaviorSubject<RemoteParticipant[]>;
state$: BehaviorSubject<ConnectionState>;
}[];
const connected: ConnectionState = {
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
};
/** Stands in for the factory: connections whose state and participants the test drives. */
function stubFactory(initialState: ConnectionState): void {
vi.spyOn(connectionFactory, "LivekitConnectionFactory").mockImplementation(
function (this: unknown) {
return {
createConnection: (
scope: ObservableScope,
role: auth.ConnectionRole,
transport: UnstableLivekitTransport,
serverName: string,
): Connection => {
const participants$ = new BehaviorSubject<RemoteParticipant[]>([]);
const state$ = new BehaviorSubject<ConnectionState>(initialState);
const connection = {
role,
transport,
serverName,
state$,
remoteParticipants$: scope.behavior(participants$),
livekitRoom: fakeLivekitRoom(),
start: vi.fn(async () => Promise.resolve()),
stop: vi.fn(async () => Promise.resolve()),
} as unknown as Connection;
connections.push({ connection, participants$, state$ });
return connection;
},
} as unknown as connectionFactory.LivekitConnectionFactory;
},
);
}
beforeEach(() => {
connections = [];
stubFactory(connected);
});
afterEach(() => {
vi.restoreAllMocks();
fetchMock.reset();
});
function backend(): ReturnType<typeof createLivekitBackend> {
return createLivekitBackend(
testScope(),
{
roomId: "!room:example.org",
slotId: "m.call#ROOM",
ownMembershipIdentity: ownMemberMock,
publish: [],
encryptionSystem: { kind: E2eeType.NONE },
mediaKeys$: NEVER,
timings: defaultSessionTimings,
logger,
},
{ client },
);
}
// Delegation goes through whichever service authenticates us as a
// publisher: the JWT service wherever the transport names one, the
// homeserver for a `url` alone
it.each([
["the JWT service", exampleTransport, 404, 401, true],
["the JWT service, and only it", bothTransport, 401, 404, false],
["the homeserver", urlTransport, 401, 404, true],
["neither", exampleTransport, 404, 404, false],
])(
"prepares the local transport and asks %s about delegation",
async (_name, transport, homeserverStatus, serviceStatus, expected) => {
fetchMock.post(homeserverEndpoint, homeserverStatus);
fetchMock.post(
`${exampleServiceUrl}/delegate_delayed_leave`,
serviceStatus,
);
const capabilities = await backend().prepareLocalTransport(transport);
expect(capabilities).toEqual({ canDelegateDelayedLeave: expected });
expect(connections).toHaveLength(1);
expect(connections[0].connection.role).toBe("publisher");
expect(connections[0].connection.transport).toEqual(transport);
expect(connections[0].connection.serverName).toBe("example.org");
},
);
it("waits for the publisher connection to hold its token before the join", async () => {
fetchMock.post(homeserverEndpoint, 404);
fetchMock.post(`${exampleServiceUrl}/delegate_delayed_leave`, 404);
stubFactory({ state: "authenticating" });
let prepared = false;
const preparing = backend()
.prepareLocalTransport(exampleTransport)
.then(() => (prepared = true));
await flushPromises();
expect(prepared).toBe(false);
connections[0].state$.next(connected);
await preparing;
expect(prepared).toBe(true);
});
it("fails the preparation with the publisher connection's error", async () => {
fetchMock.post(homeserverEndpoint, 404);
fetchMock.post(`${exampleServiceUrl}/delegate_delayed_leave`, 404);
stubFactory({ state: "authenticating" });
const preparing = backend().prepareLocalTransport(exampleTransport);
await flushPromises();
const error = new NoMatrix2AuthorizationService(new Error("404"));
connections[0].state$.next({ state: "error", error });
await expect(preparing).rejects.toBe(error);
});
it("delegates the leave through the flow that authenticated the publisher", async () => {
fetchMock.post(homeserverEndpoint, 401);
const delegate = vi.spyOn(auth, "delegateDelayedLeave").mockResolvedValue();
const b = backend();
await expect(b.delegateDelayedLeave("delay-1")).rejects.toThrow(
"No local transport",
);
await b.prepareLocalTransport(urlTransport);
await b.delegateDelayedLeave("delay-1");
expect(delegate).toHaveBeenCalledWith(
expect.objectContaining({
client,
membership: ownMemberMock,
transport: urlTransport,
roomId: "!room:example.org",
slotId: "m.call#ROOM",
delayId: "delay-1",
delayTimeoutMs: defaultSessionTimings.delegatedDelayedLeave.delay_ms,
}),
);
});
describe("mediaFor", () => {
// A member advertising both a `url` and a `livekit_service_url` may have
// authenticated through either flow, so it has two candidate identities
const legacyId = "@bob:example.org:DEV000";
const hashedId = "2qkXfWz1S7jQmU0bJ6yN8aVtR3pLcDgHe9oZxKwMiA4";
function bob(
transport: UnstableLivekitTransport,
backendIdentities: string[],
): RTCMember {
return {
local: false,
rtcBackendIdentity: legacyId,
backendIdentities,
userId: "@bob:example.org",
deviceId: "DEV000",
memberId: legacyId,
transport$: constant({
type: "livekit",
id: JSON.stringify(transport),
raw: transport,
resolved$: constant(undefined),
}),
applicationData$: constant({}),
displayName$: constant("Bob"),
avatarUrl$: constant(undefined),
};
}
async function prepared(): Promise<
ReturnType<typeof createLivekitBackend>
> {
fetchMock.post(homeserverEndpoint, 404);
fetchMock.post(`${exampleServiceUrl}/delegate_delayed_leave`, 404);
const b = backend();
await b.prepareLocalTransport(bothTransport);
return b;
}
it.each([
["legacy", legacyId],
["hashed", hashedId],
])(
"matches a member with two candidate identities publishing under the %s one",
async (_name, identity) => {
const b = await prepared();
const media = b.mediaFor(
testScope(),
bob(bothTransport, [hashedId, legacyId]),
);
await flushPromises();
// Bob is on our transport, so his media arrives on the publisher
// connection
expect(connections).toHaveLength(1);
expect(media.tracks$.value).toBeNull();
connections[0].participants$.next([
mockRemoteParticipant({ identity, trackPublications: new Map() }),
]);
await flushPromises();
expect(media.tracks$.value).toEqual([]);
},
);
it("does not match a member with two candidate identities to anyone else", async () => {
const b = await prepared();
const media = b.mediaFor(
testScope(),
bob(bothTransport, [hashedId, legacyId]),
);
connections[0].participants$.next([
mockRemoteParticipant({
identity: "@stranger:example.org:DEV999",
trackPublications: new Map(),
}),
]);
await flushPromises();
expect(media.tracks$.value).toBeNull();
});
it("has no media for a member on another transport until it shows up there", async () => {
const b = await prepared();
const media = b.mediaFor(testScope(), bob(otherTransport, [legacyId]));
await flushPromises();
const remote = connections.find(
(c) => c.connection.role === "subscriber",
);
expect(remote?.connection.transport).toEqual(otherTransport);
// The same identity on our own transport is someone else's room
connections[0].participants$.next([
mockRemoteParticipant({
identity: legacyId,
trackPublications: new Map(),
}),
]);
await flushPromises();
expect(media.tracks$.value).toBeNull();
remote!.participants$.next([
mockRemoteParticipant({
identity: legacyId,
trackPublications: new Map(),
}),
]);
await flushPromises();
expect(media.tracks$.value).toEqual([]);
});
it("follows a member to a new transport, and drops the old connection with it", async () => {
const b = await prepared();
const transport$ = new BehaviorSubject(
bob(bothTransport, [legacyId]).transport$.value,
);
const media = b.mediaFor(testScope(), {
...bob(bothTransport, [legacyId]),
transport$,
});
connections[0].participants$.next([
mockRemoteParticipant({
identity: legacyId,
trackPublications: new Map(),
}),
]);
await flushPromises();
expect(media.tracks$.value).toEqual([]);
transport$.next(bob(otherTransport, [legacyId]).transport$.value);
await flushPromises();
expect(media.tracks$.value).toBeNull();
const remote = connections.find(
(c) => c.connection.role === "subscriber",
);
expect(remote?.connection.transport).toEqual(otherTransport);
expect(b.connections$.value.map(({ transport }) => transport)).toEqual([
bothTransport,
otherTransport,
]);
transport$.next(bob(bothTransport, [legacyId]).transport$.value);
await flushPromises();
expect(media.tracks$.value).toEqual([]);
expect(b.connections$.value.map(({ transport }) => transport)).toEqual([
bothTransport,
]);
});
it("has no media for a member without a transport, and opens no connection", async () => {
const b = await prepared();
const media = b.mediaFor(testScope(), {
...bob(bothTransport, [legacyId]),
transport$: constant(undefined),
});
await flushPromises();
expect(media.tracks$.value).toBeNull();
expect(connections).toHaveLength(1);
});
it("connects to a remote member's transport on the member's homeserver", async () => {
const b = await prepared();
const carlsTransport: UnstableLivekitTransport = {
type: "livekit",
url: "wss://sfu.sample.com",
};
b.mediaFor(testScope(), {
...bob(carlsTransport, ["carl-hashed"]),
userId: "@carl:sample.com",
});
await flushPromises();
const remote = connections.find(
(c) => c.connection.role === "subscriber",
);
expect(remote?.connection.transport).toEqual(carlsTransport);
expect(remote?.connection.serverName).toBe("sample.com");
});
});
});
/** The parts of a LiveKit room the local media and the member media read. */
function fakeLivekitRoom(): LivekitRoom {
return {
options: {},
setE2EEEnabled: vi.fn(),
localParticipant: {
...mockEmitter(),
trackPublications: new Map(),
getTrackPublication: () => undefined,
},
remoteParticipants: new Map(),
...mockEmitter(),
} as unknown as LivekitRoom;
}
@@ -0,0 +1,389 @@
/*
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 MatrixClient } from "matrix-js-sdk";
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
isUnstableLivekitTransport,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
filter,
firstValueFrom,
map,
type Observable,
of,
switchMap,
} from "rxjs";
import { type ResolvedTransport, type RTCMember } from "../../api";
import { type MediaQuality } from "../../config";
import { MatrixRTCError } from "../../errors";
import { type MemberMedia } from "../../media-api";
import { type Behavior } from "../../reactive/Behavior";
import {
Epoch,
type ObservableScope,
trackEpoch,
} from "../../reactive/ObservableScope";
import { mapScoped } from "../../utils/mapScoped";
import { serverNameOf } from "../../utils/serverName";
import {
type BackendConnection,
type MediaBackend,
type MediaBackendContext,
type TransportCapabilities,
} from "../api";
import {
chooseAuthFlow,
delegateDelayedLeave,
type TransportLocator,
} from "./auth";
import { type Connection } from "./Connection";
import {
ConnectionMap,
createConnectionMap$,
keyFor,
type TransportKey,
} from "./ConnectionMap";
import {
LivekitConnectionFactory,
ResolvedConnection,
} from "./ConnectionFactory";
import { toMediaConnectionState } from "./connectionState";
import { createDataChannel$ } from "./DataChannel";
import { createKeyProvider } from "./KeyProvider";
import { createLivekitMemberMedia } from "./LivekitMemberMedia";
import { createLivekitLocalMedia } from "./LocalMedia";
export interface LivekitBackendOptions {
/**
* `getOpenIdToken` for the JWT service flow, the MSC4195 methods for the
* client-server API flow, `baseUrl` for the delegation probe and handover.
*/
client: Pick<
MatrixClient,
| "getOpenIdToken"
| "_unstable_getLivekitToken"
| "_unstable_delegateDelayedLeave"
| "baseUrl"
>;
mediaQuality?: MediaQuality;
}
/**
* MatrixRTC media over LiveKit SFUs: one room per transport, the local one
* published on, every remote one subscribed to, each authenticated for its
* role through the homeserver or the transport's JWT service.
*/
export function createLivekitBackend(
scope: ObservableScope,
{
roomId,
slotId,
ownMembershipIdentity,
publish,
encryptionSystem,
mediaKeys$,
timings,
logger: parentLogger,
}: MediaBackendContext,
{ client, mediaQuality }: LivekitBackendOptions,
): MediaBackend {
const logger = parentLogger.getChild("[LivekitBackend]");
const keyProvider = createKeyProvider(encryptionSystem, mediaKeys$, logger);
const transports = createTransportRegistry(scope);
const localTransport$ = new BehaviorSubject<TransportLocator | null>(null);
const audioOutputDeviceId$ = new BehaviorSubject<string | undefined>(
undefined,
);
const connectionFactory = new LivekitConnectionFactory(
client,
roomId,
slotId,
publish,
audioOutputDeviceId$,
keyProvider,
mediaQuality,
);
// 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
const connectionMap$ = scope.behavior<Epoch<ConnectionMap>>(
mapScoped(scope, localTransport$, (mapScope, localTransport) =>
createConnectionMap$({
scope: mapScope,
connectionFactory,
localTransport,
remoteTransports$: transports.transports$,
logger,
ownMembershipIdentity,
}),
).pipe(switchMap((map$) => map$ ?? of(new Epoch(new ConnectionMap())))),
);
const localMedia_ = createLivekitLocalMedia({
scope,
connectionMap$,
localTransport$,
publish,
audioOutputDeviceId$,
encryptionSystem,
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,
);
const prepareLocalTransport = async (
transport: Transport,
): Promise<TransportCapabilities> => {
if (!isUnstableLivekitTransport(transport))
throw new MatrixRTCError(`Not a LiveKit transport: ${transport.type}`);
const locator: TransportLocator = {
transport,
serverName: serverNameOf(ownMembershipIdentity.userId),
};
// Delegation goes through whichever service authenticates us as a
// publisher, so only that service's support counts
const flow = chooseAuthFlow(transport, "publisher");
const canDelegate =
flow.kind === "openID"
? supportsDelegation(
`${flow.serviceUrl}/delegate_delayed_leave`,
`transport ${flow.serviceUrl}`,
logger,
)
: homeserverSupportsDelegation;
localTransport$.next(locator);
// The membership is only sent once the publisher connection holds its
// token, so that the LiveKit room exists before anyone subscribes to it:
// a subscriber lacks the permission to create it and would fail without
// retrying
const [, canDelegateDelayedLeave] = await Promise.all([
authenticated(localMedia_.connection$),
canDelegate,
]);
return { canDelegateDelayedLeave };
};
const delegateLeave = async (delayId: string): Promise<void> => {
const local = localTransport$.value;
if (local === null)
throw new MatrixRTCError("No local transport to delegate the leave to");
await delegateDelayedLeave({
client,
membership: ownMembershipIdentity,
transport: local.transport,
roomId,
slotId,
delayId,
delayTimeoutMs: timings.delegatedDelayedLeave.delay_ms,
logger,
});
};
const mediaFor = (
memberScope: ObservableScope,
member: RTCMember,
): MemberMedia => {
const { backendIdentities } = member;
const locator$ = memberScope.behavior(
member.transport$.pipe(
map((transport): TransportLocator | undefined => {
const raw = transport?.raw;
return isUnstableLivekitTransport(raw)
? { transport: raw, serverName: serverNameOf(member.userId) }
: undefined;
}),
distinctUntilChanged((a, b) => (a && keyFor(a)) === (b && keyFor(b))),
),
);
transports.follow(memberScope, locator$);
const source$ = memberScope.behavior(
combineLatest([locator$, connectionMap$]).pipe(
map(([locator, { value: connections }]) => {
if (locator === undefined) return null;
const connection = connections.getConnectionForTransport(locator);
// A member advertising both a `url` and a `livekit_service_url` may
// have authenticated through either flow, under either identity
const participant = connections
.getParticipantsForTransport(locator)
.find((p) => backendIdentities.includes(p.identity));
return connection && participant
? { participant, room: connection.livekitRoom }
: null;
}),
distinctUntilChanged(
(a, b) => a?.participant === b?.participant && a?.room === b?.room,
),
),
);
source$.pipe(memberScope.bind()).subscribe((source) => {
logger.info(
`${member.rtcBackendIdentity}: LiveKit participant ${source ? `matched (${source.participant.sid})` : "missing"} on ${locator$.value ? JSON.stringify(locator$.value.transport) : "no transport"}`,
);
});
return createLivekitMemberMedia(memberScope, source$, encryptionSystem);
};
const connections$ = scope.behavior<BackendConnection[]>(
connectionMap$.pipe(
switchMap(({ value }) => {
const connections = value.getConnections();
if (connections.length === 0) return of([]);
return combineLatest(connections.map(backendConnection$));
}),
),
);
const { data$, sendData } = createDataChannel$({
scope,
connectionMap$,
connection$: localMedia_.connection$,
});
return {
transportType: "livekit",
prepareLocalTransport,
delegateDelayedLeave: delegateLeave,
local: localMedia_,
mediaFor,
connections$,
setAudioOutputDeviceId: localMedia_.setAudioOutputDeviceId,
sendData,
data$,
};
}
/** Resolves once the connection holds its token; rejects with its error otherwise. */
async function authenticated(
connection$: Behavior<Connection | null>,
): Promise<void> {
const state = await firstValueFrom(
connection$.pipe(
switchMap((connection) => connection?.state$ ?? of(null)),
filter(
(state) =>
state !== null &&
(state.state === "authenticated" ||
state.state === "error" ||
state.state === "stopped"),
),
),
);
if (state?.state === "error") throw state.error;
if (state?.state === "stopped")
throw new MatrixRTCError(
"The local connection stopped before it was authenticated",
);
}
function backendConnection$(
connection: Connection,
): Observable<BackendConnection> {
const resolved$: Observable<ResolvedTransport | undefined> =
connection instanceof ResolvedConnection
? connection.resolved$
: of(undefined);
return combineLatest([connection.state$, resolved$]).pipe(
map(([state, resolved]): BackendConnection => ({
transport: connection.transport,
state: toMediaConnectionState(state),
resolved,
})),
);
}
export interface TransportRegistry {
/** Keeps a member's transport registered for as long as its scope lives. */
follow(
scope: ObservableScope,
locator$: Behavior<TransportLocator | undefined>,
): void;
/** Every transport some member is on, for the connection map. */
transports$: Behavior<Epoch<TransportLocator[]>>;
}
/**
* The remote transports to connect to, counted per member so that a transport
* stays while any member names it. A release after the scope has ended only
* touches the map, so it is safe in any order of teardown.
*/
export function createTransportRegistry(
scope: ObservableScope,
): TransportRegistry {
const counts = new Map<
TransportKey,
{ locator: TransportLocator; count: number }
>();
const transports = new BehaviorSubject<TransportLocator[]>([]);
const publish = (): void =>
transports.next([...counts.values()].map(({ locator }) => locator));
const register = (locator: TransportLocator): (() => void) => {
const key = keyFor(locator);
const entry = counts.get(key) ?? { locator, count: 0 };
entry.count++;
if (entry.count === 1) {
counts.set(key, entry);
publish();
}
return () => {
if (--entry.count > 0) return;
counts.delete(key);
publish();
};
};
return {
follow: (memberScope, locator$) => {
let release: (() => void) | undefined;
locator$.pipe(memberScope.bind()).subscribe((locator) => {
release?.();
release = locator && register(locator);
});
memberScope.onEnd(() => release?.());
},
transports$: scope.behavior(transports.pipe(trackEpoch())),
};
}
/**
* Whether a service can take over restarting the delayed leave. The endpoint
* is hit without credentials and read for a 404, not retried: many servers
* predate the endpoint altogether and answer with a CORS failure that a retry
* loop would only repeat.
*/
export async function supportsDelegation(
url: string,
serviceName: string,
logger: Logger,
): Promise<boolean> {
try {
const res = await fetch(url, { method: "POST" });
const supported = res.status !== 404;
logger.info(
`${serviceName} ${supported ? "supports" : "does not support"} delegation`,
);
return supported;
} catch (e) {
logger.warn(
`Failed to determine whether ${serviceName} supports delegation, assuming no support`,
e,
);
return false;
}
}
@@ -0,0 +1,267 @@
/*
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 { EventEmitter } from "events";
import {
type LocalParticipant,
ParticipantEvent,
RemoteAudioTrack,
type RemoteParticipant,
type Room as LivekitRoom,
RoomEvent,
Track,
TrackEvent,
type TrackPublication,
} from "livekit-client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ObservableScope } from "../../reactive/ObservableScope";
import { type AudioMediaTrack } from "../../media-api";
import {
createLivekitMediaTrack,
createLocalLivekitMediaTrack,
} from "./LivekitMediaTrack";
describe("createLivekitMediaTrack", () => {
let scope: ObservableScope;
beforeEach(() => {
scope = new ObservableScope();
});
afterEach(() => {
scope.end();
vi.useRealTimers();
});
it("describes the publication", () => {
const { participant, publication, room } = fakes();
const track = createLivekitMediaTrack(
scope,
participant,
publication as unknown as TrackPublication,
room,
);
expect(track.kind).toBe("video");
expect(track.source).toBe("camera");
expect(track.id).toBe("TR_1");
expect(track.encrypted).toBe(true);
});
it("attaches elements to the track, even one that arrives later", () => {
const { participant, publication, room, emitter } = fakes({
track: undefined,
});
const element = document.createElement("video");
const track = createLivekitMediaTrack(
scope,
participant,
publication as unknown as TrackPublication,
room,
);
track.attach(element);
track.attach(element);
const livekitTrack = fakeTrack();
publication.track = livekitTrack;
emitter.emit(TrackEvent.Subscribed, livekitTrack);
expect(livekitTrack.attach).toHaveBeenCalledTimes(1);
expect(livekitTrack.attach).toHaveBeenCalledWith(element);
track.detach(element);
expect(livekitTrack.detach).toHaveBeenCalledWith(element);
});
it("detaches everything when its scope ends", () => {
const { participant, publication, room } = fakes();
const element = document.createElement("video");
const scope = new ObservableScope();
const track = createLivekitMediaTrack(
scope,
participant,
publication as unknown as TrackPublication,
room,
);
track.attach(element);
scope.end();
expect(publication.track!.detach).toHaveBeenCalledWith(element);
});
it("follows the mute state", () => {
const { participant, publication, room, emitter } = fakes();
const track = createLivekitMediaTrack(
scope,
participant,
publication as unknown as TrackPublication,
room,
);
expect(track.muted$.value).toBe(false);
publication.isMuted = true;
emitter.emit(ParticipantEvent.TrackMuted, publication);
expect(track.muted$.value).toBe(true);
});
it("scales a remote member's volume on the participant", () => {
const { participant, publication, room } = fakes({
kind: Track.Kind.Audio,
});
const track = createLivekitMediaTrack(
scope,
participant,
publication as unknown as TrackPublication,
room,
) as AudioMediaTrack;
track.setVolume(0.5);
expect(publication.track!.setVolume).toHaveBeenCalledWith(0.5);
});
it("is active while the member speaks and the track is not muted", () => {
const { participant, publication, room, emitter } = fakes({
kind: Track.Kind.Audio,
});
const track = createLivekitMediaTrack(
scope,
participant,
publication as unknown as TrackPublication,
room,
) as AudioMediaTrack;
expect(track.isActive$.value).toBe(false);
participant.isSpeaking = true;
emitter.emit(ParticipantEvent.IsSpeakingChanged, true);
expect(track.isActive$.value).toBe(true);
publication.isMuted = true;
emitter.emit(ParticipantEvent.TrackMuted, publication);
expect(track.isActive$.value).toBe(false);
});
it("reports the audio level the room announces, 0 while muted", () => {
const { participant, publication, room, emitter } = fakes({
kind: Track.Kind.Audio,
});
const track = createLivekitMediaTrack(
scope,
participant,
publication as unknown as TrackPublication,
room,
) as AudioMediaTrack;
expect(track.audioLevel$.value).toBe(0);
participant.audioLevel = 0.4;
(room as unknown as EventEmitter).emit(RoomEvent.ActiveSpeakersChanged, [
participant,
]);
expect(track.audioLevel$.value).toBe(0.4);
publication.isMuted = true;
emitter.emit(ParticipantEvent.TrackMuted, publication);
expect(track.audioLevel$.value).toBe(0);
});
it("polls statistics at the given interval, only while subscribed", async () => {
vi.useFakeTimers();
const { participant, publication, room } = fakes();
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
publication.track!.getRTCStatsReport.mockResolvedValue(
new Map([["s", stats]]),
);
const track = createLivekitMediaTrack(
scope,
participant,
publication as unknown as TrackPublication,
room,
);
const stats$ = track.stats$(500);
await vi.advanceTimersByTimeAsync(1000);
expect(publication.track!.getRTCStatsReport).not.toHaveBeenCalled();
const seen = vi.fn();
const subscription = stats$.subscribe(seen);
await vi.advanceTimersByTimeAsync(0);
expect(publication.track!.getRTCStatsReport).toHaveBeenCalledTimes(1);
expect(seen).toHaveBeenLastCalledWith(stats);
await vi.advanceTimersByTimeAsync(1000);
expect(publication.track!.getRTCStatsReport).toHaveBeenCalledTimes(3);
subscription.unsubscribe();
await vi.advanceTimersByTimeAsync(1000);
expect(publication.track!.getRTCStatsReport).toHaveBeenCalledTimes(3);
});
it("gives our own track its controls", async () => {
const { participant, publication, room } = fakes({
kind: Track.Kind.Audio,
});
const setEnabled = vi.fn().mockResolvedValue(false);
const switchActiveDevice = vi.fn().mockResolvedValue(true);
const track = createLocalLivekitMediaTrack(
scope,
Object.assign(participant, {
isLocal: true,
}) as unknown as LocalParticipant,
publication as unknown as TrackPublication,
Object.assign(room, { switchActiveDevice }),
setEnabled,
);
await expect(track.setEnabled(false)).resolves.toBe(false);
expect(setEnabled).toHaveBeenCalledWith("microphone", false);
await track.setDevice("mic-2");
expect(switchActiveDevice).toHaveBeenCalledWith("audioinput", "mic-2");
});
});
type FakeTrack = Track & {
attach: ReturnType<typeof vi.fn>;
detach: ReturnType<typeof vi.fn>;
setVolume: ReturnType<typeof vi.fn>;
getRTCStatsReport: ReturnType<typeof vi.fn>;
};
type FakePublication = Omit<TrackPublication, "track" | "isMuted"> & {
track: FakeTrack | undefined;
isMuted: boolean;
};
function fakeTrack(): FakeTrack {
return Object.assign(Object.create(RemoteAudioTrack.prototype), {
attach: vi.fn(),
detach: vi.fn(),
setVolume: vi.fn(),
setAudioContext: vi.fn(),
setWebAudioPlugins: vi.fn(),
getRTCStatsReport: vi.fn().mockResolvedValue(undefined),
}) as FakeTrack;
}
function fakes({
track = fakeTrack(),
kind = Track.Kind.Video,
}: { track?: FakeTrack | undefined; kind?: Track.Kind } = {}): {
participant: RemoteParticipant & { isSpeaking: boolean; audioLevel: number };
publication: FakePublication;
room: LivekitRoom;
emitter: EventEmitter;
} {
const emitter = new EventEmitter();
const participant = Object.assign(emitter, {
isLocal: false,
isSpeaking: false,
audioLevel: 0,
identity: "@alice:example.org:DEVICE",
getTrackPublication: (): TrackPublication =>
publication as unknown as TrackPublication,
}) as unknown as RemoteParticipant & {
isSpeaking: boolean;
audioLevel: number;
};
const publication = Object.assign(emitter, {
kind,
source:
kind === Track.Kind.Audio ? Track.Source.Microphone : Track.Source.Camera,
trackSid: "TR_1",
isMuted: false,
isEncrypted: true,
track,
}) as unknown as FakePublication;
const room = new EventEmitter() as unknown as LivekitRoom;
return { participant, publication, room, emitter };
}
@@ -0,0 +1,293 @@
/*
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 {
observeParticipantEvents,
observeParticipantMedia,
roomEventSelector,
} from "@livekit/components-core";
import {
facingModeFromLocalTrack,
type LocalParticipant,
LocalTrack,
LocalVideoTrack,
type Participant,
ParticipantEvent,
RemoteAudioTrack,
RemoteTrack,
type Room as LivekitRoom,
RoomEvent,
Track,
TrackEvent,
type TrackPublication,
} from "livekit-client";
import {
combineLatest,
distinctUntilChanged,
fromEvent,
map,
merge,
type Observable,
of,
startWith,
switchMap,
timer,
} from "rxjs";
import { type Behavior } from "../../reactive/Behavior";
import { type ObservableScope } from "../../reactive/ObservableScope";
import {
type AudioMediaTrack,
type LocalAudioMediaTrack,
type LocalVideoMediaTrack,
type MediaSource,
type MediaStreamStats,
type MediaTrack,
type VideoMediaTrack,
} from "../../media-api";
import { MatrixRTCError } from "../../errors";
import { convertToLivekitProcessor } from "./videoProcessor";
export const mediaSources: Record<Track.Source, MediaSource> = {
[Track.Source.Microphone]: "microphone",
[Track.Source.Camera]: "camera",
[Track.Source.ScreenShare]: "screenShare",
[Track.Source.ScreenShareAudio]: "screenShareAudio",
[Track.Source.Unknown]: "unknown",
};
export const livekitSources: Record<MediaSource, Track.Source> = {
microphone: Track.Source.Microphone,
camera: Track.Source.Camera,
screenShare: Track.Source.ScreenShare,
screenShareAudio: Track.Source.ScreenShareAudio,
unknown: Track.Source.Unknown,
};
/**
* One publication of a participant as a `MediaTrack`. The publication is
* fixed; the track behind it may come and go (a remote track arrives on
* subscription), and every attached element follows it.
*/
export function createLivekitMediaTrack(
scope: ObservableScope,
participant: Participant,
publication: TrackPublication,
room: LivekitRoom,
): AudioMediaTrack | VideoMediaTrack {
const mediaChanged$ = observeParticipantMedia(participant);
const track$ = publicationTrack$(scope, participant, publication);
const attached = new Set<HTMLMediaElement>();
let audioContext: AudioContext | undefined;
let audioPlugins: AudioNode[] = [];
let volume: number | undefined;
const applyAudioSettings = (track: Track | undefined): void => {
if (!(track instanceof RemoteAudioTrack)) return;
track.setAudioContext(audioContext);
track.setWebAudioPlugins(audioPlugins);
if (volume !== undefined) track.setVolume(volume);
};
let current = track$.value;
track$.pipe(scope.bind()).subscribe((track) => {
for (const element of attached) current?.detach(element);
current = track;
applyAudioSettings(track);
for (const element of attached) track?.attach(element);
});
scope.onEnd(() => {
for (const element of attached) current?.detach(element);
attached.clear();
});
const muted$ = scope.behavior(
mediaChanged$.pipe(map(() => publication.isMuted)),
publication.isMuted,
);
const base: MediaTrack = {
source: mediaSources[publication.source],
kind: publication.kind === Track.Kind.Audio ? "audio" : "video",
id: publication.trackSid,
muted$,
encrypted: publication.isEncrypted,
stats$: (intervalMs): Observable<MediaStreamStats> =>
timer(0, intervalMs).pipe(
switchMap(async () => rtpStreamStats(publication, participant.isLocal)),
scope.bind(),
),
attach: (element) => {
if (attached.has(element)) return;
attached.add(element);
current?.attach(element);
},
detach: (element) => {
if (!attached.delete(element)) return;
current?.detach(element);
},
};
if (base.kind === "audio")
return {
...base,
kind: "audio",
// LiveKit detects speakers per participant, so this is the member's
// activity, narrowed to the tracks that can be carrying it
isActive$: scope.behavior(
combineLatest(
[
observeParticipantEvents(
participant,
ParticipantEvent.IsSpeakingChanged,
).pipe(map((p) => p.isSpeaking)),
muted$,
],
(speaking, muted) => speaking && !muted,
),
participant.isSpeaking && !publication.isMuted,
),
// The level has no event of its own: it is written to the participant
// with each speaker update, which the room announces
audioLevel$: scope.behavior(
combineLatest(
[
roomEventSelector(room, RoomEvent.ActiveSpeakersChanged).pipe(
map(() => participant.audioLevel),
startWith(participant.audioLevel),
),
muted$,
],
(level, muted) => (muted ? 0 : level),
),
),
setAudioContext: (ctx, plugins = []) => {
audioContext = ctx;
audioPlugins = plugins;
applyAudioSettings(current);
},
setVolume: (v) => {
// Our own audio is never played back, so there is nothing to scale
volume = v;
applyAudioSettings(current);
},
};
return { ...base, kind: "video" };
}
/**
* One of our own publications, with the controls over it. `setEnabled` goes
* through the local member so that what it remembers for a reconnection
* stays in step.
*/
export function createLocalLivekitMediaTrack(
scope: ObservableScope,
participant: LocalParticipant,
publication: TrackPublication,
room: LivekitRoom,
setEnabled: (source: MediaSource, enabled: boolean) => Promise<boolean>,
): LocalAudioMediaTrack | LocalVideoMediaTrack {
const base = createLivekitMediaTrack(scope, participant, publication, room);
const local = {
setEnabled: async (enabled: boolean) => setEnabled(base.source, enabled),
setDevice: async (deviceId: string): Promise<void> => {
if (base.source !== "microphone" && base.source !== "camera")
throw new MatrixRTCError(`A ${base.source} track has no device`);
await room.switchActiveDevice(
base.kind === "audio" ? "audioinput" : "videoinput",
deviceId,
);
},
};
if (base.kind === "audio") return { ...base, ...local };
const videoTrack = (): LocalVideoTrack | undefined =>
publication.track instanceof LocalVideoTrack
? publication.track
: undefined;
return {
...base,
...local,
facingMode$: facingMode$(
scope,
publicationTrack$(scope, participant, publication),
),
switchFacingMode: async () => {
const track = videoTrack();
if (!track) return;
const { facingMode } = facingModeFromLocalTrack(track);
if (facingMode !== "user" && facingMode !== "environment") return;
await track.restartTrack({
facingMode: facingMode === "user" ? "environment" : "user",
});
return track.mediaStreamTrack.getSettings().deviceId;
},
setProcessor: async (processor) => {
const track = videoTrack();
// A processor cannot be built on a track that has already ended
if (!track || track.mediaStreamTrack.readyState === "ended") return;
if (processor)
await track.setProcessor(convertToLivekitProcessor(processor));
else if (track.getProcessor()) await track.stopProcessor();
},
};
}
/** The track behind a publication; a remote one arrives on subscription. */
function publicationTrack$(
scope: ObservableScope,
participant: Participant,
publication: TrackPublication,
): Behavior<Track | undefined> {
return scope.behavior(
merge(
observeParticipantMedia(participant),
fromEvent(publication, TrackEvent.Subscribed),
fromEvent(publication, TrackEvent.Unsubscribed),
).pipe(
map(() => publication.track),
startWith(publication.track),
distinctUntilChanged(),
),
);
}
async function rtpStreamStats(
publication: TrackPublication,
local: boolean,
): Promise<MediaStreamStats> {
const track = publication.track;
if (!(track instanceof RemoteTrack || track instanceof LocalTrack))
return undefined;
const report = await track.getRTCStatsReport();
if (!report) return undefined;
const type = local ? "outbound-rtp" : "inbound-rtp";
for (const stats of report.values()) if (stats.type === type) return stats;
return undefined;
}
function facingMode$(
scope: ObservableScope,
track$: Behavior<Track | undefined>,
): Behavior<"user" | "environment" | undefined> {
return scope.behavior(
track$.pipe(
switchMap((track) => {
if (!(track instanceof LocalVideoTrack)) return of(undefined);
return fromEvent(track, TrackEvent.Restarted).pipe(
startWith(null),
map(() => {
const { facingMode } = facingModeFromLocalTrack(track);
return facingMode === "user" || facingMode === "environment"
? facingMode
: undefined;
}),
);
}),
),
);
}
@@ -0,0 +1,106 @@
/*
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 { EventEmitter } from "events";
import {
ParticipantEvent,
type RemoteParticipant,
type Room as LivekitRoom,
Track,
type TrackPublication,
} from "livekit-client";
import { describe, expect, it } from "vitest";
import { E2eeType } from "../../encryption";
import { constant } from "../../reactive/Behavior";
import { testScope } from "../../utils/test";
import { createLivekitMemberMedia } from "./LivekitMemberMedia";
describe("createLivekitMemberMedia", () => {
it("lists one stable track per publication, in publication order", () => {
const { participant, publications, room, emitter } = fakes();
const media = createLivekitMemberMedia(
testScope(),
constant({ participant, room }),
{ kind: E2eeType.NONE },
);
expect(media.tracks$.value).toEqual([]);
const microphone = publish(publications, "TR_1", Track.Source.Microphone);
const camera = publish(publications, "TR_2", Track.Source.Camera);
emitter.emit(ParticipantEvent.TrackPublished, camera);
const [first, second] = media.tracks$.value!;
expect(first.source).toBe("microphone");
expect(second.source).toBe("camera");
// An unrelated media event keeps the same objects
microphone.isMuted = true;
emitter.emit(ParticipantEvent.TrackMuted, microphone);
expect(media.tracks$.value![0]).toBe(first);
expect(media.tracks$.value![1]).toBe(second);
publications.delete("TR_1");
emitter.emit(ParticipantEvent.TrackUnpublished, microphone);
expect(media.tracks$.value).toEqual([second]);
});
it("includes a track published without a source", () => {
const { participant, publications, room, emitter } = fakes();
const media = createLivekitMemberMedia(
testScope(),
constant({ participant, room }),
{ kind: E2eeType.NONE },
);
emitter.emit(
ParticipantEvent.TrackPublished,
publish(publications, "TR_1", Track.Source.Unknown),
);
expect(media.tracks$.value!.map((t) => t.source)).toEqual(["unknown"]);
});
});
type FakePublication = { isMuted: boolean } & Omit<TrackPublication, "isMuted">;
function publish(
publications: Map<string, TrackPublication>,
trackSid: string,
source: Track.Source,
): FakePublication {
const publication = Object.assign(new EventEmitter(), {
trackSid,
source,
kind:
source === Track.Source.Camera || source === Track.Source.ScreenShare
? Track.Kind.Video
: Track.Kind.Audio,
isMuted: false,
isEncrypted: true,
track: undefined,
}) as unknown as FakePublication;
publications.set(trackSid, publication as unknown as TrackPublication);
return publication;
}
function fakes(): {
participant: RemoteParticipant;
publications: Map<string, TrackPublication>;
room: LivekitRoom;
emitter: EventEmitter;
} {
const emitter = new EventEmitter();
const publications = new Map<string, TrackPublication>();
const participant = Object.assign(emitter, {
isLocal: false,
isSpeaking: false,
identity: "@alice:example.org:DEVICE",
trackPublications: publications,
getTrackPublication: (source: Track.Source) =>
[...publications.values()].find((p) => p.source === source),
}) as unknown as RemoteParticipant;
const room = new EventEmitter() as unknown as LivekitRoom;
return { participant, publications, room, emitter };
}
@@ -0,0 +1,149 @@
/*
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 {
observeParticipantMedia,
roomEventSelector,
} from "@livekit/components-core";
import {
type LocalParticipant,
type Participant,
type Room as LivekitRoom,
RoomEvent,
type TrackPublication,
} from "livekit-client";
import { EMPTY, filter, map, type Observable, of, switchMap } from "rxjs";
import { type ObservableScope } from "../../reactive/ObservableScope";
import { generateItems } from "../../reactive/observable";
import { E2eeType, type EncryptionSystem } from "../../encryption";
import { type Behavior } from "../../reactive/Behavior";
import { mapScoped } from "../../utils/mapScoped";
import {
type EncryptionError,
type LocalMemberMedia,
type MediaSource,
type MemberMedia,
} from "../../media-api";
import {
createLivekitMediaTrack,
createLocalLivekitMediaTrack,
} from "./LivekitMediaTrack";
/** Where a member's media comes from: its participant on a room, null while it has none. */
export type ParticipantSource<P extends Participant = Participant> = {
participant: P;
room: LivekitRoom;
} | null;
/**
* A member's media from its participant. The only place, with
* `LivekitMediaTrack`, that reads a LiveKit participant; everything above
* sees tracks and behaviors.
*/
export function createLivekitMemberMedia(
scope: ObservableScope,
source$: Behavior<ParticipantSource>,
encryptionSystem: EncryptionSystem,
): MemberMedia {
return {
tracks$: memberTracks$(
scope,
source$,
(trackScope, participant, publication, room) =>
createLivekitMediaTrack(trackScope, participant, publication, room),
),
encryptionError$: encryptionErrors$(source$, encryptionSystem),
};
}
export function createLocalLivekitMemberMedia(
scope: ObservableScope,
source$: Behavior<ParticipantSource<LocalParticipant>>,
encryptionSystem: EncryptionSystem,
setEnabled: (source: MediaSource, enabled: boolean) => Promise<boolean>,
): LocalMemberMedia {
return {
tracks$: memberTracks$(
scope,
source$,
(trackScope, participant, publication, room) =>
createLocalLivekitMediaTrack(
trackScope,
participant,
publication,
room,
setEnabled,
),
),
encryptionError$: encryptionErrors$(source$, encryptionSystem),
};
}
/**
* One track per publication, each living as long as its publication is in
* the participant's map; null while there is no participant.
*/
function memberTracks$<P extends Participant, T>(
scope: ObservableScope,
source$: Behavior<ParticipantSource<P>>,
factory: (
scope: ObservableScope,
participant: P,
publication: TrackPublication,
room: LivekitRoom,
) => T,
): Behavior<T[] | null> {
return scope.behavior(
mapScoped(scope, source$, (participantScope, { participant, room }) =>
participantScope.behavior(
observeParticipantMedia(participant).pipe(
generateItems(
`${participant.identity} tracks$`,
function* () {
for (const publication of participant.trackPublications.values())
yield { keys: [publication.trackSid], data: publication };
},
(trackScope, publication$) =>
factory(trackScope, participant, publication$.value, room),
),
),
),
).pipe(switchMap((tracks$) => tracks$ ?? of(null))),
);
}
function encryptionErrors$(
source$: Behavior<ParticipantSource>,
encryptionSystem: EncryptionSystem,
): Observable<EncryptionError> {
return source$.pipe(
switchMap((source) =>
source === null
? EMPTY
: roomEventSelector(source.room, RoomEvent.EncryptionError).pipe(
map(([error]) => error?.message ?? ""),
// The participant the error is about does not survive the trip
// from the worker, so the identity is matched in the message. A
// shared key is the same for everyone, so its errors are too.
filter(
(message) =>
encryptionSystem.kind === E2eeType.SHARED_KEY ||
message.includes(source.participant.identity),
),
map((message): EncryptionError | undefined =>
message.includes("MissingKey")
? "MissingKey"
: message.includes("InvalidKey")
? "InvalidKey"
: undefined,
),
filter((error) => error !== undefined),
),
),
);
}
@@ -0,0 +1,477 @@
/*
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 {
ConnectionState as LivekitConnectionState,
type LocalParticipant,
type LocalTrack,
type LocalTrackPublication,
ParticipantEvent,
type Room as LivekitRoom,
Track,
} from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { BehaviorSubject } from "rxjs";
import { describe, expect, it, vi } from "vitest";
import { E2eeType } from "../../encryption";
import { FailToStartLivekitConnection, MatrixRTCError } from "../../errors";
import { Epoch, type ObservableScope } from "../../reactive/ObservableScope";
import { flushPromises, mockEmitter, testScope } from "../../utils/test";
import { MediaConnectionState } from "../api";
import { type TransportLocator } from "./auth";
import { type Connection, type ConnectionState } from "./Connection";
import { ConnectionMap } from "./ConnectionMap";
import { createLivekitLocalMedia } from "./LocalMedia";
import { exampleTransport } from "./test";
const localTransport: TransportLocator = {
transport: exampleTransport,
serverName: "example.org",
};
const connected: ConnectionState = {
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
};
interface FakeConnection {
connection: Connection;
participant: LocalParticipant;
room: LivekitRoom;
state$: BehaviorSubject<ConnectionState>;
}
/** A connection to the local transport with a participant that publishes on demand. */
function fakeConnection(state: ConnectionState = connected): FakeConnection {
const participant = fakeLocalParticipant();
const room = {
options: {},
setE2EEEnabled: vi.fn(),
localParticipant: participant,
remoteParticipants: new Map(),
getActiveDevice: vi.fn(),
switchActiveDevice: vi.fn(async () => Promise.resolve(true)),
...mockEmitter(),
} as unknown as LivekitRoom;
const state$ = new BehaviorSubject<ConnectionState>(state);
const connection = {
role: "publisher",
transport: exampleTransport,
serverName: "example.org",
state$,
livekitRoom: room,
} as unknown as Connection;
return { connection, participant, room, state$ };
}
function mapOf(...connections: Connection[]): Epoch<ConnectionMap> {
const map = new ConnectionMap();
for (const connection of connections) map.add(connection, []);
return new Epoch(map);
}
function setup(
scope: ObservableScope = testScope(),
publish = [{ source: "microphone" as const }],
): {
media: ReturnType<typeof createLivekitLocalMedia>;
connectionMap$: BehaviorSubject<Epoch<ConnectionMap>>;
localTransport$: BehaviorSubject<TransportLocator | null>;
audioOutputDeviceId$: BehaviorSubject<string | undefined>;
} {
const connectionMap$ = new BehaviorSubject(mapOf());
const localTransport$ = new BehaviorSubject<TransportLocator | null>(null);
const audioOutputDeviceId$ = new BehaviorSubject<string | undefined>(
undefined,
);
const media = createLivekitLocalMedia({
scope,
connectionMap$: scope.behavior(connectionMap$),
localTransport$: scope.behavior(localTransport$),
publish,
audioOutputDeviceId$,
encryptionSystem: { kind: E2eeType.NONE },
logger,
});
return { media, connectionMap$, localTransport$, audioOutputDeviceId$ };
}
describe("createLivekitLocalMedia", () => {
it("finds the connection to the local transport once both are known", () => {
const { media, connectionMap$, localTransport$ } = setup();
const { connection } = fakeConnection();
expect(media.connection$.value).toBeNull();
expect(media.connectionState$.value).toBe(MediaConnectionState.Initialized);
connectionMap$.next(mapOf(connection));
expect(media.connection$.value).toBeNull();
localTransport$.next(localTransport);
expect(media.connection$.value).toBe(connection);
expect(media.connectionState$.value).toBe(MediaConnectionState.Connected);
});
it("follows the connection's state", () => {
const { media, connectionMap$, localTransport$ } = setup();
const { connection, state$ } = fakeConnection({ state: "authenticating" });
connectionMap$.next(mapOf(connection));
localTransport$.next(localTransport);
expect(media.connectionState$.value).toBe(MediaConnectionState.Preparing);
const error = new MatrixRTCError("no token");
state$.next({ state: "error", error });
expect(media.connectionState$.value).toBe(error);
});
it("creates the tracks on the first publish and holds them back until then", async () => {
const { media, connectionMap$, localTransport$ } = setup();
const { connection, participant } = fakeConnection();
connectionMap$.next(mapOf(connection));
localTransport$.next(localTransport);
await flushPromises();
expect(participant.setMicrophoneEnabled).not.toHaveBeenCalled();
expect(media.tracks$.value).toEqual([]);
media.setPublishing(true);
await flushPromises();
expect(participant.setMicrophoneEnabled).toHaveBeenCalledOnce();
expect(media.tracks$.value).toHaveLength(1);
expect(media.tracks$.value![0].source).toBe("microphone");
expect(track(participant, Track.Source.Microphone).isUpstreamPaused).toBe(
false,
);
});
it("pauses the upstream while the member is not publishing", async () => {
const { media, connectionMap$, localTransport$ } = setup();
const { connection, participant } = fakeConnection();
connectionMap$.next(mapOf(connection));
localTransport$.next(localTransport);
media.setPublishing(true);
await flushPromises();
media.setPublishing(false);
await flushPromises();
expect(track(participant, Track.Source.Microphone).isUpstreamPaused).toBe(
true,
);
media.setPublishing(true);
await flushPromises();
expect(track(participant, Track.Source.Microphone).isUpstreamPaused).toBe(
false,
);
});
it("resolves a publish made before the connection exists once it is up", async () => {
const { media, connectionMap$, localTransport$ } = setup();
let published: string | undefined;
const publishing = media
.publish({ source: "camera" })
.then((t) => (published = t.source));
await flushPromises();
expect(published).toBeUndefined();
const { connection, participant } = fakeConnection();
connectionMap$.next(mapOf(connection));
localTransport$.next(localTransport);
media.setPublishing(true);
await publishing;
expect(published).toBe("camera");
// Both were requested before the start, so one prompt covers both
expect(participant.enableCameraAndMicrophone).toHaveBeenCalledOnce();
expect(media.tracks$.value?.map((t) => t.source)).toEqual([
"microphone",
"camera",
]);
});
it("publishes a request at once on a started publisher", async () => {
const { media, connectionMap$, localTransport$ } = setup();
const { connection, participant } = fakeConnection();
connectionMap$.next(mapOf(connection));
localTransport$.next(localTransport);
media.setPublishing(true);
await flushPromises();
const t = await media.publish({ source: "screenShare" });
expect(t.source).toBe("screenShare");
expect(participant.setScreenShareEnabled).toHaveBeenCalledWith(
true,
expect.objectContaining({ selfBrowserSurface: "include" }),
undefined,
);
});
it("rejects a publish the device refuses, and forgets the request", async () => {
const { media, connectionMap$, localTransport$ } = setup();
const { connection, participant } = fakeConnection();
connectionMap$.next(mapOf(connection));
localTransport$.next(localTransport);
media.setPublishing(true);
await flushPromises();
vi.mocked(participant.setCameraEnabled).mockRejectedValueOnce(
new Error("NotAllowedError"),
);
const rejection = media.publish({ source: "camera" });
await expect(rejection).rejects.toBeInstanceOf(MatrixRTCError);
await expect(rejection).rejects.toThrow("NotAllowedError");
// A new connection does not try the camera again
const next = fakeConnection();
connectionMap$.next(mapOf(next.connection));
await flushPromises();
expect(next.participant.setCameraEnabled).not.toHaveBeenCalled();
expect(next.participant.setMicrophoneEnabled).toHaveBeenCalled();
});
it("publishes again on a new connection and stops the old tracks", async () => {
const { media, connectionMap$, localTransport$ } = setup();
const first = fakeConnection();
connectionMap$.next(mapOf(first.connection));
localTransport$.next(localTransport);
media.setPublishing(true);
await flushPromises();
await media.publish({ source: "camera" });
const firstTracks = [
track(first.participant, Track.Source.Microphone),
track(first.participant, Track.Source.Camera),
];
const second = fakeConnection();
connectionMap$.next(mapOf(second.connection));
await flushPromises();
for (const t of firstTracks)
expect(first.participant.unpublishTrack).toHaveBeenCalledWith(t, true);
expect(second.participant.enableCameraAndMicrophone).toHaveBeenCalledOnce();
expect(media.tracks$.value?.map((t) => t.source)).toEqual([
"microphone",
"camera",
]);
expect(
track(second.participant, Track.Source.Camera).isUpstreamPaused,
).toBe(false);
});
it("remembers a mute across connections", async () => {
const { media, connectionMap$, localTransport$ } = setup();
const first = fakeConnection();
connectionMap$.next(mapOf(first.connection));
localTransport$.next(localTransport);
media.setPublishing(true);
await flushPromises();
await expect(media.tracks$.value![0].setEnabled(false)).resolves.toBe(
false,
);
expect(first.participant.setMicrophoneEnabled).toHaveBeenLastCalledWith(
false,
);
const second = fakeConnection();
connectionMap$.next(mapOf(second.connection));
await flushPromises();
// Muted means not created at all, to spare the permission prompt
expect(second.participant.setMicrophoneEnabled).not.toHaveBeenCalled();
});
it("unpublishes a source, the screen share audio with its video", async () => {
const { media, connectionMap$, localTransport$ } = setup();
const { connection, participant } = fakeConnection();
connectionMap$.next(mapOf(connection));
localTransport$.next(localTransport);
media.setPublishing(true);
await flushPromises();
await media.publish({ source: "screenShare" });
await media.unpublish("screenShareAudio");
expect(participant.setScreenShareEnabled).toHaveBeenLastCalledWith(false);
await media.unpublish("microphone");
expect(participant.unpublishTrack).toHaveBeenCalledOnce();
expect(media.tracks$.value).toEqual([]);
});
it("keeps the first failure to publish", async () => {
const { media, connectionMap$, localTransport$ } = setup();
const { connection, participant } = fakeConnection();
connectionMap$.next(mapOf(connection));
localTransport$.next(localTransport);
media.setPublishing(true);
await flushPromises();
expect(media.publishError$.value).toBeNull();
const microphone = track(participant, Track.Source.Microphone);
vi.mocked(microphone.resumeUpstream).mockRejectedValue(
new Error("no upstream"),
);
media.setPublishing(false);
await flushPromises();
media.setPublishing(true);
await flushPromises();
expect(media.publishError$.value).toBeInstanceOf(
FailToStartLivekitConnection,
);
expect(media.publishError$.value?.message).toContain("no upstream");
});
it("plays on the device given, on the current room and the rooms to come", async () => {
const { media, connectionMap$, localTransport$, audioOutputDeviceId$ } =
setup();
await media.setAudioOutputDeviceId("speaker");
expect(audioOutputDeviceId$.value).toBe("speaker");
const { connection, room } = fakeConnection();
connectionMap$.next(mapOf(connection));
localTransport$.next(localTransport);
await flushPromises();
await media.setAudioOutputDeviceId("headset");
expect(room.switchActiveDevice).toHaveBeenCalledWith(
"audiooutput",
"headset",
);
});
});
function track(
participant: LocalParticipant,
source: Track.Source,
): LocalTrack {
const t = participant.getTrackPublication(source)?.track;
if (t === undefined) throw new Error(`No ${source} track`);
return t;
}
function fakeLocalTrack(source: Track.Source): LocalTrack {
const state = { isMuted: false, isUpstreamPaused: false };
const t = {
source,
get isMuted() {
return state.isMuted;
},
get isUpstreamPaused() {
return state.isUpstreamPaused;
},
attach: vi.fn(),
detach: vi.fn(),
mute: vi.fn(async () => {
state.isMuted = true;
return Promise.resolve(t);
}),
unmute: vi.fn(async () => {
state.isMuted = false;
return Promise.resolve(t);
}),
pauseUpstream: vi.fn(async () => {
state.isUpstreamPaused = true;
return Promise.resolve();
}),
resumeUpstream: vi.fn(async () => {
state.isUpstreamPaused = false;
return Promise.resolve();
}),
};
return t as unknown as LocalTrack;
}
/**
* The slice of a local participant the publisher and the local tracks use:
* enabling a source publishes a fake track at once, muting an existing one
* mutes it, and the publications map and events are kept as LiveKit would.
*/
function fakeLocalParticipant(): LocalParticipant {
const emitter = mockEmitter<LocalParticipant>();
const trackPublications = new Map<string, LocalTrackPublication>();
let nextSid = 0;
const getTrackPublication = (
source: Track.Source,
): LocalTrackPublication | undefined =>
[...trackPublications.values()].find((p) => p.source === source);
const publish = (source: Track.Source): LocalTrackPublication => {
const t = fakeLocalTrack(source);
const publication = {
track: t,
source,
kind:
source === Track.Source.Microphone
? Track.Kind.Audio
: Track.Kind.Video,
trackSid: `TR_${nextSid++}`,
isEncrypted: false,
isSubscribed: true,
get isMuted() {
return t.isMuted;
},
mute: t.mute,
unmute: t.unmute,
...mockEmitter(),
} as unknown as LocalTrackPublication;
trackPublications.set(publication.trackSid, publication);
emitter.emit(ParticipantEvent.LocalTrackPublished, publication);
return publication;
};
const unpublish = (publication: LocalTrackPublication): void => {
trackPublications.delete(publication.trackSid);
emitter.emit(ParticipantEvent.LocalTrackUnpublished, publication);
};
const setEnabled = async (
source: Track.Source,
enabled: boolean,
): Promise<LocalTrackPublication | undefined> => {
const existing = getTrackPublication(source);
if (enabled) {
if (existing) await existing.unmute();
return existing ?? publish(source);
}
await existing?.mute();
return existing;
};
const participant = {
...emitter,
identity: "@alice:example.org:DEVICE",
isLocal: true,
isSpeaking: false,
audioLevel: 0,
trackPublications,
getTrackPublication,
setMicrophoneEnabled: vi.fn(async (enabled: boolean) =>
setEnabled(Track.Source.Microphone, enabled),
),
setCameraEnabled: vi.fn(async (enabled: boolean) =>
setEnabled(Track.Source.Camera, enabled),
),
setScreenShareEnabled: vi.fn(async (enabled: boolean) => {
if (enabled) return setEnabled(Track.Source.ScreenShare, true);
const existing = getTrackPublication(Track.Source.ScreenShare);
if (existing) unpublish(existing);
return Promise.resolve(existing);
}),
enableCameraAndMicrophone: vi.fn(async () => {
publish(Track.Source.Microphone);
publish(Track.Source.Camera);
return Promise.resolve();
}),
unpublishTrack: vi.fn(async (t: LocalTrack) => {
const publication = [...trackPublications.values()].find(
(p) => p.track === t,
);
if (publication !== undefined) unpublish(publication);
return Promise.resolve(publication);
}),
};
return participant as unknown as LocalParticipant;
}
+277
View File
@@ -0,0 +1,277 @@
/*
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 { observeParticipantEvents } from "@livekit/components-core";
import {
type LocalParticipant,
type LocalTrackPublication,
ParticipantEvent,
type Room as LivekitRoom,
} from "livekit-client";
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
filter,
firstValueFrom,
map,
NEVER,
type Observable,
of,
switchMap,
} from "rxjs";
import {
type LocalAudioMediaTrack,
type LocalVideoMediaTrack,
type MediaSource,
type PublishRequest,
} from "../../media-api";
import { type EncryptionSystem } from "../../encryption";
import { FailToStartLivekitConnection, toMatrixRTCError } from "../../errors";
import { type Behavior } from "../../reactive/Behavior";
import {
type Epoch,
type ObservableScope,
} from "../../reactive/ObservableScope";
import { type LocalMediaBackend, MediaConnectionState } from "../api";
import { type TransportLocator } from "./auth";
import { type Connection } from "./Connection";
import { type ConnectionMap } from "./ConnectionMap";
import { createLocalLivekitMemberMedia } from "./LivekitMemberMedia";
import { livekitSources } from "./LivekitMediaTrack";
import { type DesiredMedia, Publisher } from "./Publisher";
import { toMediaConnectionState } from "./connectionState";
interface Props {
scope: ObservableScope;
connectionMap$: Behavior<Epoch<ConnectionMap>>;
/** The transport we publish on; null until the session has found it. */
localTransport$: Behavior<TransportLocator | null>;
publish: PublishRequest[];
/** Read by the connection factory for the rooms it creates from now on. */
audioOutputDeviceId$: BehaviorSubject<string | undefined>;
encryptionSystem: EncryptionSystem;
logger: Logger;
}
export interface LivekitLocalMedia extends LocalMediaBackend {
/** The connection the local member publishes on; the data channel sends on it. */
connection$: Behavior<Connection | null>;
setAudioOutputDeviceId(deviceId: string): Promise<void>;
}
/**
* The local member's side of the LiveKit backend: one `Publisher` per
* connection to the local transport, driven by the publish intent and the
* requests the host made, both of which outlive any one connection.
*/
export function createLivekitLocalMedia({
scope,
connectionMap$,
localTransport$,
publish: initialRequests,
audioOutputDeviceId$,
encryptionSystem,
logger: parentLogger,
}: Props): LivekitLocalMedia {
const logger = parentLogger.getChild("[LocalMedia]");
const connection$ = scope.behavior(
combineLatest(
[connectionMap$, localTransport$],
({ value: connections }, transport) =>
transport && connections.getConnectionForTransport(transport),
),
);
const connectionState$ = scope.behavior<MediaConnectionState | Error>(
connection$.pipe(
switchMap(
(connection) =>
connection?.state$.pipe(map(toMediaConnectionState)) ??
of(MediaConnectionState.Initialized),
),
),
);
const desired: DesiredMedia = new Map(
initialRequests.map((request) => [
request.source,
{ request, enabled: true },
]),
);
const publishing$ = new BehaviorSubject(false);
const publisher$ = new BehaviorSubject<Publisher | null>(null);
const publishError$ = new BehaviorSubject<Error | null>(null);
scope.reconcile(connection$, async (connection) => {
if (connection === null) return;
const publisher = new Publisher(
connection.livekitRoom,
desired,
logger.getChild(`[Publisher ${JSON.stringify(connection.transport)}]`),
);
publisher$.next(publisher);
return Promise.resolve(async (): Promise<void> => {
publisher$.next(null);
await publisher.destroy();
});
});
scope.reconcile(
scope.behavior(combineLatest([publisher$, publishing$])),
async ([publisher, publish]) => {
if (publisher === null) return;
try {
if (publish) {
publisher.start();
await publisher.startPublishing();
} else if (publisher.shouldPublish) await publisher.stopPublishing();
} catch (e) {
if (publishError$.value === null)
publishError$.next(
new FailToStartLivekitConnection(
e instanceof Error ? e.message : String(e),
),
);
else logger.error("Another publish error", e);
}
},
);
const participant$ = scope.behavior(
connection$.pipe(map((c) => c?.livekitRoom.localParticipant ?? null)),
);
const setEnabled = async (
source: MediaSource,
enabled: boolean,
): Promise<boolean> => {
const wanted = desired.get(source);
if (wanted) wanted.enabled = enabled;
// Without a publisher the request waits for the tracks to be created
const publisher = publisher$.value;
if (publisher === null) return enabled;
const result = await publisher.setEnabled(source, enabled);
if (wanted && result !== enabled) wanted.enabled = result;
return result;
};
const { tracks$, encryptionError$ } = createLocalLivekitMemberMedia(
scope,
participantAndRoom$(scope, participant$, connection$),
encryptionSystem,
setEnabled,
);
const publish = async (
request: PublishRequest,
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack> => {
desired.set(request.source, { request, enabled: true });
const publisher = publisher$.value;
try {
// Before the start, the publisher publishes everything desired itself
const publication =
(publisher?.started ? await publisher.publish(request) : undefined) ??
(await firstValueFrom(publication$(participant$, request.source)));
return await firstValueFrom(trackWithId$(tracks$, publication.trackSid));
} catch (e) {
desired.delete(request.source);
throw toMatrixRTCError(e);
}
};
const unpublish = async (source: MediaSource): Promise<void> => {
const published = publishedSource(source);
if (published === undefined) return;
desired.delete(published);
await publisher$.value?.unpublish(published);
};
const setAudioOutputDeviceId = async (deviceId: string): Promise<void> => {
audioOutputDeviceId$.next(deviceId);
await publisher$.value?.setAudioOutputDevice(deviceId);
};
return {
connection$,
connectionState$,
tracks$,
encryptionError$,
setPublishing: (publish) => publishing$.next(publish),
publishError$,
publish,
unpublish,
setAudioOutputDeviceId,
};
}
/** The pair the local tracks are built on, changing only when one of the two does. */
function participantAndRoom$(
scope: ObservableScope,
participant$: Behavior<LocalParticipant | null>,
connection$: Behavior<Connection | null>,
): Behavior<{ participant: LocalParticipant; room: LivekitRoom } | null> {
return scope.behavior(
combineLatest([participant$, connection$]).pipe(
map(([participant, connection]) =>
participant && connection
? { participant, room: connection.livekitRoom }
: null,
),
distinctUntilChanged(
(a, b) => a?.participant === b?.participant && a?.room === b?.room,
),
),
);
}
/** The publication of a source, once the current participant has made it. */
function publication$(
participant$: Behavior<LocalParticipant | null>,
source: MediaSource,
): Observable<LocalTrackPublication> {
return participant$.pipe(
switchMap((participant) =>
participant === null
? NEVER
: observeParticipantEvents(
participant,
ParticipantEvent.LocalTrackPublished,
).pipe(
map(() => participant.getTrackPublication(livekitSources[source])),
),
),
filter((publication) => publication !== undefined),
);
}
function trackWithId$(
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>,
id: string,
): Observable<LocalAudioMediaTrack | LocalVideoMediaTrack> {
return tracks$.pipe(
map((tracks) => tracks?.find((track) => track.id === id)),
filter((track) => track !== undefined),
);
}
/** The publication a source belongs to: the screen share audio goes with its video. */
function publishedSource(
source: MediaSource,
): PublishRequest["source"] | undefined {
switch (source) {
case "screenShareAudio":
return "screenShare";
case "unknown":
return undefined;
default:
return source;
}
}
@@ -0,0 +1,51 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { KeyProviderEvent } from "livekit-client";
import { Subject } from "rxjs";
import { describe, expect, test, vi } from "vitest";
import { type MediaKey } from "../api";
import { MatrixKeyProvider } from "./MatrixKeyProvider";
describe("matrixKeyProvider", () => {
test("initializes", () => {
const keyProvider = new MatrixKeyProvider();
expect(keyProvider).toBeTruthy();
});
test("follows the key stream it is given", async () => {
// jsdom has no HKDF; the derived material is not what is under test
vi.spyOn(crypto.subtle, "importKey").mockResolvedValue({} as CryptoKey);
const keyProvider = new MatrixKeyProvider();
const setKey = vi.fn();
keyProvider.on(KeyProviderEvent.SetKey, setKey);
const keys$ = new Subject<MediaKey>();
keyProvider.setMediaKeys(keys$);
keys$.next({ participantId: "alice", index: 2, key: new Uint8Array(16) });
await vi.waitFor(() =>
expect(setKey.mock.lastCall?.[0]).toMatchObject({
participantIdentity: "alice",
keyIndex: 2,
}),
);
});
test("stops following a stream when given another", () => {
const keyProvider = new MatrixKeyProvider();
const first$ = new Subject<MediaKey>();
const second$ = new Subject<MediaKey>();
keyProvider.setMediaKeys(first$);
expect(first$.observed).toBe(true);
keyProvider.setMediaKeys(second$);
expect(first$.observed).toBe(false);
expect(second$.observed).toBe(true);
});
});
@@ -0,0 +1,46 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BaseKeyProvider } from "livekit-client";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { type Observable, type Subscription } from "rxjs";
import { type MediaKey } from "../api";
/** LiveKit's key provider, fed from the session's per-participant media keys. */
export class MatrixKeyProvider extends BaseKeyProvider {
private subscription?: Subscription;
private logger: Logger;
public constructor() {
super({ ratchetWindowSize: 10, keyringSize: 256 });
this.logger = rootLogger.getChild("[MatrixKeyProvider]");
}
public setMediaKeys(mediaKeys$: Observable<MediaKey>): void {
this.subscription?.unsubscribe();
this.subscription = mediaKeys$.subscribe(this.onMediaKey);
}
private onMediaKey = ({ key, index, participantId }: MediaKey): void => {
crypto.subtle
.importKey("raw", key, "HKDF", false, ["deriveBits", "deriveKey"])
.then(
(keyMaterial) => {
this.onSetEncryptionKey(keyMaterial, participantId, index);
this.logger.debug(
`Sent new key to livekit participantId=${participantId} encryptionKeyIndex=${index}`,
);
},
(e) => {
this.logger.error(
`Failed to create key material from buffer for livekit participantId=${participantId} encryptionKeyIndex=${index}`,
e,
);
},
);
};
}
@@ -0,0 +1,439 @@
/*
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 { EventEmitter } from "events";
import {
ConnectionState,
LocalParticipant,
type LocalTrack,
type LocalTrackPublication,
ParticipantEvent,
type Room as LivekitRoom,
Track,
} from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type PublishRequest } from "../../media-api";
import { flushPromises } from "../../utils/test";
import { type DesiredMedia, Publisher } from "./Publisher";
describe("Publisher", () => {
describe("setAudioOutputDevice", () => {
it("switches the room's output while the room is still connecting", async () => {
const { room, switchActiveDevice } = fakeRoom(ConnectionState.Connecting);
const publisher = new Publisher(room, new Map(), logger);
await publisher.setAudioOutputDevice("speaker");
expect(switchActiveDevice).toHaveBeenCalledWith("audiooutput", "speaker");
});
it("leaves a room alone that already plays on the device", async () => {
const { room, switchActiveDevice } = fakeRoom(
ConnectionState.Connected,
"speaker",
);
const publisher = new Publisher(room, new Map(), logger);
await publisher.setAudioOutputDevice("speaker");
expect(switchActiveDevice).not.toHaveBeenCalled();
});
it("rejects where the room cannot switch", async () => {
const { room, switchActiveDevice } = fakeRoom(ConnectionState.Connected);
switchActiveDevice.mockRejectedValue(new Error("no setSinkId"));
const publisher = new Publisher(room, new Map(), logger);
await expect(publisher.setAudioOutputDevice("speaker")).rejects.toThrow(
"no setSinkId",
);
});
});
// The publisher drives a real LocalParticipant, so that LiveKit's own
// bookkeeping of pending publications is part of what is tested; only the
// device access and the signalling are faked.
describe("publishing", () => {
let participant: LocalParticipant;
let room: LivekitRoom;
let desired: DesiredMedia;
let publisher: Publisher;
// Resolved by default; a test takes hold of it to stretch the track creation
let createTracksLock: Promise<void>;
const microphone: PublishRequest = { source: "microphone" };
const camera: PublishRequest = { source: "camera" };
function want(...requests: PublishRequest[]): void {
for (const request of requests)
desired.set(request.source, { request, enabled: true });
}
function track(source: Track.Source): LocalTrack | undefined {
return participant.getTrackPublication(source)?.track;
}
beforeEach(() => {
createTracksLock = Promise.resolve();
participant = fakeLocalParticipant(async () => createTracksLock);
room = {
options: {},
setE2EEEnabled: () => undefined,
localParticipant: participant,
} as unknown as LivekitRoom;
desired = new Map();
publisher = new Publisher(room, desired, logger);
});
afterEach(async () => {
await publisher.destroy();
});
it("creates no tracks where everything starts muted, to spare the permission prompt", async () => {
want(microphone, camera);
desired.get("microphone")!.enabled = false;
desired.get("camera")!.enabled = false;
publisher.start();
await flushPromises();
expect(participant.createTracks).not.toHaveBeenCalled();
});
it("asks for the camera and the microphone in one call", async () => {
const enableBoth = vi.spyOn(participant, "enableCameraAndMicrophone");
want(microphone, camera);
publisher.start();
await flushPromises();
expect(enableBoth).toHaveBeenCalledOnce();
expect(participant.createTracks).toHaveBeenCalledOnce();
expect(participant.createTracks).toHaveBeenCalledWith({
audio: true,
video: true,
});
});
it.each([
{ enabled: microphone, muted: camera, options: { audio: true } },
{ enabled: camera, muted: microphone, options: { video: true } },
])(
"creates only the $enabled.source where the $muted.source is muted",
async ({ enabled, muted, options }) => {
want(enabled, muted);
desired.get(muted.source)!.enabled = false;
publisher.start();
await flushPromises();
expect(participant.createTracks).toHaveBeenCalledOnce();
expect(participant.createTracks).toHaveBeenCalledWith(options);
},
);
it("starts again at most once", async () => {
want(microphone);
publisher.start();
publisher.start();
await flushPromises();
expect(participant.createTracks).toHaveBeenCalledOnce();
});
it("keeps the upstream paused until the member publishes", async () => {
want(microphone);
publisher.start();
await flushPromises();
const microphoneTrack = track(Track.Source.Microphone);
expect(microphoneTrack).toBeDefined();
expect(microphoneTrack!.pauseUpstream).toHaveBeenCalled();
expect(microphoneTrack!.isUpstreamPaused).toBe(true);
});
it("resumes the upstream once the member publishes", async () => {
want(camera);
publisher.start();
await flushPromises();
expect(track(Track.Source.Camera)!.isUpstreamPaused).toBe(true);
await publisher.startPublishing();
expect(track(Track.Source.Camera)!.isUpstreamPaused).toBe(false);
});
it("pauses the upstream again when the member stops publishing", async () => {
want(camera);
publisher.start();
await flushPromises();
await publisher.startPublishing();
await publisher.stopPublishing();
expect(track(Track.Source.Camera)!.isUpstreamPaused).toBe(true);
});
it("leaves a track published while it publishes, paused or not", async () => {
want(microphone);
await publisher.startPublishing();
publisher.start();
await flushPromises();
expect(track(Track.Source.Microphone)!.isUpstreamPaused).toBe(false);
});
it("mutes and unmutes a track", async () => {
want(microphone);
publisher.start();
await flushPromises();
await publisher.startPublishing();
await expect(publisher.setEnabled("microphone", false)).resolves.toBe(
false,
);
expect(track(Track.Source.Microphone)!.isMuted).toBe(true);
await expect(publisher.setEnabled("microphone", true)).resolves.toBe(
true,
);
expect(track(Track.Source.Microphone)!.isMuted).toBe(false);
});
it("keeps the upstream paused when a track is unmuted before the member publishes", async () => {
want(microphone);
publisher.start();
await flushPromises();
await publisher.setEnabled("microphone", false);
await publisher.setEnabled("microphone", true);
expect(track(Track.Source.Microphone)!.isUpstreamPaused).toBe(true);
});
it("reports the state LiveKit is left in where a mute fails", async () => {
want(camera);
publisher.start();
await flushPromises();
vi.spyOn(participant, "setCameraEnabled").mockRejectedValueOnce(
new Error("device gone"),
);
await expect(publisher.setEnabled("camera", false)).resolves.toBe(true);
});
it("reports a screen share that was never published as muted", async () => {
await expect(publisher.setEnabled("screenShare", true)).resolves.toBe(
false,
);
});
// Creating the tracks takes a while, and the host may mute in the
// meantime: LiveKit's setMicrophoneEnabled(false) does not see a track
// that is still pending, so the publisher has to apply the mute once the
// track is published.
it("mutes a track that was muted while it was being created", async () => {
want(microphone);
const lock = Promise.withResolvers<void>();
createTracksLock = lock.promise;
publisher.start();
await publisher.startPublishing();
desired.get("microphone")!.enabled = false;
void publisher.setEnabled("microphone", false);
await flushPromises();
expect(track(Track.Source.Microphone)).toBeUndefined();
lock.resolve();
await flushPromises();
await flushPromises();
const microphoneTrack = track(Track.Source.Microphone);
expect(microphoneTrack).toBeDefined();
expect(microphoneTrack!.isMuted).toBe(true);
});
it("publishes a request on its own once started", async () => {
want(microphone);
publisher.start();
await flushPromises();
const publication = await publisher.publish(camera);
expect(publication?.source).toBe(Track.Source.Camera);
expect(track(Track.Source.Camera)!.isUpstreamPaused).toBe(true);
});
it("unpublishes a source and stops its track", async () => {
want(microphone, camera);
publisher.start();
await flushPromises();
const cameraTrack = track(Track.Source.Camera)!;
await publisher.unpublish("camera");
expect(participant.unpublishTrack).toHaveBeenCalledWith(
cameraTrack,
true,
);
expect(track(Track.Source.Camera)).toBeUndefined();
expect(track(Track.Source.Microphone)).toBeDefined();
});
it("unpublishes a screen share through LiveKit, so that its audio goes with it", async () => {
await publisher.unpublish("screenShare");
expect(participant.setScreenShareEnabled).toHaveBeenCalledWith(false);
});
it("stops every track on destroy", async () => {
want(microphone, camera);
publisher.start();
await flushPromises();
const tracks = [
track(Track.Source.Microphone),
track(Track.Source.Camera),
];
await publisher.destroy();
for (const t of tracks)
expect(participant.unpublishTrack).toHaveBeenCalledWith(t, true);
expect(participant.trackPublications.size).toBe(0);
});
});
});
/** The slice of a LiveKit room the publisher touches at construction and for the output device. */
function fakeRoom(
state: ConnectionState,
activeOutput?: string,
): { room: LivekitRoom; switchActiveDevice: ReturnType<typeof vi.fn> } {
const switchActiveDevice = vi.fn(async () => Promise.resolve(true));
const room = {
state,
options: {},
setE2EEEnabled: () => undefined,
localParticipant: new EventEmitter(),
getActiveDevice: () => activeOutput,
switchActiveDevice,
} as unknown as LivekitRoom;
return { room, switchActiveDevice };
}
function fakeLocalTrack(source: Track.Source): LocalTrack {
const state = { isMuted: false, isUpstreamPaused: false };
const track = {
source,
get isMuted() {
return state.isMuted;
},
get isUpstreamPaused() {
return state.isUpstreamPaused;
},
stop: vi.fn(),
mute: vi.fn(async () => {
state.isMuted = true;
return Promise.resolve(track);
}),
unmute: vi.fn(async () => {
state.isMuted = false;
return Promise.resolve(track);
}),
pauseUpstream: vi.fn(async () => {
state.isUpstreamPaused = true;
return Promise.resolve();
}),
resumeUpstream: vi.fn(async () => {
state.isUpstreamPaused = false;
return Promise.resolve();
}),
};
return track as unknown as LocalTrack;
}
/**
* A real LocalParticipant on a fake engine: the track creation, publication
* and unpublication are replaced so that no device or signalling is touched,
* while the enable/disable logic and the pending publications stay LiveKit's.
*/
function fakeLocalParticipant(
createTracksLock: () => Promise<void>,
): LocalParticipant {
const engine = {
client: { sendUpdateLocalMetadata: vi.fn() },
on: vi.fn().mockReturnThis(),
sendDataPacket: vi.fn(),
};
const participant = new LocalParticipant(
"local-sid",
"local-identity",
// @ts-expect-error - an engine that answers nothing is enough here
engine,
{
adaptiveStream: true,
dynacast: false,
audioCaptureDefaults: {},
videoCaptureDefaults: {},
stopLocalTrackOnUnpublish: true,
reconnectPolicy: "always",
disconnectOnPageLeave: true,
},
new Map(),
{},
{},
{},
);
let nextSid = 0;
vi.spyOn(participant, "createTracks").mockImplementation(async (opts) => {
const tracks: LocalTrack[] = [];
if (opts?.audio) tracks.push(fakeLocalTrack(Track.Source.Microphone));
if (opts?.video) tracks.push(fakeLocalTrack(Track.Source.Camera));
await createTracksLock();
return tracks;
});
vi.spyOn(participant, "publishTrack").mockImplementation(async (t) => {
const track = t as LocalTrack;
const publication = {
track,
source: track.source,
trackSid: `TR_${nextSid++}`,
get isMuted() {
return track.isMuted;
},
mute: track.mute,
unmute: track.unmute,
} as Partial<LocalTrackPublication> as LocalTrackPublication;
participant.trackPublications.set(publication.trackSid, publication);
participant.emit(ParticipantEvent.LocalTrackPublished, publication);
return Promise.resolve(publication);
});
vi.spyOn(participant, "unpublishTrack").mockImplementation(async (t) => {
const publication = [...participant.trackPublications.values()].find(
(p) => p.track === t,
);
if (publication === undefined) return undefined;
participant.trackPublications.delete(publication.trackSid);
participant.emit(ParticipantEvent.LocalTrackUnpublished, publication);
return Promise.resolve(publication);
});
vi.spyOn(participant, "getTrackPublication").mockImplementation((source) =>
[...participant.trackPublications.values()].find(
(p) => p.source === source,
),
);
vi.spyOn(participant, "setScreenShareEnabled").mockResolvedValue(undefined);
return participant;
}
+217
View File
@@ -0,0 +1,217 @@
/*
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 LocalTrackPublication,
ParticipantEvent,
type Room as LivekitRoom,
type Track,
} from "livekit-client";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type MediaSource, type PublishRequest } from "../../media-api";
import { livekitSources, mediaSources } from "./LivekitMediaTrack";
import {
audioCaptureOptions,
screenShareCaptureOptions,
screenSharePublishOptions,
videoCaptureOptions,
videoPublishOptions,
} from "./publishOptions";
/** What the host asked to publish, kept across publishers for a reconnection. */
export interface DesiredPublication {
request: PublishRequest;
enabled: boolean;
}
export type DesiredMedia = Map<MediaSource, DesiredPublication>;
/**
* Publishes the local media on one LiveKit room, following the host's
* requests.
*
* LiveKit publishes a track the moment it is created, but a member must not be
* heard before it has joined the MatrixRTC session, nor after it has left. The
* tracks are therefore kept, and their upstream paused, while `shouldPublish`
* is false: the local preview stays live while nothing reaches the room.
*/
export class Publisher {
public shouldPublish = false;
/** Whether the initial requests have been published; `publish` is immediate from then on. */
public started = false;
private readonly room: LivekitRoom;
public constructor(
room: LivekitRoom,
private readonly desired: DesiredMedia,
private readonly logger: Logger,
) {
this.room = room;
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
this.logger.error("Failed to enable E2EE on the room", e);
});
this.onLocalTrackPublished = this.onLocalTrackPublished.bind(this);
room.localParticipant.on(
ParticipantEvent.LocalTrackPublished,
this.onLocalTrackPublished,
);
}
public async destroy(): Promise<void> {
this.room.localParticipant.off(
ParticipantEvent.LocalTrackPublished,
this.onLocalTrackPublished,
);
try {
for (const { track } of this.publications())
if (track) await this.room.localParticipant.unpublishTrack(track, true);
} catch (e) {
this.logger.error("Failed to stop the local tracks", e);
}
}
/** Publishes what the host asked for, less what it has muted since. Safe to call more than once. */
public start(): void {
if (this.started) return;
this.started = true;
const requests = [...this.desired.values()]
.filter(({ enabled }) => enabled)
.map(({ request }) => request);
const sources = new Set(requests.map((request) => request.source));
// Microphone and camera in one call so that the browser asks for
// permission once; the room defaults carry their options. LiveKit
// resolves these once the track is published, which may block on the
// connection; LocalTrackPublished is what tells us a track exists.
if (sources.has("microphone") && sources.has("camera"))
void this.room.localParticipant.enableCameraAndMicrophone();
for (const request of requests)
if (
request.source === "screenShare" ||
!(sources.has("microphone") && sources.has("camera"))
)
this.publish(request).catch((e) => {
this.logger.error(`Failed to publish the ${request.source}`, e);
});
}
/** Publishes one request and resolves with its publication once LiveKit has it. */
public async publish(
request: PublishRequest,
): Promise<LocalTrackPublication | undefined> {
const participant = this.room.localParticipant;
let publication: LocalTrackPublication | undefined;
if (request.source === "microphone")
publication = await participant.setMicrophoneEnabled(
true,
audioCaptureOptions(request),
);
else if (request.source === "camera")
publication = await participant.setCameraEnabled(
true,
videoCaptureOptions(request),
videoPublishOptions(request.capture),
);
else
publication = await participant.setScreenShareEnabled(
true,
screenShareCaptureOptions(request),
screenSharePublishOptions(request.capture),
);
// Until the member has joined, the upstream has to stay paused
if (!this.shouldPublish) await this.pauseUpstreams();
return publication;
}
public async unpublish(source: MediaSource): Promise<void> {
const participant = this.room.localParticipant;
// Takes the screen share audio with it
if (source === "screenShare") {
await participant.setScreenShareEnabled(false);
return;
}
const track = participant.getTrackPublication(
livekitSources[source],
)?.track;
if (track) await participant.unpublishTrack(track, true);
}
/**
* Mutes or unmutes a source and reports what resulted: the request where
* it succeeded, the state LiveKit is left in where it did not (a denied
* permission, a missing device).
*/
public async setEnabled(
source: MediaSource,
enabled: boolean,
): Promise<boolean> {
const participant = this.room.localParticipant;
const livekitSource = livekitSources[source];
try {
if (source === "microphone")
await participant.setMicrophoneEnabled(enabled);
else if (source === "camera") await participant.setCameraEnabled(enabled);
else {
const publication = participant.getTrackPublication(livekitSource);
if (!publication) return false;
if (enabled) await publication.unmute();
else await publication.mute();
}
// Unmuting restarts the upstream; until the member has joined, it has
// to stay paused
if (enabled && !this.shouldPublish) await this.pauseUpstreams();
return enabled;
} catch (e) {
this.logger.error(`Failed to set ${source} enabled=${enabled}`, e);
return !(participant.getTrackPublication(livekitSource)?.isMuted ?? true);
}
}
public async startPublishing(): Promise<void> {
if (this.shouldPublish) return;
this.shouldPublish = true;
// Enabling a track does not resume an upstream that was paused while it
// was already enabled, so it is done explicitly
for (const { track } of this.publications())
if (track?.isUpstreamPaused) await track.resumeUpstream();
}
public async stopPublishing(): Promise<void> {
this.shouldPublish = false;
await this.pauseUpstreams();
}
private onLocalTrackPublished(publication: LocalTrackPublication): void {
this.logger.info(`Local ${publication.source} track published`);
if (!this.shouldPublish)
this.pauseUpstreams().catch((e) => {
this.logger.error("Failed to pause the upstream", e);
});
// The host may have changed its mind while the track was being created
const source = mediaSources[publication.source as Track.Source];
if (this.desired.get(source)?.enabled === false)
void this.setEnabled(source, false);
}
private async pauseUpstreams(): Promise<void> {
for (const { track } of this.publications())
if (track && !track.isUpstreamPaused) await track.pauseUpstream();
}
private publications(): LocalTrackPublication[] {
return [...this.room.localParticipant.trackPublications.values()];
}
/**
* Works on a room that is still connecting too: LiveKit keeps the device
* in the room's options and gives it to every participant that arrives.
*/
public async setAudioOutputDevice(deviceId: string): Promise<void> {
if (this.room.getActiveDevice("audiooutput") === deviceId) return;
await this.room.switchActiveDevice("audiooutput", deviceId);
}
}
+102
View File
@@ -0,0 +1,102 @@
/*
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 { ConnectionError, HTTPError, type MatrixClient } from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { doNetworkOperationWithRetry } from "../../../utils/network";
import { extractFullConfigFromToken, type SFUConfig } from "./types";
/**
* In widget mode the host may refuse an action before any request is made,
* which surfaces as a plain error. Asking again cannot change its answer, so
* only errors from the network are worth retrying.
*/
function isNetworkError(e: unknown): boolean {
return e instanceof HTTPError || e instanceof ConnectionError;
}
export type ClientGetTokenParts = Pick<
MatrixClient,
"_unstable_getLivekitToken"
>;
export interface GetSFUConfigParams {
client: ClientGetTokenParts;
/** Our own membership identity; the member id is what the token is issued for. */
membership: CallMembershipIdentityParts;
/** The WebSocket URL of the SFU to get a token for. */
url: string;
/** The homeserver the SFU belongs to. */
serverName: string;
roomId: string;
slotId: string;
}
/** Gets an SFU token through the homeserver's MSC4195 `get_token` endpoint. */
export async function getSFUConfig({
client,
membership,
url,
serverName,
roomId,
slotId,
}: GetSFUConfigParams): Promise<SFUConfig> {
const res = await doNetworkOperationWithRetry(
async () =>
client._unstable_getLivekitToken({
url,
server_name: serverName,
room_id: roomId,
slot_id: slotId,
member_id: membership.memberId,
}),
isNetworkError,
);
return extractFullConfigFromToken({ url, jwt: res.jwt });
}
export type ClientDelegationParts = Pick<
MatrixClient,
"_unstable_delegateDelayedLeave" | "baseUrl"
>;
export interface DelegateDelayedLeaveParams {
client: ClientDelegationParts;
membership: CallMembershipIdentityParts;
/** The WebSocket URL of the SFU we publish on. */
url: string;
roomId: string;
slotId: string;
/** The delay ID of the leave event to hand over. */
delayId: string;
}
/**
* Hands the delayed leave to the homeserver through the MSC4195
* `delegate_delayed_leave` endpoint, which sends it once we drop off the SFU.
*/
export async function delegateDelayedLeave({
client,
membership,
url,
roomId,
slotId,
delayId,
}: DelegateDelayedLeaveParams): Promise<void> {
await doNetworkOperationWithRetry(
async () =>
client._unstable_delegateDelayedLeave({
url,
room_id: roomId,
slot_id: slotId,
member_id: membership.memberId,
delay_id: delayId,
}),
isNetworkError,
);
}
@@ -0,0 +1,407 @@
/*
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, describe, expect, it, type Mocked, vi } from "vitest";
import fetchMock from "fetch-mock";
import { ConnectionError, MatrixError } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { NoMatrix2AuthorizationService } from "../../../errors";
import {
aliceDeviceId,
aliceUserId,
createJWTToken,
} from "../../../utils/test-fixtures";
import type * as csApi from "./csApi";
import type * as openID from "./openID";
import { chooseAuthFlow, delegateDelayedLeave, getSFUConfig } from ".";
const aliceId = `${aliceUserId}:${aliceDeviceId}`;
const csApiJWTToken = createJWTToken(aliceId, "via CS API");
const legacyJWTToken = createJWTToken(aliceId, "via legacy endpoint");
const defaultJWTToken = createJWTToken(aliceId, "via default endpoint");
const client: Mocked<
csApi.ClientGetTokenParts &
csApi.ClientDelegationParts &
openID.ClientOpenIDParts
> = {
baseUrl: "https://matrix.example.org",
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: csApiJWTToken }),
_unstable_delegateDelayedLeave: vi.fn().mockResolvedValue({}),
getOpenIdToken: vi
.fn()
.mockResolvedValue({ access_token: "mock-openID-token" }),
};
const membership: CallMembershipIdentityParts = {
userId: aliceUserId,
deviceId: aliceDeviceId,
memberId: "123",
};
const roomId = "!room:example.org";
const slotId = "m.call#ROOM";
const unsupportedError = new MatrixError({ errcode: "M_UNSUPPORTED" }, 404);
const urlTransport: UnstableLivekitTransport = {
type: "livekit",
url: "wss://livekit.example.org",
};
const serviceUrl = "https://livekit-jwt.example.org";
const serviceUrlTransport: UnstableLivekitTransport = {
type: "livekit",
livekit_service_url: serviceUrl,
};
const urlAndServiceUrlTransport: UnstableLivekitTransport = {
...urlTransport,
...serviceUrlTransport,
};
/** The JWT service, answering each endpoint with a token that names it. */
function mockJWTService(): void {
fetchMock.post(`${serviceUrl}/sfu/get`, {
status: 200,
body: { url: "wss://sfu", jwt: legacyJWTToken },
});
fetchMock.post(`${serviceUrl}/get_token`, {
status: 200,
body: { url: "wss://sfu", jwt: defaultJWTToken },
});
}
/** Whether the JWT service was asked at all, and which endpoint. */
function jwtServiceCalls(): string[] {
return fetchMock.calls().map(([url]) => url.replace(serviceUrl, ""));
}
afterEach(() => {
vi.clearAllMocks();
fetchMock.reset();
});
describe("getSFUConfig", () => {
const defaultParams = {
client,
membership,
serverName: "example.org",
role: "subscriber" as const,
roomId,
slotId,
logger,
};
it("uses CS API with url transport", async () => {
mockJWTService();
expect(
await getSFUConfig({ ...defaultParams, transport: urlTransport }),
).toEqual(
expect.objectContaining({
jwt: csApiJWTToken,
livekitAlias: "via CS API",
}),
);
expect(client._unstable_getLivekitToken).toHaveBeenCalledWith({
url: urlTransport.url,
server_name: "example.org",
room_id: roomId,
slot_id: slotId,
member_id: "123",
});
expect(jwtServiceCalls()).toEqual([]);
});
it("does not fall back to OpenID with url transport", async () => {
mockJWTService();
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
await expect(
getSFUConfig({ ...defaultParams, transport: urlTransport }),
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual([]);
});
it("uses OpenID with livekit_service_url transport", async () => {
mockJWTService();
expect(
await getSFUConfig({ ...defaultParams, transport: serviceUrlTransport }),
).toEqual(
expect.objectContaining({
jwt: defaultJWTToken,
livekitAlias: "via default endpoint",
}),
);
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual(["/get_token"]);
});
it("uses CS API when subscribing with url + livekit_service_url transport", async () => {
mockJWTService();
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
}),
).toEqual(
expect.objectContaining({
jwt: csApiJWTToken,
livekitAlias: "via CS API",
}),
);
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual([]);
});
it("falls back to OpenID with url + livekit_service_url transport", async () => {
mockJWTService();
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
}),
).toEqual(
expect.objectContaining({
jwt: defaultJWTToken,
livekitAlias: "via default endpoint",
}),
);
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual(["/get_token"]);
});
it("falls back to OpenID when the widget host refuses the CS API", async () => {
mockJWTService();
// In widget mode a host without MSC4533 rejects the request before it
// reaches the homeserver, with a plain error rather than a Matrix error
client._unstable_getLivekitToken.mockRejectedValueOnce(
new Error(
"The rtc_livekit_get_token action is not supported by the client.",
),
);
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
}),
).toEqual(expect.objectContaining({ jwt: defaultJWTToken }));
expect(jwtServiceCalls()).toEqual(["/get_token"]);
});
it("surfaces the widget host's refusal with url transport", async () => {
mockJWTService();
const error = new Error(
"The rtc_livekit_get_token action is not supported by the client.",
);
client._unstable_getLivekitToken.mockRejectedValueOnce(error);
await expect(
getSFUConfig({ ...defaultParams, transport: urlTransport }),
).rejects.toThrow(error);
expect(jwtServiceCalls()).toEqual([]);
});
it("uses CS API when publishing with url transport", async () => {
mockJWTService();
expect(
await getSFUConfig({
...defaultParams,
transport: urlTransport,
role: "publisher",
}),
).toEqual(expect.objectContaining({ jwt: csApiJWTToken }));
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual([]);
});
it("does not fall back to OpenID when publishing", async () => {
mockJWTService();
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
await expect(
getSFUConfig({
...defaultParams,
transport: urlTransport,
role: "publisher",
}),
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual([]);
});
it("uses the legacy OpenID endpoint when publishing with url + livekit_service_url transport", async () => {
mockJWTService();
// Subscribers predating MSC4195 expect a legacy participant identity from
// anyone advertising a membership, which only the JWT service assigns
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
role: "publisher",
}),
).toEqual(
expect.objectContaining({
jwt: legacyJWTToken,
livekitAlias: "via legacy endpoint",
}),
);
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual(["/sfu/get"]);
});
it("does not fall back to OpenID on connection error", async () => {
vi.useFakeTimers();
try {
mockJWTService();
const client_ = {
...client,
_unstable_getLivekitToken: vi
.fn()
.mockRejectedValue(new ConnectionError("No connection")),
};
await Promise.all([
expect(
getSFUConfig({
...defaultParams,
client: client_,
transport: urlAndServiceUrlTransport,
}),
).rejects.toThrow(expect.any(ConnectionError)),
vi.runAllTimersAsync(), // Allow request to be retried
]);
expect(client_._unstable_getLivekitToken).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual([]);
} finally {
vi.useRealTimers();
}
});
});
describe("delegateDelayedLeave", () => {
const defaultParams = {
client,
membership,
roomId,
slotId,
delayId: "delay123",
delayTimeoutMs: 3_600_000,
logger,
};
/** The delay fields the JWT service was handed, if any. */
function delegationViaJWTService(): object | undefined {
const call = fetchMock.calls().find(([url]) => url.endsWith("/sfu/get"));
if (call === undefined) return undefined;
const { delay_id, delay_timeout, delay_cs_api_url } = JSON.parse(
call[1]?.body as string,
);
return { delay_id, delay_timeout, delay_cs_api_url };
}
it("uses CS API with url transport", async () => {
mockJWTService();
await delegateDelayedLeave({ ...defaultParams, transport: urlTransport });
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalledWith({
url: urlTransport.url,
room_id: roomId,
slot_id: slotId,
member_id: "123",
delay_id: "delay123",
});
expect(jwtServiceCalls()).toEqual([]);
});
it("does not fall back to OpenID with url transport", async () => {
mockJWTService();
client._unstable_delegateDelayedLeave.mockRejectedValueOnce(
unsupportedError,
);
await expect(
delegateDelayedLeave({ ...defaultParams, transport: urlTransport }),
).rejects.toThrow();
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual([]);
});
it("uses OpenID with livekit_service_url transport", async () => {
mockJWTService();
await delegateDelayedLeave({
...defaultParams,
transport: serviceUrlTransport,
});
expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled();
expect(delegationViaJWTService()).toEqual({
delay_id: "delay123",
delay_timeout: 3_600_000,
delay_cs_api_url: client.baseUrl,
});
});
it("uses OpenID with url + livekit_service_url transport", async () => {
mockJWTService();
// Must match the flow that got the token, so the identities line up
await delegateDelayedLeave({
...defaultParams,
transport: urlAndServiceUrlTransport,
});
expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled();
expect(delegationViaJWTService()).toEqual({
delay_id: "delay123",
delay_timeout: 3_600_000,
delay_cs_api_url: client.baseUrl,
});
});
});
describe("chooseAuthFlow", () => {
it.each([
[
"url",
"publisher",
urlTransport,
{ kind: "csApi", url: urlTransport.url },
],
[
"url",
"subscriber",
urlTransport,
{ kind: "csApi", url: urlTransport.url },
],
[
"livekit_service_url",
"publisher",
serviceUrlTransport,
{ kind: "openID", serviceUrl },
],
[
"livekit_service_url",
"subscriber",
serviceUrlTransport,
{ kind: "openID", serviceUrl },
],
[
"url + livekit_service_url",
"publisher",
urlAndServiceUrlTransport,
{ kind: "openID", serviceUrl },
],
[
"url + livekit_service_url",
"subscriber",
urlAndServiceUrlTransport,
{ kind: "csApi", url: urlTransport.url },
],
] as const)(
"chooses the flow for a %s transport as %s",
(_shape, role, transport, flow) => {
expect(chooseAuthFlow(transport, role)).toEqual(flow);
},
);
});
+212
View File
@@ -0,0 +1,212 @@
/*
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.
*/
/**
* Authentication with a LiveKit transport: the MSC4195 client-server API
* through our homeserver for a transport with an SFU `url`, the OpenID flow
* against the JWT service for one with a `livekit_service_url`, and the
* choice between them where a transport carries both.
*/
import { ConnectionError, MatrixError } from "matrix-js-sdk";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { NoMatrix2AuthorizationService } from "../../../errors";
import * as csApi from "./csApi";
import * as openID from "./openID";
import { type ConnectionRole, type SFUConfig } from "./types";
export type { ClientOpenIDParts } from "./openID";
export type { ClientGetTokenParts, ClientDelegationParts } from "./csApi";
export type * from "./types";
/**
* Whether an error means the homeserver does not implement the MSC4195
* endpoints. The MSC says `M_NOT_FOUND`; a homeserver without support answers
* with whatever it uses for unknown routes (Synapse: 404 with
* `M_UNRECOGNIZED`), so any 404 counts.
*/
function isEndpointUnsupported(e: unknown): boolean {
return (
e instanceof MatrixError &&
(e.httpStatus === 404 ||
e.errcode === "M_NOT_FOUND" ||
e.errcode === "M_UNRECOGNIZED")
);
}
/**
* Whether an error means the MSC4195 client-server API cannot be used from
* here: the homeserver does not implement it, or the request never reached
* the homeserver because a widget host does not expose the endpoint
* (MSC4533), which the widget API reports as a plain error. A connection
* error is neither: the endpoint may well exist.
*/
function isCsApiUnavailable(e: unknown): boolean {
if (isEndpointUnsupported(e)) return true;
return !(e instanceof MatrixError) && !(e instanceof ConnectionError);
}
export interface GetSFUConfigParams {
client: csApi.ClientGetTokenParts & openID.ClientOpenIDParts;
membership: CallMembershipIdentityParts;
transport: UnstableLivekitTransport;
/** The homeserver the transport belongs to. */
serverName: string;
roomId: string;
slotId: string;
role: ConnectionRole;
logger: Logger;
}
/** Authenticates with a transport, through whichever flow suits it and our role. */
export async function getSFUConfig({
client,
membership,
transport,
serverName,
roomId,
slotId,
role,
logger,
}: GetSFUConfigParams): Promise<SFUConfig> {
const viaOpenID = async (serviceUrl: string): Promise<SFUConfig> =>
await openID.getSFUConfig({
client,
membership,
serviceUrl,
roomId,
slotId,
role,
logger,
});
const flow = chooseAuthFlow(transport, role);
if (flow.kind === "openID") return await viaOpenID(flow.serviceUrl);
try {
return await csApi.getSFUConfig({
client,
membership,
url: flow.url,
serverName,
roomId,
slotId,
});
} catch (e) {
// A publisher never falls back: a homeserver that advertises a `url` but
// does not implement MSC4195 is misconfigured, and the admin should hear
// about it before `livekit_service_url` is deprecated
const mayFallBack =
role === "subscriber" && "livekit_service_url" in transport;
if (mayFallBack && isCsApiUnavailable(e)) {
logger.warn(
`Cannot use the MSC4195 get_token endpoint (${e}), falling back to the OpenID flow with service ${transport.livekit_service_url}`,
);
return await viaOpenID(transport.livekit_service_url);
}
if (isEndpointUnsupported(e)) {
logger.error(
`Homeserver does not support the MSC4195 get_token endpoint, cannot get token for transport ${flow.url}`,
);
throw new NoMatrix2AuthorizationService(e as Error);
}
throw e;
}
}
export interface DelegateDelayedLeaveParams {
client: csApi.ClientDelegationParts & openID.ClientOpenIDParts;
membership: CallMembershipIdentityParts;
/** The transport we publish on. */
transport: UnstableLivekitTransport;
roomId: string;
slotId: string;
delayId: string;
/** How long the service waits before sending the leave, for the JWT service flow. */
delayTimeoutMs: number;
logger: Logger;
}
/**
* Hands the delayed leave to the service that authenticated us as a
* publisher, so that the identity it watches for on the SFU is the one we
* publish under.
*/
export async function delegateDelayedLeave({
client,
membership,
transport,
roomId,
slotId,
delayId,
delayTimeoutMs,
logger,
}: DelegateDelayedLeaveParams): Promise<void> {
const flow = chooseAuthFlow(transport, "publisher");
if (flow.kind === "csApi") {
try {
await csApi.delegateDelayedLeave({
client,
membership,
url: flow.url,
roomId,
slotId,
delayId,
});
} catch (e) {
if (isEndpointUnsupported(e))
logger.error(
"Server misconfigured: transport contains a 'url' field but server does not support the MSC4195 delegate_delayed_leave endpoint",
);
throw e;
}
} else {
// The token this issues is discarded; the request is what hands the leave over
await openID.getSFUConfig({
client,
membership,
serviceUrl: flow.serviceUrl,
roomId,
slotId,
role: "publisher",
delayEndpointBaseUrl: client.baseUrl,
delayId,
delayTimeoutMs,
logger,
});
}
}
/** The flow with which the local member authenticates against a transport. */
export type AuthFlow =
| { kind: "csApi"; url: string }
| { kind: "openID"; serviceUrl: string };
/**
* Chooses between the MSC4195 client-server API and the OpenID flow against
* the transport's JWT service.
*
* Only the JWT service's `/sfu/get` endpoint assigns the legacy participant
* identity that clients predating MSC4195 expect from anyone advertising a
* membership, so a publisher keeps using the JWT service wherever the
* transport still offers one, even next to a `url`. A subscriber may use the
* client-server API regardless, as nobody matches its identity.
*/
export function chooseAuthFlow(
transport: UnstableLivekitTransport,
role: ConnectionRole,
): AuthFlow {
if ("url" in transport) {
const useCsApi =
!("livekit_service_url" in transport) || role === "subscriber";
if (useCsApi) return { kind: "csApi", url: transport.url };
}
return { kind: "openID", serviceUrl: transport.livekit_service_url };
}
@@ -0,0 +1,229 @@
/*
Copyright 2025 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 {
beforeEach,
afterEach,
describe,
expect,
it,
type MockedObject,
vitest,
} from "vitest";
import fetchMock from "fetch-mock";
import { MatrixError } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import {
getSFUConfig,
type GetSFUConfigParams,
type ClientOpenIDParts,
} from "./openID";
import { testJWTToken } from "../../../utils/test-fixtures";
import { ownMemberMock } from "../../../utils/test";
import { FailToGetOpenIdToken } from "../../../errors";
const serviceUrl = "https://sfu.example.org";
const delayParams = {
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
delayId: "mock_delay_id",
delayTimeoutMs: 3_600_000,
};
describe("getSFUConfig", () => {
let matrixClient: MockedObject<ClientOpenIDParts>;
let params: GetSFUConfigParams;
beforeEach(() => {
fetchMock.catch(404);
matrixClient = { getOpenIdToken: vitest.fn() };
params = {
client: matrixClient,
membership: ownMemberMock,
serviceUrl: "https://sfu.example.org",
roomId: "!example_room_id",
slotId: "m.call#ROOM",
role: "publisher",
logger,
};
});
afterEach(async () => {
void (await fetchMock.flush());
vitest.clearAllMocks();
fetchMock.reset();
});
it("should handle fetching a token", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfig(params);
expect(config).toEqual({
jwt: testJWTToken,
url: serviceUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
});
it("should fail if the SFU errors", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
try {
await getSFUConfig(params);
} catch (ex: unknown) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
const mxError = (ex as Error).cause as MatrixError;
expect(mxError.message).toEqual(
"MatrixError: [500] Failed to look up user info from homeserver",
);
return;
}
expect.fail("Expected test to throw;");
});
it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => {
let callCount = 0;
fetchMock.post(
"https://sfu.example.org/sfu/get",
(url, opts) => {
callCount++;
const body = JSON.parse(opts.body as string);
// First call: check if it has delay parts and return 400
if (callCount === 1) {
expect(body).toHaveProperty("delay_id", "mock_delay_id");
return {
status: 400,
body: { errcode: "M_BAD_JSON", error: "Unsupported parameters" },
};
}
// Second call: check if delay parts were stripped and return success
expect(body).not.toHaveProperty("delay_id");
expect(body).not.toHaveProperty("delay_timeout");
expect(body).not.toHaveProperty("delay_cs_api_url");
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
},
{ overwriteRoutes: true },
);
const config = await getSFUConfig({ ...params, ...delayParams });
expect(config.jwt).toBe(testJWTToken);
expect(callCount).toBe(2);
});
it("should successfully send delay parameters to the JWT service legacy endpoint", async () => {
fetchMock.post(
"https://sfu.example.org/sfu/get",
(url, opts) => {
const body = JSON.parse(opts.body as string);
// Verify, that the request contains the expected delay parameters
if (
body.delay_id === "mock_delay_id" &&
body.delay_timeout === 3600000 &&
body.delay_cs_api_url === "https://matrix.homeserverserver.org"
) {
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
}
return {
status: 400,
body: { error: "Missing expected delay params" },
};
},
{ overwriteRoutes: true },
);
const config = await getSFUConfig({ ...params, ...delayParams });
expect(config).toMatchObject({
jwt: testJWTToken,
url: serviceUrl,
});
});
it("should use the default endpoint when subscribing", async () => {
fetchMock.post("https://sfu.example.org/get_token", (url, opts) => {
const body = JSON.parse(opts.body as string);
expect(body).toMatchObject({
room_id: "!example_room_id",
slot_id: "m.call#ROOM",
member: {
id: ownMemberMock.memberId,
claimed_user_id: ownMemberMock.userId,
claimed_device_id: ownMemberMock.deviceId,
},
});
return { status: 200, body: { url: serviceUrl, jwt: testJWTToken } };
});
const config = await getSFUConfig({ ...params, role: "subscriber" });
expect(config.jwt).toBe(testJWTToken);
expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false);
});
it("should fail when subscribing if the default endpoint is missing", async () => {
// Nothing but the catch-all 404 answers the default endpoint. Subscribers
// must not fall back to the legacy endpoint, which would hand them a
// legacy identity.
await expect(
getSFUConfig({ ...params, role: "subscriber" }),
).rejects.toThrow(FailToGetOpenIdToken);
expect(fetchMock.called("https://sfu.example.org/get_token")).toBe(true);
expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false);
});
it("should retry fetching the openid token", async () => {
let count = 0;
matrixClient.getOpenIdToken.mockImplementation(async () => {
count++;
if (count < 2) {
throw Error("Test failure");
}
return Promise.resolve({
token_type: "Bearer",
access_token: "foobar",
matrix_server_name: "example.org",
expires_in: 30,
});
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfig({ ...params, ...delayParams });
expect(config).toEqual({
jwt: testJWTToken,
url: serviceUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
});
});
@@ -0,0 +1,203 @@
/*
Copyright 2023, 2024 New Vector Ltd.
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 IOpenIDToken,
type MatrixClient,
parseErrorResponse,
} from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { FailToGetOpenIdToken } from "../../../errors";
import { doNetworkOperationWithRetry } from "../../../utils/network";
import {
type ConnectionRole,
extractFullConfigFromToken,
type SFUConfig,
} from "./types";
export type ClientOpenIDParts = Pick<MatrixClient, "getOpenIdToken">;
export interface GetSFUConfigParams {
client: ClientOpenIDParts;
/** Our own membership identity, as the JWT service wants it. */
membership: CallMembershipIdentityParts;
/** The base URL of the LiveKit JWT service. */
serviceUrl: string;
/** The Matrix room the session is in; the JWT service maps it to a LiveKit room. */
roomId: string;
/** The MatrixRTC slot the session is in, e.g. `m.call#ROOM`. */
slotId: string;
role: ConnectionRole;
/** The base URL of the Matrix homeserver, for delayed leave delegation. */
delayEndpointBaseUrl?: string;
/** The delay ID of the leave event to delegate to the JWT service. */
delayId?: string;
/** How long the SFU waits before sending the delegated leave. Needed with `delayId`. */
delayTimeoutMs?: number;
logger: Logger;
}
/**
* Authenticates with a LiveKit JWT service through the OpenID flow: an OpenID
* token from the homeserver, exchanged at the service for an SFU token.
*
* A publisher uses the legacy `/sfu/get` endpoint, the only one that assigns
* the `<user_id>:<device_id>` identity that clients predating MSC4195 match a
* membership against. A subscriber uses the `/get_token` endpoint, which
* hashes the identity: nobody matches a subscriber.
* @throws {@link FailToGetOpenIdToken}
*/
export async function getSFUConfig({
client,
membership,
serviceUrl,
roomId,
slotId,
role,
delayEndpointBaseUrl,
delayId,
delayTimeoutMs,
logger,
}: GetSFUConfigParams): Promise<SFUConfig> {
let openIdToken: IOpenIDToken;
try {
openIdToken = await doNetworkOperationWithRetry(async () =>
client.getOpenIdToken(),
);
} catch (error) {
throw new FailToGetOpenIdToken(
error instanceof Error ? error : new Error("Unknown error"),
);
}
logger.debug("Got openID token", openIdToken);
const delay: DelayParams =
// Also check for empty string
delayId && delayEndpointBaseUrl
? {
delay_id: delayId,
delay_timeout: delayTimeoutMs,
delay_cs_api_url: delayEndpointBaseUrl,
}
: {};
const endpoint = role === "publisher" ? "legacy" : "default";
try {
logger.info(
`Trying to get JWT with ${endpoint} endpoint for focus ${serviceUrl}...`,
);
const sfuConfig = await (
endpoint === "legacy" ? getLiveKitJWTLegacy : getLiveKitJWT
)(membership, serviceUrl, roomId, slotId, openIdToken, delay);
logger.info(`Got JWT from call's active focus URL.`);
return extractFullConfigFromToken(sfuConfig);
} catch (e) {
logger.error(`Failed fetching jwt with ${endpoint} endpoint:`, e);
throw new FailToGetOpenIdToken(
e instanceof Error ? e : new Error(`Unknown error ${e}`),
);
}
}
interface DelayParams {
delay_id?: string;
delay_timeout?: number;
delay_cs_api_url?: string;
}
/** The legacy `/sfu/get` endpoint, which assigns the `<user_id>:<device_id>` identity. */
async function getLiveKitJWTLegacy(
membership: CallMembershipIdentityParts,
livekitServiceURL: string,
matrixRoomId: string,
_slotId: string, // The legacy endpoint predates slots
openIDToken: IOpenIDToken,
delay: DelayParams,
): Promise<{ url: string; jwt: string }> {
const makeRequest = async (delayParts: DelayParams): Promise<Response> =>
await fetch(livekitServiceURL + "/sfu/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
// The legacy JWT endpoint uses only the matrix room id to calculate
// the livekit room alias, which comes back in the JWT payload
room: matrixRoomId,
openid_token: openIDToken,
device_id: membership.deviceId,
...delayParts,
}),
});
const res = await doNetworkOperationWithRetry(async () => {
let response = await makeRequest(delay);
// A service from before delegation rejects the delay fields as bad JSON;
// it still issues a token without them
if (response.status === 400 && Object.keys(delay).length > 0) {
try {
const errorBody = await response.json();
if (errorBody.errcode === "M_BAD_JSON")
response = await makeRequest({});
} catch {
// Not a JSON error body, so a real error
}
}
return response;
});
if (!res.ok) throw parseErrorResponse(res, await res.text());
return await res.json();
}
class NotSupportedError extends Error {
public constructor(message: string) {
super(message);
this.name = "NotSupported";
}
}
/** The `/get_token` endpoint, which assigns a hashed identity. */
async function getLiveKitJWT(
membership: CallMembershipIdentityParts,
livekitServiceURL: string,
matrixRoomId: string,
slotId: string,
openIDToken: IOpenIDToken,
delay: DelayParams,
): Promise<{ url: string; jwt: string }> {
const { userId, deviceId, memberId } = membership;
const res = await doNetworkOperationWithRetry(
async () =>
await fetch(livekitServiceURL + "/get_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
room_id: matrixRoomId,
slot_id: slotId,
openid_token: openIDToken,
member: {
id: memberId,
claimed_user_id: userId,
claimed_device_id: deviceId,
},
...delay,
}),
}),
);
if (!res.ok) {
const msg = "SFU Config fetch failed with status code " + res.status;
if (res.status === 404) throw new NotSupportedError(msg);
throw parseErrorResponse(res, await res.text());
}
return await res.json();
}
@@ -0,0 +1,58 @@
/*
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 UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
/**
* A transport and the homeserver it belongs to. The server name is what our
* homeserver asks for a token for a transport with a `url`, over federation
* where the server is not ours; a JWT service is reached directly.
*/
export interface TransportLocator {
transport: UnstableLivekitTransport;
serverName: string;
}
/** Whether we publish on a transport or only subscribe to it. */
export type ConnectionRole = "publisher" | "subscriber";
/** What authenticating with a transport yields: where to connect and with what. */
export interface SFUConfig {
/**
* The WebSocket URL of the SFU, as opposed to the JWT service URL in the
* transport. The legacy JWT service returns it; the client-server API flow
* has it from the transport.
*/
url: string;
jwt: string;
livekitAlias: string;
livekitIdentity: string;
}
/** The parts of the JWT payload the backend reads. */
interface SFUJWTPayload {
/** The subject: the participant identity the token carries. */
sub: string;
video: {
room: string;
};
}
/** Completes the SFU websocket url and the JWT with what the JWT payload says. */
export function extractFullConfigFromToken(sfuConfig: {
url: string;
jwt: string;
}): SFUConfig {
const [, payloadStr] = sfuConfig.jwt.split(".");
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
return {
jwt: sfuConfig.jwt,
url: sfuConfig.url,
livekitAlias: payload.video.room,
livekitIdentity: payload.sub,
};
}
@@ -0,0 +1,38 @@
/*
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 { ConnectionState as LivekitConnectionState } from "livekit-client";
import { MediaConnectionState } from "../api";
import { type ConnectionState } from "./Connection";
const livekitStates: Record<LivekitConnectionState, MediaConnectionState> = {
[LivekitConnectionState.Disconnected]: MediaConnectionState.Disconnected,
[LivekitConnectionState.Connecting]: MediaConnectionState.Connecting,
[LivekitConnectionState.Connected]: MediaConnectionState.Connected,
[LivekitConnectionState.Reconnecting]: MediaConnectionState.Reconnecting,
[LivekitConnectionState.SignalReconnecting]:
MediaConnectionState.Reconnecting,
};
/** A connection's state as the session sees it; an error passes through. */
export function toMediaConnectionState(
state: ConnectionState,
): MediaConnectionState | Error {
switch (state.state) {
case "initialized":
return MediaConnectionState.Initialized;
case "authenticating":
return MediaConnectionState.Preparing;
case "authenticated":
return livekitStates[state.livekitState];
case "error":
return state.error;
case "stopped":
return MediaConnectionState.Stopped;
}
}
@@ -0,0 +1,189 @@
/*
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 { VideoPresets, type VideoPreset } from "livekit-client";
import { buildLiveKitOptions } from "./livekitOptions";
describe("buildLiveKitOptions", () => {
it("returns sensible defaults with no config", () => {
const opts = buildLiveKitOptions();
expect(opts.adaptiveStream).toBe(true);
expect(opts.dynacast).toBe(true);
expect(opts.videoCaptureDefaults?.resolution).toEqual(
VideoPresets.h720.resolution,
);
expect(opts.publishDefaults?.videoCodec).toBe("vp8");
expect(opts.publishDefaults?.videoEncoding).toEqual({
maxBitrate: 1_700_000,
maxFramerate: 30,
});
expect(opts.publishDefaults?.screenShareEncoding).toEqual({
maxBitrate: 5_000_000,
maxFramerate: 30,
});
expect(opts.publishDefaults?.videoSimulcastLayers).toEqual([
VideoPresets.h180,
VideoPresets.h360,
]);
});
it("applies video codec from config", () => {
const opts = buildLiveKitOptions({ video_codec: "vp9" });
expect(opts.publishDefaults?.videoCodec).toBe("vp9");
});
it("applies video resolution and encoding from config", () => {
const baseVideoConfig = {
max_resolution: 1080,
max_bitrate: 3_000_000,
max_framerate: 60,
};
const opts1080 = buildLiveKitOptions({
video: baseVideoConfig,
});
const opts1440 = buildLiveKitOptions({
video: { ...baseVideoConfig, max_resolution: 1440 },
});
const opts2160 = buildLiveKitOptions({
video: { ...baseVideoConfig, max_resolution: 2160 },
});
expect(opts1080.videoCaptureDefaults?.resolution).toEqual(
VideoPresets.h1080.resolution,
);
expect(opts1440.videoCaptureDefaults?.resolution).toEqual(
VideoPresets.h1440.resolution,
);
expect(opts2160.videoCaptureDefaults?.resolution).toEqual(
VideoPresets.h2160.resolution,
);
expect(opts1080.publishDefaults?.videoEncoding).toEqual({
maxBitrate: 3_000_000,
maxFramerate: 60,
});
});
it("applies screen share encoding from config", () => {
const opts = buildLiveKitOptions({
screen_share: {
max_bitrate: 8_000_000,
max_framerate: 15,
},
});
expect(opts.publishDefaults?.screenShareEncoding).toEqual({
maxBitrate: 8_000_000,
maxFramerate: 15,
});
});
it("uses DEFAULT_CONFIG defaults when only resolution is set", () => {
const opts = buildLiveKitOptions({
screen_share: {
max_resolution: 720,
},
});
// Bitrate and framerate fall back to DEFAULT_CONFIG, not the preset
expect(opts.publishDefaults?.screenShareEncoding).toEqual({
maxBitrate: 5_000_000,
maxFramerate: 30,
});
});
it("maps low resolutions to the closest preset, rounding up", () => {
const expectations: [number, VideoPreset][] = [
[180, VideoPresets.h180],
[360, VideoPresets.h360],
[480, VideoPresets.h540],
[540, VideoPresets.h540],
[720, VideoPresets.h720],
];
for (const [height, preset] of expectations) {
const opts = buildLiveKitOptions({ video: { max_resolution: height } });
expect(opts.videoCaptureDefaults?.resolution).toEqual(preset.resolution);
}
});
it("screen share layers fall back to max_framerate, then 30", () => {
const fromMax = buildLiveKitOptions({
screen_share: {
max_framerate: 15,
simulcast_layers: [{ height: 540, bitrate: 1_000_000 }],
},
});
expect(
fromMax.publishDefaults?.screenShareSimulcastLayers?.[0],
).toMatchObject({ encoding: { maxFramerate: 15 } });
const fromDefault = buildLiveKitOptions({
screen_share: {
simulcast_layers: [{ height: 540, bitrate: 1_000_000 }],
},
});
expect(
fromDefault.publishDefaults?.screenShareSimulcastLayers?.[0],
).toMatchObject({ encoding: { maxFramerate: 30 } });
});
it("applies custom video simulcast layers", () => {
const opts = buildLiveKitOptions({
video: {
simulcast_layers: [
{ height: 180, bitrate: 100_000 },
{ height: 360, bitrate: 300_000 },
{ height: 540, bitrate: 600_000 },
],
max_framerate: 24,
},
});
const layers = opts.publishDefaults?.videoSimulcastLayers;
expect(layers).toHaveLength(3);
expect(layers?.[0]).toMatchObject({
width: 320,
height: 180,
encoding: { maxBitrate: 100_000, maxFramerate: 24 },
});
expect(layers?.[2]).toMatchObject({
width: 960,
height: 540,
encoding: { maxBitrate: 600_000, maxFramerate: 24 },
});
});
it("applies custom screen share simulcast layers", () => {
const opts = buildLiveKitOptions({
screen_share: {
simulcast_layers: [{ height: 540, bitrate: 1_000_000, framerate: 5 }],
},
});
const layers = opts.publishDefaults?.screenShareSimulcastLayers;
expect(layers).toHaveLength(1);
expect(layers?.[0]).toMatchObject({
width: 960,
height: 540,
encoding: { maxBitrate: 1_000_000, maxFramerate: 5 },
});
});
it("does not include screenShareSimulcastLayers when not configured", () => {
const opts = buildLiveKitOptions();
expect(opts.publishDefaults?.screenShareSimulcastLayers).toBeUndefined();
});
it("backupCodec always uses stock VP8 720p encoding", () => {
const opts = buildLiveKitOptions({
video_codec: "av1",
video: { max_bitrate: 10_000_000, max_framerate: 60 },
});
const backup = opts.publishDefaults?.backupCodec as {
codec: string;
encoding: { maxBitrate: number; maxFramerate: number };
};
expect(backup.codec).toBe("vp8");
expect(backup.encoding).toEqual(VideoPresets.h720.encoding);
});
});
@@ -0,0 +1,140 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
AudioPresets,
DefaultReconnectPolicy,
type RoomOptions,
type TrackPublishDefaults,
type VideoPreset,
VideoPresets,
VideoPreset as VideoPresetClass,
} from "livekit-client";
import { defaultMediaQuality, type MediaQuality } from "../../config";
/**
* Find the closest matching VideoPreset for a given height.
*/
function videoPresetForHeight(height: number): VideoPreset {
if (height <= 180) return VideoPresets.h180;
if (height <= 360) return VideoPresets.h360;
if (height <= 540) return VideoPresets.h540;
if (height <= 720) return VideoPresets.h720;
if (height <= 1080) return VideoPresets.h1080;
if (height <= 1440) return VideoPresets.h1440;
return VideoPresets.h2160;
}
/**
* Build LiveKit publish options from config, falling back to sensible defaults.
*/
function buildPublishOptions(
mediaQuality: MediaQuality | undefined,
): TrackPublishDefaults {
const defaults = defaultMediaQuality;
const videoConf = mediaQuality?.video;
const screenConf = mediaQuality?.screen_share;
const codec = mediaQuality?.video_codec ?? defaults.video_codec;
// Camera video encoding
const videoEncoding = {
maxBitrate: videoConf?.max_bitrate ?? defaults.video.max_bitrate,
maxFramerate: videoConf?.max_framerate ?? defaults.video.max_framerate,
};
// Camera simulcast layers
let videoSimulcastLayers: VideoPreset[];
if (videoConf?.simulcast_layers) {
videoSimulcastLayers = videoConf.simulcast_layers.map(
(layer) =>
new VideoPresetClass(
Math.round((layer.height * 16) / 9),
layer.height,
layer.bitrate,
videoConf?.max_framerate ?? defaults.video.max_framerate,
),
);
} else {
videoSimulcastLayers = [VideoPresets.h180, VideoPresets.h360];
}
// Screen share encoding
const screenShareEncoding = {
maxBitrate: screenConf?.max_bitrate ?? defaults.screen_share.max_bitrate,
maxFramerate:
screenConf?.max_framerate ?? defaults.screen_share.max_framerate,
};
// Screen share simulcast layers
let screenShareSimulcastLayers: VideoPreset[] | undefined;
if (screenConf?.simulcast_layers) {
screenShareSimulcastLayers = screenConf.simulcast_layers.map(
(layer) =>
new VideoPresetClass(
Math.round((layer.height * 16) / 9),
layer.height,
layer.bitrate,
layer.framerate ?? screenConf?.max_framerate ?? 30,
),
);
}
return {
audioPreset: AudioPresets.music,
dtx: true,
// disable red because the livekit server strips out red packets for clients
// that don't support it (firefox) but of course that doesn't work with e2ee.
red: false,
forceStereo: false,
simulcast: true,
videoSimulcastLayers: videoSimulcastLayers as VideoPreset[],
screenShareEncoding,
...(screenShareSimulcastLayers && {
screenShareSimulcastLayers: screenShareSimulcastLayers as VideoPreset[],
}),
stopMicTrackOnMute: false,
videoCodec: codec,
videoEncoding,
backupCodec: {
codec: "vp8",
encoding: VideoPresets.h720.encoding,
},
} as TrackPublishDefaults;
}
/**
* Build LiveKit RoomOptions from the media quality a host asks for.
*/
export function buildLiveKitOptions(mediaQuality?: MediaQuality): RoomOptions {
const videoHeight =
mediaQuality?.video?.max_resolution ??
defaultMediaQuality.video.max_resolution;
const basePreset = videoPresetForHeight(videoHeight);
return {
// automatically manage subscribed video quality
adaptiveStream: true,
// optimize publishing bandwidth and CPU for published tracks
dynacast: true,
// capture settings
videoCaptureDefaults: {
resolution: basePreset.resolution,
},
// publish settings
publishDefaults: buildPublishOptions(mediaQuality),
// default LiveKit options that seem to be sane
stopLocalTrackOnUnpublish: true,
reconnectPolicy: new DefaultReconnectPolicy(),
disconnectOnPageLeave: true,
webAudioMix: false,
};
}
@@ -0,0 +1,145 @@
/*
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 {
audioCaptureOptions,
screenShareCaptureOptions,
screenSharePublishOptions,
videoCaptureOptions,
videoPublishOptions,
} from "./publishOptions";
describe("audioCaptureOptions", () => {
it("leaves a bare request to the room defaults", () => {
expect(audioCaptureOptions({ source: "microphone" })).toBeUndefined();
});
it("carries the device and the processing flags", () => {
expect(
audioCaptureOptions({
source: "microphone",
deviceId: "mic-1",
capture: { echoCancellation: false, noiseSuppression: true },
}),
).toEqual({
deviceId: "mic-1",
echoCancellation: false,
noiseSuppression: true,
});
});
it("drops an empty device id", () => {
expect(
audioCaptureOptions({
source: "microphone",
deviceId: "",
capture: { autoGainControl: false },
}),
).toEqual({ autoGainControl: false });
});
});
describe("videoCaptureOptions", () => {
it("leaves a request without device, processor or resolution to the room defaults", () => {
expect(
videoCaptureOptions({
source: "camera",
capture: { maxBitrate: 1_000_000 },
}),
).toBeUndefined();
});
it("carries the device and the resolution, but not the encoding", () => {
expect(
videoCaptureOptions({
source: "camera",
deviceId: "cam-1",
capture: {
resolution: { width: 1920, height: 1080, frameRate: 60 },
maxBitrate: 4_000_000,
},
}),
).toEqual({
deviceId: "cam-1",
resolution: { width: 1920, height: 1080, frameRate: 60 },
});
});
});
describe("videoPublishOptions", () => {
it("is nothing without capture settings", () => {
expect(videoPublishOptions(undefined)).toBeUndefined();
});
it("is empty for settings that only set the resolution", () => {
expect(
videoPublishOptions({ resolution: { width: 640, height: 360 } }),
).toEqual({});
});
it("maps the bitrate, framerate and codec to the encoding", () => {
expect(
videoPublishOptions({
maxBitrate: 4_000_000,
maxFramerate: 60,
codec: "vp9",
}),
).toEqual({
videoEncoding: { maxBitrate: 4_000_000, maxFramerate: 60 },
videoCodec: "vp9",
});
});
});
describe("screenShareCaptureOptions", () => {
it("captures the audio without processing by default", () => {
expect(screenShareCaptureOptions({ source: "screenShare" })).toEqual({
audio: {
autoGainControl: false,
noiseSuppression: false,
voiceIsolation: false,
},
selfBrowserSurface: "include",
surfaceSwitching: "include",
systemAudio: "include",
});
});
it("leaves the audio out on request and carries the resolution", () => {
expect(
screenShareCaptureOptions({
source: "screenShare",
audio: false,
capture: { resolution: { width: 1280, height: 720 } },
}),
).toMatchObject({
audio: false,
resolution: { width: 1280, height: 720 },
});
});
});
describe("screenSharePublishOptions", () => {
it("is nothing without capture settings", () => {
expect(screenSharePublishOptions(undefined)).toBeUndefined();
});
it("maps the bitrate, framerate and codec to the screen share encoding", () => {
expect(
screenSharePublishOptions({
maxBitrate: 5_000_000,
maxFramerate: 15,
codec: "av1",
}),
).toEqual({
screenShareEncoding: { maxBitrate: 5_000_000, maxFramerate: 15 },
videoCodec: "av1",
});
});
});
@@ -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.
*/
import {
type AudioCaptureOptions,
type ScreenShareCaptureOptions,
type TrackPublishOptions,
type VideoCaptureOptions,
} from "livekit-client";
import {
type PublishRequest,
type VideoCaptureSettings,
} from "../../media-api";
import { convertToLivekitProcessor } from "./videoProcessor";
export type PublishRequestFor<S extends PublishRequest["source"]> = Extract<
PublishRequest,
{ source: S }
>;
/** LiveKit fills in the room defaults for whatever a request leaves out. */
export function audioCaptureOptions({
deviceId,
capture,
}: PublishRequestFor<"microphone">): AudioCaptureOptions | undefined {
if (deviceId === undefined && capture === undefined) return undefined;
return { ...(deviceId && { deviceId }), ...capture };
}
export function videoCaptureOptions({
deviceId,
processor,
capture,
}: PublishRequestFor<"camera">): VideoCaptureOptions | undefined {
if (!deviceId && !processor && !capture?.resolution) return undefined;
return {
...(deviceId && { deviceId }),
...(processor && { processor: convertToLivekitProcessor(processor) }),
...(capture?.resolution && { resolution: capture.resolution }),
};
}
export function videoPublishOptions(
settings: VideoCaptureSettings | undefined,
): TrackPublishOptions | undefined {
if (settings === undefined) return undefined;
return {
...(settings.maxBitrate !== undefined && {
videoEncoding: {
maxBitrate: settings.maxBitrate,
maxFramerate: settings.maxFramerate,
},
}),
...(settings.codec && { videoCodec: settings.codec }),
};
}
export function screenShareCaptureOptions({
audio = true,
capture,
}: PublishRequestFor<"screenShare">): ScreenShareCaptureOptions {
return {
// No echo cancellation: it would cancel the other members' voices out of
// the shared audio
audio: audio && {
autoGainControl: false,
noiseSuppression: false,
voiceIsolation: false,
},
selfBrowserSurface: "include",
surfaceSwitching: "include",
systemAudio: "include",
...(capture?.resolution && { resolution: capture.resolution }),
};
}
export function screenSharePublishOptions(
settings: VideoCaptureSettings | undefined,
): TrackPublishOptions | undefined {
if (settings === undefined) return undefined;
return {
...(settings.maxBitrate !== undefined && {
screenShareEncoding: {
maxBitrate: settings.maxBitrate,
maxFramerate: settings.maxFramerate,
},
}),
...(settings.codec && { videoCodec: settings.codec }),
};
}
+38
View File
@@ -0,0 +1,38 @@
/*
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.
*/
/** LiveKit test doubles shared by the backend's unit tests. Not part of the package. */
import {
type RemoteParticipant,
type RemoteTrackPublication,
} from "livekit-client";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { mockEmitter } from "../../utils/test";
export const exampleServiceUrl = "https://lk.example.org";
export const exampleTransport: UnstableLivekitTransport = {
type: "livekit",
livekit_service_url: exampleServiceUrl,
};
export function mockRemoteParticipant(
participant: Partial<RemoteParticipant>,
): RemoteParticipant {
return {
isLocal: false,
setVolume() {},
getTrackPublication: () =>
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
// this will only get used for `getTrackPublications().length`
getTrackPublications: () => [0],
...mockEmitter(),
...participant,
} as RemoteParticipant;
}
@@ -0,0 +1,50 @@
/*
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 { Track, type VideoProcessorOptions } from "livekit-client";
import { describe, expect, it, vi } from "vitest";
import { type VideoProcessor } from "../../media-api";
import { convertToLivekitProcessor } from "./videoProcessor";
describe("convertToLivekitProcessor", () => {
const captured = { id: "captured" } as MediaStreamTrack;
const processed = { id: "processed" } as MediaStreamTrack;
const restarted = { id: "restarted" } as MediaStreamTrack;
const element = document.createElement("video");
const options: VideoProcessorOptions = {
kind: Track.Kind.Video,
track: captured,
element,
};
function fakeProcessor(): VideoProcessor {
return {
name: "blur",
init: vi.fn().mockResolvedValue(processed),
restart: vi.fn().mockResolvedValue(restarted),
destroy: vi.fn().mockResolvedValue(undefined),
};
}
it("holds the processed track where LiveKit reads it", async () => {
const processor = fakeProcessor();
const adapter = convertToLivekitProcessor(processor);
expect(adapter.name).toBe("blur");
expect(adapter.processedTrack).toBeUndefined();
await adapter.init(options);
expect(processor.init).toHaveBeenCalledWith(captured, element);
expect(adapter.processedTrack).toBe(processed);
await adapter.restart(options);
expect(adapter.processedTrack).toBe(restarted);
await adapter.destroy();
expect(processor.destroy).toHaveBeenCalled();
});
});
@@ -0,0 +1,34 @@
/*
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 Track, type TrackProcessor } from "livekit-client";
import { type VideoProcessor } from "../../media-api";
/**
* A `VideoProcessor` as livekit-client wants it. LiveKit reads the processed
* track off the processor after `init` and `restart` rather than taking it as
* their result, so the adapter keeps it there.
*/
export function convertToLivekitProcessor(
processor: VideoProcessor,
): TrackProcessor<Track.Kind.Video> {
const adapter: TrackProcessor<Track.Kind.Video> = {
name: processor.name,
init: async (opts) => {
adapter.processedTrack = await processor.init(opts.track, opts.element);
},
restart: async (opts) => {
adapter.processedTrack = await processor.restart(
opts.track,
opts.element,
);
},
destroy: async () => processor.destroy(),
};
return adapter;
}
+145
View File
@@ -0,0 +1,145 @@
/*
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 { MatrixRTCError, type ParticipationState } from "./api";
import { type MembershipState } from "./matrixrtc/LocalMembership";
import { MediaConnectionState } from "./media-backend/api";
import { participationState } from "./participationState";
const error = new MatrixRTCError("gone");
const waiting: ParticipationState = { kind: "waitingForTransport" };
const connecting: ParticipationState = { kind: "connecting" };
const connected: ParticipationState = { kind: "connected" };
const reconnecting: ParticipationState = {
kind: "reconnecting",
reason: "media",
};
const joined: MembershipState = { kind: "joined" };
describe("participationState", () => {
it.each<
[
string,
ParticipationState,
MembershipState,
MediaConnectionState | Error,
ParticipationState,
]
>([
[
"no transport yet",
waiting,
{ kind: "waitingForTransport" },
MediaConnectionState.Initialized,
waiting,
],
[
"joining",
waiting,
{ kind: "joining" },
MediaConnectionState.Connecting,
connecting,
],
[
"joined, media still connecting",
connecting,
joined,
MediaConnectionState.Connecting,
connecting,
],
[
"both links up",
connecting,
joined,
MediaConnectionState.Connected,
connected,
],
[
"media dropped after connecting",
connected,
joined,
MediaConnectionState.Reconnecting,
reconnecting,
],
[
"homeserver dropped after connecting",
connected,
{ kind: "reconnecting", reason: "sync" },
MediaConnectionState.Connected,
{ kind: "reconnecting", reason: "sync" },
],
[
"the matrix side wins over the media side",
reconnecting,
{ kind: "reconnecting", reason: "membership" },
MediaConnectionState.Reconnecting,
{ kind: "reconnecting", reason: "membership" },
],
[
"membership reconnecting before the media ever came up",
connecting,
{ kind: "reconnecting", reason: "sync" },
MediaConnectionState.Connecting,
connecting,
],
[
"back from a reconnect",
reconnecting,
joined,
MediaConnectionState.Connected,
connected,
],
[
"a membership error",
connected,
{ kind: "failed", error },
MediaConnectionState.Connected,
{ kind: "failed", error },
],
[
"a media connection error",
connected,
joined,
error,
{ kind: "failed", error },
],
[
"failed stays failed",
{ kind: "failed", error },
joined,
MediaConnectionState.Connected,
{ kind: "failed", error },
],
[
"left",
connected,
{ kind: "left" },
MediaConnectionState.Stopped,
{ kind: "left" },
],
[
"left after an error",
{ kind: "failed", error },
{ kind: "left" },
MediaConnectionState.Stopped,
{ kind: "left" },
],
])("%s", (_name, previous, membership, media, expected) => {
expect(participationState(previous, membership, media)).toEqual(expected);
});
it("wraps a plain media error", () => {
const state = participationState(connected, joined, new Error("boom"));
expect(state.kind).toBe("failed");
if (state.kind === "failed") {
expect(state.error).toBeInstanceOf(MatrixRTCError);
expect(state.error.cause).toBeInstanceOf(Error);
}
});
});
+42
View File
@@ -0,0 +1,42 @@
/*
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 ParticipationState } from "./api";
import { toMatrixRTCError } from "./errors";
import { type MembershipState } from "./matrixrtc/LocalMembership";
import { MediaConnectionState } from "./media-backend/api";
/**
* The membership combined with the local media connection, in priority
* order; `left` and `failed` are absorbing. "Connected once" is tracked here
* rather than in the membership because `connected` needs both links.
*/
export function participationState(
previous: ParticipationState,
membership: MembershipState,
media: MediaConnectionState | Error,
): ParticipationState {
if (membership.kind === "left") return { kind: "left" };
if (previous.kind === "failed") return previous;
if (membership.kind === "failed") return membership;
if (media instanceof Error)
return { kind: "failed", error: toMatrixRTCError(media) };
if (membership.kind === "waitingForTransport") return membership;
if (membership.kind === "joining") return { kind: "connecting" };
const reason =
membership.kind === "reconnecting"
? membership.reason
: media === MediaConnectionState.Connected
? null
: "media";
if (reason === null) return { kind: "connected" };
const wasConnected =
previous.kind === "connected" || previous.kind === "reconnecting";
return wasConnected
? { kind: "reconnecting", reason }
: { kind: "connecting" };
}
+29
View File
@@ -0,0 +1,29 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BehaviorSubject } from "rxjs";
/**
* A stateful, read-only reactive value. As an Observable, it is "hot" and
* always replays the current value upon subscription.
*
* A Behavior is to BehaviorSubject what Observable is to Subject; it does not
* provide a way to imperatively set new values. For more info on the
* distinction between Behaviors and Observables, see
* https://monoid.dk/post/behaviors-and-streams-why-both/.
*/
export type Behavior<T> = Omit<
BehaviorSubject<T>,
"next" | "observers" | "error"
>;
/**
* Creates a Behavior which never changes in value.
*/
export function constant<T>(value: T): Behavior<T> {
return new BehaviorSubject(value);
}
+314
View File
@@ -0,0 +1,314 @@
/*
Copyright 2025 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 { BehaviorSubject, combineLatest, Subject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { sleep } from "matrix-js-sdk/lib/utils";
import {
Epoch,
mapEpoch,
ObservableScope,
trackEpoch,
} from "./ObservableScope";
import { testScope, withTestScheduler } from "../utils/test";
describe("Epoch", () => {
it("should map the value correctly", () => {
const epoch = new Epoch(1);
const mappedEpoch = epoch.mapInner((v) => v + 1);
expect(mappedEpoch.value).toBe(2);
expect(mappedEpoch.epoch).toBe(0);
});
it("should be tracked from an observable", () => {
withTestScheduler(({ expectObservable, behavior }) => {
const observable$ = behavior("abc", {
a: 1,
b: 2,
c: 3,
});
const epochObservable$ = observable$.pipe(trackEpoch());
expectObservable(epochObservable$).toBe("abc", {
a: expect.toSatisfy((e) => e.epoch === 0 && e.value === 1),
b: expect.toSatisfy((e) => e.epoch === 1 && e.value === 2),
c: expect.toSatisfy((e) => e.epoch === 2 && e.value === 3),
});
});
});
it("can be mapped without loosing epoch information", () => {
withTestScheduler(({ expectObservable, behavior }) => {
const observable$ = behavior("abc", {
a: "A",
b: "B",
c: "C",
});
const epochObservable$ = observable$.pipe(trackEpoch());
const derivedEpoch$ = epochObservable$.pipe(
mapEpoch((e) => e + "-mapped"),
);
expectObservable(derivedEpoch$).toBe("abc", {
a: new Epoch("A-mapped", 0),
b: new Epoch("B-mapped", 1),
c: new Epoch("C-mapped", 2),
});
});
});
it("diamonds emits in a predictable order", () => {
const sb$ = new BehaviorSubject("initial");
const root$ = sb$.pipe(trackEpoch());
const derivedA$ = root$.pipe(mapEpoch((e) => e + "-A"));
const derivedB$ = root$.pipe(mapEpoch((e) => e + "-B"));
combineLatest([root$, derivedB$, derivedA$]).subscribe(
([root, derivedA, derivedB]) => {
logger.log(
"combined" +
root.epoch +
root.value +
"\n" +
derivedA.epoch +
derivedA.value +
"\n" +
derivedB.epoch +
derivedB.value,
);
},
);
sb$.next("updated");
sb$.next("ANOTERUPDATE");
});
it("behavior test", () => {
const scope = testScope();
const s$ = new Subject();
const behavior$ = scope.behavior(s$, 0);
behavior$.subscribe((value) => {
logger.log(`Received value: ${value}`);
});
s$.next(1);
s$.next(2);
s$.next(3);
s$.next(3);
s$.next(3);
s$.next(3);
s$.next(3);
s$.complete();
});
});
describe("Reconcile", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should wait clean up before processing next", async () => {
vi.useFakeTimers();
const scope = testScope();
const behavior$ = new BehaviorSubject<number>(0);
const setup = vi.fn().mockImplementation(async () => await sleep(100));
const cleanup = vi
.fn()
.mockImplementation(async (n: number) => await sleep(100));
scope.reconcile(behavior$, async (value) => {
await setup();
return async (): Promise<void> => {
await cleanup(value);
};
});
// Let the initial setup process
await vi.advanceTimersByTimeAsync(120);
expect(setup).toHaveBeenCalledTimes(1);
expect(cleanup).toHaveBeenCalledTimes(0);
// Send next value
behavior$.next(1);
await vi.advanceTimersByTimeAsync(50);
// Should not have started setup for 1 yet
expect(setup).toHaveBeenCalledTimes(1);
expect(cleanup).toHaveBeenCalledTimes(1);
expect(cleanup).toHaveBeenCalledWith(0);
// Let cleanup finish
await vi.advanceTimersByTimeAsync(50);
// Now setup for 1 should have started
expect(setup).toHaveBeenCalledTimes(2);
});
it("should skip intermediates values that are not setup", async () => {
vi.useFakeTimers();
const scope = testScope();
const behavior$ = new BehaviorSubject<number>(0);
const setup = vi
.fn()
.mockImplementation(async (n: number) => await sleep(100));
const cleanupLock = Promise.withResolvers();
const cleanup = vi
.fn()
.mockImplementation(async (n: number) => await cleanupLock.promise);
scope.reconcile(behavior$, async (value) => {
await setup(value);
return async (): Promise<void> => {
await cleanup(value);
};
});
// Let the initial setup process (0)
await vi.advanceTimersByTimeAsync(120);
// Send 4 next values quickly
behavior$.next(1);
behavior$.next(2);
behavior$.next(3);
behavior$.next(4);
await vi.advanceTimersByTimeAsync(3000);
// should have only called cleanup for 0
expect(cleanup).toHaveBeenCalledTimes(1);
expect(cleanup).toHaveBeenCalledWith(0);
// Let cleanup finish
cleanupLock.resolve(undefined);
await vi.advanceTimersByTimeAsync(120);
// Now setup for 4 should have started, skipping 1,2,3
expect(setup).toHaveBeenCalledTimes(2);
expect(setup).toHaveBeenCalledWith(4);
expect(setup).not.toHaveBeenCalledWith(1);
expect(setup).not.toHaveBeenCalledWith(2);
expect(setup).not.toHaveBeenCalledWith(3);
});
// The loop ends a few microtasks before its promise settles; a value
// arriving in that window used to be dropped
it.each([0, 1, 2, 3, 4, 5, 6])(
"picks up a value that arrives %i microtasks after the previous loop ended",
async (ticks) => {
const scope = testScope();
const behavior$ = new BehaviorSubject<number>(0);
const setup = vi.fn();
scope.reconcile(behavior$, async (value) => {
setup(value);
return Promise.resolve();
});
for (let i = 0; i < ticks; i++) await Promise.resolve();
behavior$.next(1);
await vi.advanceTimersByTimeAsync(10);
expect(setup).toHaveBeenCalledWith(1);
},
);
it("should wait for setup to complete before starting cleanup", async () => {
vi.useFakeTimers();
const scope = testScope();
const behavior$ = new BehaviorSubject<number>(0);
const setup = vi
.fn()
.mockImplementation(async (n: number) => await sleep(3000));
const cleanupLock = Promise.withResolvers();
const cleanup = vi
.fn()
.mockImplementation(async (n: number) => await cleanupLock.promise);
scope.reconcile(behavior$, async (value) => {
await setup(value);
return async (): Promise<void> => {
await cleanup(value);
};
});
await vi.advanceTimersByTimeAsync(500);
// Setup for 0 should be in progress
expect(setup).toHaveBeenCalledTimes(1);
behavior$.next(1);
await vi.advanceTimersByTimeAsync(500);
// Should not have started setup for 1 yet
expect(setup).not.toHaveBeenCalledWith(1);
// Should not have called cleanup yet, because the setup for 0 is not done
expect(cleanup).toHaveBeenCalledTimes(0);
// Let setup for 0 finish
await vi.advanceTimersByTimeAsync(2500 + 100);
// Now cleanup for 0 should have started
expect(cleanup).toHaveBeenCalledTimes(1);
expect(cleanup).toHaveBeenCalledWith(0);
cleanupLock.resolve(undefined);
await vi.advanceTimersByTimeAsync(100);
// Now setup for 1 should have started
expect(setup).toHaveBeenCalledWith(1);
});
});
describe("behavior", () => {
it("warns with the tag and nesting depth, logging each new depth once", () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const scope = testScope();
const source$ = new Subject<number>();
const behavior$ = scope.behavior(source$, 0, "tagged");
// Re-enter once on value 1 (depth 2), then a nested re-entry on value 2
// (depth 3), which is the shape needed to strand a middle subscriber.
behavior$.subscribe((v) => {
if (v === 1) source$.next(2);
});
behavior$.subscribe((v) => {
if (v === 2) source$.next(3);
});
const seen: number[] = [];
behavior$.subscribe((v) => seen.push(v));
source$.next(1);
const messages = warn.mock.calls.map((c) => c[0] as string);
expect(messages).toEqual([
expect.stringContaining("Behavior (tagged) re-entered at depth 2"),
expect.stringContaining("Behavior (tagged) re-entered at depth 3"),
]);
warn.mock.calls.forEach((c) => expect(c[1]).toEqual(expect.any(String)));
// The behavior settles on the newest value while the last subscriber is
// left stranded on the oldest.
expect(behavior$.value).toBe(3);
expect(seen.at(-1)).toBe(1);
});
it("warns when the scope is ended while a behavior is mid-delivery", () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const scope = new ObservableScope();
const source$ = new Subject<number>();
const behavior$ = scope.behavior(source$, 0);
behavior$.subscribe((v) => {
if (v === 1) scope.end();
});
// A derived behavior is bound to the scope, so ending the scope
// mid-delivery unsubscribes it before the value reaches it.
const derived$ = scope.behavior(behavior$);
source$.next(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("Scope ended while 1 of its behaviors"),
expect.any(String),
);
expect(behavior$.value).toBe(1);
expect(derived$.value).toBe(0);
});
});
+334
View File
@@ -0,0 +1,334 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
BehaviorSubject,
catchError,
distinctUntilChanged,
EMPTY,
endWith,
filter,
map,
type Observable,
type OperatorFunction,
share,
take,
takeUntil,
} from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "./Behavior";
type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
type SplitBehavior<T> = keyof T extends string | number
? { [K in keyof T as `${K}$`]: Behavior<T[K]> }
: never;
const nothing = Symbol("nothing");
/**
* A scope which limits the execution lifetime of its bound Observables.
*/
export class ObservableScope {
/**
* Number of this scope's behaviors currently mid-delivery. Used to detect a
* scope being torn down synchronously from within one of its own emissions.
*/
private delivering = 0;
private readonly ended$ = new BehaviorSubject(false);
private readonly bindImpl: MonoTypeOperator = takeUntil(
this.ended$.pipe(filter((ended) => ended)),
);
/**
* Binds an Observable to this scope, so that it completes when the scope
* ends.
*/
public bind(): MonoTypeOperator {
return this.bindImpl;
}
private readonly shareImpl: MonoTypeOperator = share({
resetOnError: false,
resetOnComplete: false,
resetOnRefCountZero: false,
});
/**
* Shares (multicasts) the Observable as a hot Observable.
*/
public readonly share: MonoTypeOperator = (input$) =>
input$.pipe(this.bindImpl, this.shareImpl);
/**
* Converts an Observable to a Behavior. If no initial value is specified, the
* Observable must synchronously emit an initial value.
*/
public behavior<T>(
setValue$: Observable<T>,
initialValue: T | typeof nothing = nothing,
// An optional label used only in the re-entrancy warning below, so that a
// torn behavior can be identified from a rageshake.
tag?: string,
): Behavior<T> {
const subject$ = new BehaviorSubject(initialValue);
// Push values from the Observable into the BehaviorSubject.
// BehaviorSubjects have an undesirable feature where if you call 'complete',
// they will no longer re-emit their current value upon subscription. We want
// to support Observables that complete (for example `of({})`), so we have to
// take care to not propagate the completion event.
// If a subscriber synchronously causes this same behavior to emit again,
// rxjs delivers the nested value to every subscriber first and then
// resumes delivering the outer (older) value to the remaining subscribers,
// leaving them permanently out of sync with the others. A single such
// re-entry can only strand a contiguous run of subscribers; stranding a
// subscriber in the middle of the list needs a nested (deeper) re-entry, so
// we report the depth and log each new depth (with a stack trace) rather
// than only the first occurrence, to make a nested re-entry visible.
let depth = 0;
let maxReportedDepth = 1;
setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({
next: (value) => {
if (depth > 0 && depth + 1 > maxReportedDepth) {
maxReportedDepth = depth + 1;
logger.warn(
`Behavior${tag ? ` (${tag})` : ""} re-entered at depth ${
depth + 1
} while delivering a value; later subscribers will be left with a stale value`,
new Error().stack,
);
}
depth++;
this.delivering++;
try {
subject$.next(value);
} finally {
depth--;
this.delivering--;
}
},
error: (err: unknown) => {
subject$.error(err);
},
});
if (subject$.value === nothing)
throw new Error("Behavior failed to synchronously emit an initial value");
return subject$ as Behavior<T>;
}
/**
* Ends the scope, causing any bound Observables to complete.
*/
public end(): void {
if (this.delivering > 0)
logger.warn(
`Scope ended while ${this.delivering} of its behaviors were still delivering a value; later subscribers will be left with a stale value`,
new Error().stack,
);
this.ended$.next(true);
}
/**
* Register a callback to be executed when the scope is ended.
*/
public onEnd(callback: () => void): void {
this.ended$
.pipe(
filter((ended) => ended),
take(1),
)
.subscribe(callback);
}
/**
* For the duration of the scope, sync some external state with the value of
* the provided Behavior by way of an async function which attempts to update
* (reconcile) the external state. The reconciliation function may return a
* clean-up callback which will be called and awaited before the next change
* in value (or the end of the scope).
*
* All calls to the function and its clean-up callbacks are serialized. If the
* value changes faster than the handlers can keep up with, intermediate
* values may be skipped.
*
* Basically, this is like React's useEffect but async and for Behaviors.
*/
public reconcile<T>(
value$: Behavior<T>,
callback: (value: T) => Promise<(() => Promise<void>) | void>,
): void {
let latestValue: T | typeof nothing = nothing;
let running = false;
let cleanUp: (() => Promise<void>) | void = undefined;
let prevVal: T | typeof nothing = nothing;
// While this loop runs it will process the latest from `value$` until it caught up with the updates.
// It might skip updates from `value$` and only process the newest value after callback has resolved.
const reconcileLoop = async (): Promise<void> => {
running = true;
try {
while (latestValue !== prevVal) {
await cleanUp?.(); // Call the previous value's clean-up handler
prevVal = latestValue;
if (latestValue !== nothing) cleanUp = await callback(latestValue); // Sync current value...
// `latestValue` might have gotten updated during the `await callback`. That is why we loop here
}
} finally {
// Cleared the moment the loop is done, not when its promise settles:
// a value arriving in between would otherwise find the loop still
// marked as running and never be processed
running = false;
}
};
value$
.pipe(
catchError(() => EMPTY), // Ignore errors
this.bind(), // Limit to the duration of the scope
endWith(nothing), // Clean up when the scope ends
)
.subscribe((value) => {
// Always track the latest value! The `reconcileLoop` will run until it "processed" the "last" `latestValue`.
latestValue = value;
// There's already an instance of the below 'reconcileLoop' loop running
// concurrently. So lets let the loop handle it. NEVER instanciate two `reconcileLoop`s.
if (running) return;
void reconcileLoop();
});
}
/**
* Splits a Behavior of objects with static properties into an object with
* Behavior properties.
*
* For example, splitting a `Behavior<{ name: string; age: number }>` results
* in an object of type `{ name$: Behavior<string>; age$: Behavior<number> }`.
*/
public splitBehavior<T extends object>(
input$: Behavior<T>,
): SplitBehavior<T> {
return Object.fromEntries(
Object.keys(input$.value).map((key) => [
`${key}$`,
this.behavior(
input$.pipe(map((input) => input[key as keyof T])),
nothing,
key,
),
]),
) as SplitBehavior<T>;
}
}
/**
* `Epoch`'s can be used to create `Behavior`s and `Observable`s which derivitives can be merged
* with `combinedLatest` without duplicated emissions.
*
* This is useful in the following example:
* ```
* const rootObs$ = of("red","green","blue");
* const derivedObs$ = rootObs$.pipe(
* map((v)=> {red:"fire", green:"grass", blue:"water"}[v])
* );
* const otherDerivedObs$ = rootObs$.pipe(
* map((v)=> {red:"tomatoes", green:"leaves", blue:"sky"}[v])
* );
* const mergedObs$ = combineLatest([rootObs$, derivedObs$, otherDerivedObs$]).pipe(
* map(([color, a,b]) => color + " like " + a + " and " + b)
* );
*
* ```
* will result in 6 emissions with mismatching items like "red like fire and leaves"
*
* # Use Epoch
* ```
* const ancestorObs$ = of(1,2,3).pipe(trackEpoch());
* const derivedObs$ = ancestorObs$.pipe(
* mapEpoch((v)=> "this number: " + v)
* );
* const otherDerivedObs$ = ancestorObs$.pipe(
* mapEpoch((v)=> "multiplied by: " + v)
* );
* const mergedObs$ = combineLatest([derivedObs$, otherDerivedObs$]).pipe(
* filter((values) => values.every((v) => v.epoch === values[0].v)),
* map(([color, a, b]) => color + " like " + a + " and " + b)
* );
*
* ```
* will result in 3 emissions all matching (e.g. "blue like water and sky")
*/
export class Epoch<T> {
public readonly epoch: number;
public readonly value: T;
public constructor(value: T, epoch?: number) {
this.value = value;
this.epoch = epoch ?? 0;
}
/**
* Maps the value inside the epoch to a new value while keeping the epoch number.
* # usage
* ```
* const myEpoch$ = myObservable$.pipe(
* map(trackEpoch()),
* // this is the preferred way using mapEpoch
* mapEpoch((v)=> v+1)
* // This is how inner map can be used:
* map((epoch) => epoch.innerMap((v)=> v+1))
* // It is equivalent to:
* map((epoch) => new Epoch(epoch.value + 1, epoch.epoch))
* )
* ```
* See also `Epoch<T>`
*/
public mapInner<U>(map: (value: T) => U): Epoch<U> {
return new Epoch<U>(map(this.value), this.epoch);
}
}
/**
* A `pipe` compatible map oparator that keeps the epoch in tact but allows mapping the value.
* # usage
* ```
* const myEpoch$ = myObservable$.pipe(
* map(trackEpoch()),
* // this is the preferred way using mapEpoch
* mapEpoch((v)=> v+1)
* // This is how inner map can be used:
* map((epoch) => epoch.innerMap((v)=> v+1))
* // It is equivalent to:
* map((epoch) => new Epoch(epoch.value + 1, epoch.epoch))
* )
* ```
* See also `Epoch<T>`
*/
export function mapEpoch<T, U>(
mapFn: (value: T) => U,
): OperatorFunction<Epoch<T>, Epoch<U>> {
return map((e) => e.mapInner(mapFn));
}
/**
* # usage
* ```
* const myEpoch$ = myObservable$.pipe(
* map(trackEpoch()),
* map((epoch) => epoch.innerMap((v)=> v+1))
* )
* const derived = myEpoch$.pipe(
* mapEpoch((v)=>v^2)
* )
* ```
* See also `Epoch<T>`
*/
export function trackEpoch<T>(): OperatorFunction<T, Epoch<T>> {
return map<T, Epoch<T>>((value, number) => new Epoch(value, number));
}
+104
View File
@@ -0,0 +1,104 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "vitest";
import { type Observable, of, Subject, switchMap } from "rxjs";
import { withTestScheduler } from "../utils/test";
import { filterBehavior, generateItems, pauseWhen } from "./observable";
import { type Behavior } from "./Behavior";
test("pauseWhen", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const inputMarbles = " abcdefgh-i-jk-";
const pauseMarbles = " n-y--n-yn-y--n";
const outputMarbles = "abc--fgh-i---k";
expectObservable(
behavior(inputMarbles).pipe(
pauseWhen(behavior(pauseMarbles, { y: true, n: false })),
),
).toBe(outputMarbles);
});
});
test("generateItems", () => {
const scope1$ = new Subject<string>();
const scope2$ = new Subject<string>();
const scope3$ = new Subject<string>();
const scope4$ = new Subject<string>();
const scopeSubjects = [scope1$, scope2$, scope3$, scope4$];
withTestScheduler(({ hot, expectObservable }) => {
// Each scope should start when the input number reaches or surpasses their
// number and end when the input number drops back below their number.
// At the very end, unsubscribing should end all remaining scopes.
const inputMarbles = " 123242";
const outputMarbles = " abcbdb";
const subscriptionMarbles = "^-----!";
const scope1Marbles = " y-----n";
const scope2Marbles = " -y----n";
const scope3Marbles = " --ynyn";
const scope4Marbles = " ----yn";
expectObservable(
hot<string>(inputMarbles).pipe(
generateItems(
"test items",
function* (input) {
for (let i = 1; i <= +input; i++) {
yield { keys: [i], data: undefined };
}
},
(scope, data$, i) => {
scopeSubjects[i - 1].next("y");
scope.onEnd(() => scopeSubjects[i - 1].next("n"));
return i.toString();
},
),
),
subscriptionMarbles,
).toBe(outputMarbles, {
a: ["1"],
b: ["1", "2"],
c: ["1", "2", "3"],
d: ["1", "2", "3", "4"],
});
expectObservable(scope1$).toBe(scope1Marbles);
expectObservable(scope2$).toBe(scope2Marbles);
expectObservable(scope3$).toBe(scope3Marbles);
expectObservable(scope4$).toBe(scope4Marbles);
});
});
test("filterBehavior", () => {
withTestScheduler(({ behavior, expectObservable }) => {
// Filtering the input should segment it into 2 modes of non-null behavior.
const inputMarbles = " abcxabx";
const filteredMarbles = "a--xa-x";
const input$ = behavior(inputMarbles, {
a: "a",
b: "b",
c: "c",
x: null,
});
const filtered$: Observable<Behavior<string> | null> = input$.pipe(
filterBehavior((value) => typeof value === "string"),
);
expectObservable(filtered$).toBe(filteredMarbles, {
a: expect.any(Object),
x: null,
});
expectObservable(
filtered$.pipe(
switchMap((value$) => (value$ === null ? of(null) : value$)),
),
).toBe(inputMarbles, { a: "a", b: "b", c: "c", x: null });
});
});
+264
View File
@@ -0,0 +1,264 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type Observable,
audit,
defer,
filter,
finalize,
map,
of,
scan,
tap,
withLatestFrom,
BehaviorSubject,
type OperatorFunction,
distinctUntilChanged,
} from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "./Behavior";
import { Epoch, ObservableScope } from "./ObservableScope";
const nothing = Symbol("nothing");
/**
* RxJS operator that invokes a callback when the Observable is finalized,
* passing the most recently emitted value. If no value was emitted, the
* callback will not be invoked.
*/
export function finalizeValue<T>(callback: (finalValue: T) => void) {
return (source$: Observable<T>): Observable<T> =>
defer(() => {
let finalValue: T | typeof nothing = nothing;
return source$.pipe(
tap((value) => (finalValue = value)),
finalize(() => {
if (finalValue !== nothing) callback(finalValue);
}),
);
});
}
/**
* RxJS operator that pauses all changes in the input value whenever a Behavior
* is true. When the Behavior returns to being false, the most recently
* suppressed change is emitted as the most recent value.
*/
export function pauseWhen<T>(pause$: Behavior<boolean>) {
return (value$: Observable<T>): Observable<T> =>
value$.pipe(
withLatestFrom(pause$),
audit(([, pause]) =>
pause ? pause$.pipe(filter((pause) => !pause)) : of(null),
),
map(([value]) => value),
);
}
interface ItemHandle<Keys extends unknown[], Data, Item> {
scope: ObservableScope;
keys: readonly [...Keys];
data$: BehaviorSubject<Data>;
item: Item;
}
/**
* Maps a changing input value to a collection of items that each capture some
* dynamic data and are tied to a key. Items will be automatically created when
* their key is requested for the first time, reused when the same key is
* requested at a later time, and destroyed (have their scope ended) when the
* key is no longer requested.
*
* @param name A name for this collection to use in debug logs.
* @param generator A generator function yielding a tuple of keys and the
* currently associated data for each item that it wants to exist.
* @param factory A function constructing an individual item, given the item's key,
* dynamic data, and an automatically managed ObservableScope for the item.
*/
export function generateItems<
Input,
Keys extends [unknown, ...unknown[]],
Data,
Item,
>(
name: string,
generator: (
input: Input,
) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>,
factory: (
scope: ObservableScope,
data$: Behavior<Data>,
...keys: Keys
) => Item,
): OperatorFunction<Input, Item[]> {
return generateItemsInternal(name, generator, factory, (items) => items);
}
/**
* Same as generateItems, but preserves epoch data.
*/
export function generateItemsWithEpoch<
Input,
Keys extends [unknown, ...unknown[]],
Data,
Item,
>(
name: string,
generator: (
input: Input,
) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>,
factory: (
scope: ObservableScope,
data$: Behavior<Data>,
...keys: Keys
) => Item,
): OperatorFunction<Epoch<Input>, Epoch<Item[]>> {
return generateItemsInternal(
name,
function* (input) {
yield* generator(input.value);
},
factory,
(items, input) => new Epoch(items, input.epoch),
);
}
/**
* Segments a behavior into periods during which its value matches the filter
* (outputting a behavior with a narrowed type) and periods during which it does
* not match (outputting null).
*/
export function filterBehavior<T, S extends T>(
predicate: (value: T) => value is S,
): OperatorFunction<T, Behavior<S> | null> {
return (input$) =>
input$.pipe(
scan<T, BehaviorSubject<S> | null>((acc$, input) => {
if (predicate(input)) {
const output$ = acc$ ?? new BehaviorSubject(input);
output$.next(input);
return output$;
}
return null;
}, null),
distinctUntilChanged(),
);
}
function generateItemsInternal<
Input,
Keys extends [unknown, ...unknown[]],
Data,
Item,
Output,
>(
name: string,
generator: (
input: Input,
) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>,
factory: (
scope: ObservableScope,
data$: Behavior<Data>,
...keys: Keys
) => Item,
project: (items: Item[], input: Input) => Output,
): OperatorFunction<Input, Output> {
/* eslint-disable @typescript-eslint/no-explicit-any */
return (input$) =>
input$.pipe(
// Keep track of the existing items over time, so they can persist
scan<
Input,
{
map: Map<any, any>;
items: Set<ItemHandle<Keys, Data, Item>>;
input: Input;
},
{ map: Map<any, any>; items: Set<ItemHandle<Keys, Data, Item>> }
>(
({ map: prevMap, items: prevItems }, input) => {
const nextMap = new Map();
const nextItems = new Set<ItemHandle<Keys, Data, Item>>();
for (const { keys, data } of generator(input)) {
// Disable type checks for a second to grab the item out of a nested map
let i: any = prevMap;
for (const key of keys) i = i?.get(key);
let item = i as ItemHandle<Keys, Data, Item> | undefined;
if (item === undefined) {
// First time requesting the key; create the item
const scope = new ObservableScope();
const data$ = new BehaviorSubject(data);
logger.debug(
`[${name}] Creating item with keys ${keys.join(", ")}`,
);
item = {
scope,
keys,
data$,
item: factory(scope, data$, ...keys),
};
} else {
item.data$.next(data);
}
// Likewise, disable type checks to insert the item in the nested map
let m: Map<any, any> = nextMap;
for (let i = 0; i < keys.length - 1; i++) {
let inner = m.get(keys[i]);
if (inner === undefined) {
inner = new Map();
m.set(keys[i], inner);
}
m = inner;
}
const finalKey = keys[keys.length - 1];
if (m.has(finalKey))
throw new Error(
`Keys must be unique (tried to generate multiple items for key ${keys.join(", ")})`,
);
m.set(keys[keys.length - 1], item);
nextItems.add(item);
}
// Destroy all items that are no longer being requested
for (const item of prevItems)
if (!nextItems.has(item)) {
logger.debug(
`[${name}] Destroying item with keys ${item.keys.join(", ")}`,
);
item.scope.end();
}
return { map: nextMap, items: nextItems, input };
},
{ map: new Map(), items: new Set() },
),
finalizeValue(({ items }) => {
// Destroy all remaining items when no longer subscribed
logger.debug(
`[${name}] End of scope, destroying all ${items.size} items…`,
);
for (const item of items) {
logger.debug(
`[${name}] Destroying item with keys ${item.keys.join(", ")}`,
);
item.scope.end();
}
}),
map(({ items, input }) =>
project(
[...items].map(({ item }) => item),
input,
),
),
);
/* eslint-enable @typescript-eslint/no-explicit-any */
}
+133
View File
@@ -0,0 +1,133 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test } from "vitest";
import { calculateDisplayName, shouldDisambiguate } from "./displayname";
import {
alice,
aliceDoppelganger,
aliceDoppelgangerRtcMember,
aliceRtcMember,
bob,
bobRtcMember,
bobZeroWidthSpace,
bobZeroWidthSpaceRtcMember,
daveRTL,
} from "./test-fixtures";
import { mockMatrixRoom } from "./test";
import { roomToMembersMap } from "../matrixrtc/MatrixMemberMetadata";
describe("shouldDisambiguate", () => {
test("should not disambiguate a solo member", () => {
const room = mockMatrixRoom({
getMembersWithMembership: () => [],
});
expect(shouldDisambiguate(alice, [], roomToMembersMap(room))).toEqual(
false,
);
});
test("should not disambiguate a member with an empty displayname", () => {
const room = mockMatrixRoom({
getMembersWithMembership: () => [alice, aliceDoppelganger],
});
expect(
shouldDisambiguate(
{ rawDisplayName: "", userId: alice.userId },
[aliceRtcMember, aliceDoppelgangerRtcMember],
roomToMembersMap(room),
),
).toEqual(false);
});
test("should disambiguate a member with RTL characters", () => {
const room = mockMatrixRoom({ getMembersWithMembership: () => [] });
expect(shouldDisambiguate(daveRTL, [], roomToMembersMap(room))).toEqual(
true,
);
});
test("should disambiguate a member with a matching displayname", () => {
const room = mockMatrixRoom({
getMembersWithMembership: () => [alice, aliceDoppelganger],
});
expect(
shouldDisambiguate(
alice,
[aliceRtcMember, aliceDoppelgangerRtcMember],
roomToMembersMap(room),
),
).toEqual(true);
expect(
shouldDisambiguate(
aliceDoppelganger,
[aliceRtcMember, aliceDoppelgangerRtcMember],
roomToMembersMap(room),
),
).toEqual(true);
});
test("should disambiguate a member with a matching displayname with hidden spaces", () => {
const room = mockMatrixRoom({
getMembersWithMembership: () => [bob, bobZeroWidthSpace],
});
expect(
shouldDisambiguate(
bob,
[bobRtcMember, bobZeroWidthSpaceRtcMember],
roomToMembersMap(room),
),
).toEqual(true);
expect(
shouldDisambiguate(
bobZeroWidthSpace,
[bobRtcMember, bobZeroWidthSpaceRtcMember],
roomToMembersMap(room),
),
).toEqual(true);
});
test.for(["Alice @foo:bar", "@foo:b", "A@foo:lice", "A @f oo: ba r"])(
"should disambiguate a member with a displayname containing a mxid-like string '%s'",
(rawDisplayName) => {
const room = mockMatrixRoom({
getMembersWithMembership: () => [alice, aliceDoppelganger],
});
expect(
shouldDisambiguate(
{ rawDisplayName, userId: alice.userId },
[],
roomToMembersMap(room),
),
).toEqual(true);
},
);
});
describe("calculateDisplayName", () => {
test.for<[{ rawDisplayName?: string; userId: string }, boolean, string]>([
[alice, false, alice.rawDisplayName],
[alice, true, `${alice.rawDisplayName} (${alice.userId})`],
// Empty strings and zero width strings that are effectively empty are resolved as userIds
[{ rawDisplayName: "", userId: alice.userId }, false, alice.userId],
[
{ rawDisplayName: "\u200b\u200b\u200b", userId: alice.userId },
false,
alice.userId,
],
[
{ rawDisplayName: alice.userId, userId: alice.userId },
false,
alice.userId,
],
// Zero width strings are kept intact
[bobZeroWidthSpace, false, bobZeroWidthSpace.rawDisplayName],
// Directional characters are stripped.
[daveRTL, false, daveRTL.rawDisplayName.slice(1)],
[daveRTL, true, `${daveRTL.rawDisplayName.slice(1)} (${daveRTL.userId})`],
// Ensure we do NOT unhomoglyth
[{ ...alice, rawDisplayName: "alice m" }, false, "alice m"],
])("correctly calculates displayname", ([member, disambiguate, result]) =>
expect(calculateDisplayName(member, disambiguate)).toEqual(result),
);
});
+114
View File
@@ -0,0 +1,114 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
removeDirectionOverrideChars,
removeHiddenChars as removeHiddenCharsUncached,
} from "matrix-js-sdk/lib/utils";
import type { RoomMember } from "matrix-js-sdk";
import type { CallMembership } from "matrix-js-sdk/lib/matrixrtc";
// Calling removeHiddenChars() can be slow on Safari, so we cache the results.
// To illustrate a simple benchmark:
// Chrome: 10,000 calls took 2.599ms
// Safari: 10,000 calls took 242ms
// See: https://github.com/element-hq/element-call/issues/3065
const removeHiddenCharsCache = new Map<string, string>();
/**
* Calls removeHiddenCharsUncached and caches the result
*/
function removeHiddenChars(str: string): string {
if (removeHiddenCharsCache.has(str)) {
return removeHiddenCharsCache.get(str)!;
}
const result = removeHiddenCharsUncached(str);
// this is naive but should be good enough for our purposes
if (removeHiddenCharsCache.size > 500) {
removeHiddenCharsCache.clear();
}
removeHiddenCharsCache.set(str, result);
return result;
}
// Borrowed from https://github.com/matrix-org/matrix-js-sdk/blob/f10deb5ef2e8f061ff005af0476034382ea128ca/src/models/room-member.ts#L409
export function shouldDisambiguate(
member: { rawDisplayName?: string; userId: string },
memberships: Pick<CallMembership, "userId">[],
roomMembers: Map<string, Pick<RoomMember, "userId">>,
): boolean {
const { rawDisplayName: displayName, userId } = member;
if (!displayName || displayName === userId) return false;
// First check if the displayname is something we consider truthy
// after stripping it of zero width characters and padding spaces
const strippedDisplayName = removeHiddenChars(displayName);
if (!strippedDisplayName) return false;
// Next check if the name contains something that look like a mxid
// If it does, it may be someone trying to impersonate someone else
// Show full mxid in this case
if (/@.+:.+/.test(displayName)) return true;
// Also show mxid if the display name contains any LTR/RTL characters as these
// make it very difficult for us to find similar *looking* display names
// E.g "Mark" could be cloned by writing "kraM" but in RTL.
if (/[\u200E\u200F\u202A-\u202F]/.test(displayName)) return true;
// Also show mxid if there are other people with the same or similar
// displayname, after hidden character removal.
return (
memberships
.map((m) => m.userId && roomMembers.get(m.userId))
// NOTE: We *should* have a room member for everyone.
.filter((m) => !!m)
.filter((m) => m.userId !== userId)
.some(
(m) =>
removeHiddenChars(calculateDisplayName(m, false)) ===
strippedDisplayName,
)
);
}
/**
* Calculates a display name for a member, optionally disambiguating it.
* @param member - The member to calculate the display name for.
* @param member.rawDisplayName - The raw display name of the member
* @param member.userId - The user ID of the member
* @param disambiguate - Whether to disambiguate the display name.
*/
export function calculateDisplayName(
member: { rawDisplayName?: string; userId: string },
disambiguate: boolean,
): string {
const { rawDisplayName: displayName, userId } = member;
if (!displayName || displayName === userId) return userId;
const resultDisplayname = removeDirectionOverrideChars(displayName);
if (disambiguate) return resultDisplayname + " (" + userId + ")";
// First check if the displayname is something we consider truthy
// after stripping it of zero width characters and padding spaces
if (!removeHiddenChars(displayName)) return userId;
// We always strip the direction override characters (LRO and RLO).
// These override the text direction for all subsequent characters
// in the paragraph so if display names contained these, they'd
// need to be wrapped in something to prevent this from leaking out
// (which we can do in HTML but not text) or we'd need to add
// control characters to the string to reset any overrides (eg.
// adding PDF characters at the end). As far as we can see,
// there should be no reason these would be necessary - rtl display
// names should flip into the correct direction automatically based on
// the characters, and you can still embed rtl in ltr or vice versa
// with the embed chars or marker chars.
return resultDisplayname;
}

Some files were not shown because too many files have changed in this diff Show More