mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Merge pull request #4316 from element-hq/toger5/matrixrtc-sdk-package
[MatrixRTCSdk] add the sdk
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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` /
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}"]`);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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`) |
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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$,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
// // })
|
||||
// // };
|
||||
// // });
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user