Add the MatrixRTC SDK package, its harness and a failing smoke test

Set up `sdk/` as a package shaped exactly like `component/`: its own
package.json with peer dependencies, the pnpm workspace marker so a host can
install it as a git dependency, a build tsconfig for type emission, and a vite
lib config with an externals list that `pnpm lint:externals` now checks for
both library builds.

The package holds only the interface for now. `createRtcSession` returns an
empty object; `RtcSession`, `RtcMember`, `MemberMedia` and `TransportMetadata`
are the contract Element Call's `CallViewModel` will be rebuilt on (see
sdk-plan.md). The shared primitives are re-exported from `src` until the first
implementation slice moves them.

`sdk/dev` is the smallest consumer of the SDK: a plain page that logs in, joins
a room and renders one tile per member, served by `pnpm dev:sdk` on port 3002.
Playwright starts it as a third web server, and `playwright/sdk/smoke.spec.ts`
has two browsers join one session. It fails on the status line with the SDK's
own error until there is an implementation behind the interface.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Timo K.
2026-10-01 16:57:34 +02:00
co-authored by Claude Fable 5.1
parent b0dd1c742f
commit 12bb87fa4c
21 changed files with 1071 additions and 80 deletions
+7 -2
View File
@@ -72,8 +72,13 @@ 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-target-based-on-call-view-model` — the SDK demo, one bundle of the
`CallViewModel`; entry `sdk-target-based-on-call-view-model/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`.
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
(its own pnpm project; run pnpm from the repo root). Host API is in the README;
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
+4
View File
@@ -39,6 +39,9 @@ translations. The browser is Playwright's, so a fresh clone needs
- `playwright/component/` — the component in a host page, via the harness on port
3001 that Playwright starts as a second web server. Catches container-relative
layout, styles escaping the root, two instances on a page, host-bridge reports.
- `playwright/sdk/` — the MatrixRTC SDK through its harness on port 3002, a page
with none of Element Call on it. The smoke test is what the implementation is
built against.
- `playwright/mobile/` — Pixel 7, `mobile` project only.
Test what a user observes: the peer sees the change, it survives a reconnect, it is
@@ -50,4 +53,5 @@ pnpm test # unit + storybook
pnpm backend # Synapse + LiveKit, required for e2e
pnpm test:playwright # or :open
pnpm dev:component # component harness, port 3001
pnpm dev:sdk # SDK harness, port 3002
```
+2
View File
@@ -12,6 +12,8 @@ export default {
config: [
"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",
+8 -2
View File
@@ -7,6 +7,7 @@
"dev:full": "vite",
"dev:embedded": "vite --config vite-embedded.config.js",
"dev:component": "vite --config vite-component-dev.config.ts",
"dev:sdk": "vite --config vite-sdk-dev.config.ts",
"build": "pnpm build:full",
"build:full": "node --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
"build:full:production": "pnpm build:full",
@@ -14,6 +15,11 @@
"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": "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:production": "pnpm build:sdk",
"build:sdk:development": "pnpm build:sdk:js --mode development && pnpm build:sdk:types",
"build:sdk-target-based-on-call-view-model": "pnpm build:full --config vite-sdk-target-based-on-call-view-model.config.js",
"build:component": "pnpm build:component:js && pnpm build:component:types",
"build:component:js": "pnpm build:full --config vite-component.config.js",
@@ -24,8 +30,8 @@
"format": "oxfmt",
"format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc",
"lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip && pnpm lint:externals",
"lint:oxlint": "oxlint src component playwright",
"lint:oxlint-fix": "oxlint --fix src component playwright",
"lint:oxlint": "oxlint src component sdk playwright",
"lint:oxlint-fix": "oxlint --fix src component sdk playwright",
"lint:knip": "knip",
"lint:externals": "node scripts/check-component-externals.mjs",
"lint:types": "tsc",
+13
View File
@@ -12,6 +12,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { COMPONENT_HARNESS_URL } from "./playwright/component/harness.ts";
import { SDK_HARNESS_URL } from "./playwright/sdk/harness.ts";
const baseURL = process.env.USE_DOCKER
? "http://localhost:8080"
@@ -146,5 +147,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,
},
},
],
});
+88
View File
@@ -0,0 +1,88 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type Page } 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!";
/**
* 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.
*/
export async function createUsersAndRoom(
name: string,
): Promise<{ usernames: [string, string]; roomId: string }> {
const admin = SynapseAdmin.forHomeserver(HOMESERVER_URL);
const usernames: [string, string] = [
`${name}_a_${Date.now()}`,
`${name}_b_${Date.now()}`,
];
const [{ access_token: accessToken }] = await Promise.all(
usernames.map(async (username, index) =>
admin.registerUser(username, PASSWORD, `${name} ${"AB"[index]}`),
),
);
const response = await fetch(
`${HOMESERVER_URL}/_matrix/client/v3/createRoom`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: `${name}'s session`,
preset: "public_chat",
}),
},
);
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 { usernames, roomId };
}
/**
* Opens the harness signed in as the given user and waits until the SDK
* reports the session as joined. 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,
username: string,
roomId: string,
): Promise<void> {
const query = new URLSearchParams({
homeserver: HOMESERVER_URL,
username,
password: PASSWORD,
room: roomId,
});
await page.goto(`${SDK_HARNESS_URL}/?${query.toString()}`);
await page.getByRole("button", { name: "Start" }).click();
// A login, a crypto setup and an initial sync happen first. An error is
// final, so it is not worth waiting out the timeout for "Joined" after one.
const status = page.getByTestId("status");
await expect(status).toHaveText(/^(Joined|Error)/, { timeout: 120_000 });
const text = await status.textContent();
if (text !== "Joined")
throw new Error(`The harness did not join the session: ${text}`);
}
+56
View File
@@ -0,0 +1,56 @@
/*
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 Page, test } from "@playwright/test";
import { createUsersAndRoom, startHarness } 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.
*
* This is the smoke test the implementation is built against. It fails until
* `createRtcSession` does something, and it fails on the status line first, so
* the SDK's own error is what the report shows.
*/
// 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 { usernames, roomId } = await createUsersAndRoom("sdksmoke");
const [pageA, pageB] = await Promise.all(
usernames.map(async () => newPage(browser)),
);
await Promise.all([
startHarness(pageA, usernames[0], roomId),
startHarness(pageB, usernames[1], roomId),
]);
// Each page shows itself and the other, each tile named by its user
for (const page of [pageA, pageB]) {
await expect(page.getByTestId("member")).toHaveCount(2, {
timeout: 60_000,
});
for (const username of usernames)
await expect(
page.getByTestId("member").filter({ hasText: username }),
).toHaveCount(1);
}
});
/**
* 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).
*/
async function newPage(browser: Browser): Promise<Page> {
const context = await browser.newContext({ ignoreHTTPSErrors: true });
return context.newPage();
}
+91 -76
View File
@@ -6,58 +6,73 @@ 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, LiveKit 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",
"livekit-client",
"@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",
"livekit-client",
"@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;
}
@@ -89,60 +104,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);
+74
View File
@@ -0,0 +1,74 @@
# MatrixRTC SDK (EXPERIMENTAL)
`@element-hq/matrixrtc-sdk` is the call model under Element Call, on its own:
MatrixRTC memberships and transports, LiveKit connections, publishing, E2EE keys and
the media of every member, as observables. It has no UI. Element Call's own
`CallViewModel` is meant to become one consumer of it; the design is in
[`sdk-plan.md`](../sdk-plan.md).
**Status:** interface only. `createRtcSession` returns an object that does nothing.
The development harness and its e2e test exist so the implementation can be built
against them.
## Using it
```ts
import {
constant,
createRtcSession,
E2eeType,
MatrixRTCMode,
ObservableScope,
} from "@element-hq/matrixrtc-sdk";
const scope = new ObservableScope();
const session = createRtcSession(
scope,
client, // a matrix-js-sdk MatrixClient, logged in and syncing
room, // the matrix-js-sdk Room to hold the session in
{
microphoneEnabled$: constant(true),
cameraEnabled$: constant(true),
audioInputDeviceId$: constant(undefined),
videoInputDeviceId$: constant(undefined),
videoProcessor$: constant(undefined),
},
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
matrixRTCMode: MatrixRTCMode.Compatibility,
},
);
session.join();
session.remoteMembers$.subscribe((members) => {
// each member has displayName$, media$ and more; a media track is rendered
// by handing it a <video> or <audio> element: track.attach(element)
});
// later
session.leave();
scope.end();
```
[`dev/main.ts`](./dev/main.ts) is this example as a working page.
## 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`
```
## Installing
```sh
pnpm add github:element-hq/element-call#<ref>&path:/sdk
```
The host supplies `matrix-js-sdk`, `livekit-client` and `rxjs`; the build leaves
them external.
+56
View File
@@ -0,0 +1,56 @@
<!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: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.5rem;
}
#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" />
<button type="submit">Start</button>
<button type="button" id="leave" hidden>Leave</button>
</form>
<p id="status" data-testid="status"></p>
<main id="members"></main>
<script type="module" src="./main.ts"></script>
</body>
</html>
+157
View File
@@ -0,0 +1,157 @@
/*
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, 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 {
constant,
createRtcSession,
E2eeType,
MatrixRTCMode,
type MediaTrack,
ObservableScope,
type RtcMember,
type RtcSession,
} from "@element-hq/matrixrtc-sdk";
import { createSession } from "./session";
const form = document.querySelector("form")!;
const status = document.getElementById("status")!;
const members = document.getElementById("members")!;
const leaveButton = 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;
void start(
field("homeserver"),
field("username"),
field("password"),
field("room"),
);
});
async function start(
homeserver: string,
username: string,
password: string,
roomIdOrAlias: string,
): Promise<void> {
try {
status.textContent = "Logging in";
const client = await createSession(homeserver, username, password);
const room = await client.joinRoom(roomIdOrAlias);
const scope = new ObservableScope();
const session = createRtcSession(
scope,
client,
room,
{
microphoneEnabled$: constant(true),
cameraEnabled$: constant(true),
audioInputDeviceId$: constant(undefined),
videoInputDeviceId$: constant(undefined),
videoProcessor$: constant(undefined),
},
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
matrixRTCMode: MatrixRTCMode.Compatibility,
},
);
showMembers(scope, session);
session.join();
status.textContent = "Joined";
leaveButton.hidden = false;
leaveButton.onclick = (): void => {
session.leave();
scope.end();
members.replaceChildren();
leaveButton.hidden = true;
status.textContent = "Left";
};
} catch (e) {
status.textContent = `Error: ${e}`;
logger.error(e);
}
}
function showMembers(scope: ObservableScope, session: RtcSession): void {
const tiles = new Map<string, HTMLElement>();
combineLatest([session.localMember$, session.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.id === id)) {
tile.remove();
tiles.delete(id);
}
for (const member of current)
if (!tiles.has(member.id)) {
const tile = memberTile(scope, member);
tiles.set(member.id, tile);
members.append(tile);
}
});
}
function memberTile(scope: ObservableScope, member: RtcMember): HTMLElement {
const tile = document.createElement("section");
tile.dataset.testid = "member";
tile.dataset.userId = member.userId;
const name = tile.appendChild(document.createElement("h2"));
member.displayName$.pipe(scope.bind()).subscribe((n) => {
name.textContent = n;
});
const camera$ = member.media$.pipe(
switchMap((media) => media?.camera$ ?? of(undefined)),
);
render(scope, camera$, tile.appendChild(document.createElement("video")));
// Our own microphone would only echo
if (!member.local) {
const microphone$ = member.media$.pipe(
switchMap((media) => media?.microphone$ ?? of(undefined)),
);
render(
scope,
microphone$,
tile.appendChild(document.createElement("audio")),
);
}
return tile;
}
function render(
scope: ObservableScope,
track$: Observable<MediaTrack | undefined>,
element: HTMLMediaElement,
): void {
let attached: MediaTrack | undefined;
track$.pipe(scope.bind()).subscribe((track) => {
attached?.detach(element);
attached = track;
track?.attach(element);
});
scope.onEnd(() => attached?.detach(element));
}
+49
View File
@@ -0,0 +1,49 @@
/*
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,
SyncState,
} from "matrix-js-sdk";
/** Logs in with a password and returns a client that has finished its first sync. */
export async function createSession(
homeserver: string,
username: string,
password: string,
): Promise<MatrixClient> {
const login = await createClient({ baseUrl: homeserver }).login(
"m.login.password",
{ identifier: { type: "m.id.user", user: username }, password },
);
const client = createClient({
baseUrl: homeserver,
accessToken: login.access_token,
userId: login.user_id,
deviceId: login.device_id,
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;
}
+271
View File
@@ -0,0 +1,271 @@
/*
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.
*
* Only the interface exists so far. `createRtcSession` returns an object that
* does nothing, so that the development harness in `sdk/dev` and its e2e test
* can be written against the contract before the implementation lands behind
* it. The design and the migration from Element Call's `CallViewModel` are in
* `sdk-plan.md` at the repository root.
*/
import { type MatrixClient, type Room } from "matrix-js-sdk";
import {
type CallMembership,
type RTCCallIntent,
type RTCNotificationType,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { type Track, type TrackProcessor } from "livekit-client";
import { type Observable } from "rxjs";
import { type Behavior } from "../src/state/Behavior";
import { type ObservableScope } from "../src/state/ObservableScope";
import { type EncryptionSystem } from "../src/e2ee/sharedKeyManagement";
import { type MatrixRTCMode } from "../src/config/ConfigOptions";
// Shared with Element Call. They live in `src` until the SDK has an
// implementation to move them with; a consumer gets them from here either way.
export { type Behavior, constant } from "../src/state/Behavior";
export { ObservableScope } from "../src/state/ObservableScope";
export { E2eeType } from "../src/e2ee/e2eeType";
export { type EncryptionSystem } from "../src/e2ee/sharedKeyManagement";
export { MatrixRTCMode } from "../src/config/ConfigOptions";
// ---------------------------------------------------------------------------
// Session
export interface RtcSessionOptions {
encryptionSystem: EncryptionSystem;
/** Resolved by the host; the SDK reads neither config.json nor settings. */
matrixRTCMode: MatrixRTCMode;
/**
* MSC4075 notification sent with the join. Parameters of the MatrixRTC join
* itself, so they are here even though they are named after calls; reacting
* to a notification (ringing, timeouts, declines) is the application's job.
*/
sendNotificationType?: RTCNotificationType;
callIntent?: RTCCallIntent;
}
/** What the local member publishes. */
export interface LocalMediaInputs {
microphoneEnabled$: Behavior<boolean>;
cameraEnabled$: Behavior<boolean>;
audioInputDeviceId$: Behavior<string | undefined>;
videoInputDeviceId$: Behavior<string | undefined>;
/** Background blur and the like. */
videoProcessor$: Behavior<TrackProcessor<Track.Kind.Video> | undefined>;
}
export type SessionConnectionStatus =
| "waitingForTransport"
| "connecting"
| "connected"
| "reconnecting"
| "disconnected";
export class RtcSessionError extends Error {
public constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "RtcSessionError";
}
}
export interface RtcSession {
join(): void;
leave(): void;
/** Collapsed view of the local member's state machine. */
status$: Behavior<SessionConnectionStatus>;
connected$: Behavior<boolean>;
reconnecting$: Behavior<boolean>;
/** A transport, Matrix or connection error that stops the session. */
fatalError$: Behavior<RtcSessionError | null>;
localMember$: Behavior<LocalRtcMember | null>;
remoteMembers$: Behavior<RemoteRtcMember[]>;
/** `remoteMembers.length`, plus one for the local member once it exists. */
memberCount$: Behavior<number>;
/**
* Whether the session has grown large enough that MatrixRTC has stopped
* rotating the media encryption key.
*/
keyRotationSuppressed$: Behavior<boolean>;
/** Transports the session currently holds a live connection to. */
connectedTransports$: Behavior<TransportMetadata[]>;
}
// ---------------------------------------------------------------------------
// 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 session. For LiveKit, the service url. */
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
export interface RtcMember {
local: boolean;
/** `${userId}:${deviceId}` before sticky events, a uuid in Matrix 2.0 mode. The backend identity. */
id: string;
userId: string;
deviceId: string;
membership$: Behavior<CallMembership>;
displayName$: Behavior<string>;
avatarUrl$: Behavior<string | undefined>;
/** Which transport this member is on; undefined when the membership has none. */
transport$: Behavior<TransportMetadata | undefined>;
/**
* Null while the member has a transport but no media has arrived on it yet
* ("waiting for media").
*/
media$: Behavior<MemberMedia | null>;
}
export interface RemoteRtcMember extends RtcMember {
local: false;
}
export interface LocalRtcMember extends RtcMember {
local: true;
media$: Behavior<LocalMemberMedia | null>;
sharingScreen$: Behavior<boolean>;
/** Null when the platform cannot share a screen. */
toggleScreenSharing: (() => void) | null;
screenShareError$: Behavior<Error | null>;
dismissScreenShareError(): void;
}
// ---------------------------------------------------------------------------
// Media
export type MediaSource =
| "microphone"
| "camera"
| "screenShare"
| "screenShareAudio";
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. */
encrypted$: Behavior<boolean>;
/** Polled while subscribed. */
stats$: Behavior<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";
/** 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";
/** Undefined for remote tracks. */
facingMode$?: Behavior<"user" | "environment" | undefined>;
}
export type EncryptionError = "MissingKey" | "InvalidKey";
/**
* The media of one member, backed by a LiveKit participant inside the SDK.
* There is no identity field: the LiveKit identity is the member's `id`.
*/
export interface MemberMedia {
local: boolean;
speaking$: Behavior<boolean>;
screenShareEnabled$: Behavior<boolean>;
microphone$: Behavior<AudioMediaTrack | undefined>;
camera$: Behavior<VideoMediaTrack | undefined>;
screenShare$: Behavior<VideoMediaTrack | undefined>;
screenShareAudio$: Behavior<AudioMediaTrack | undefined>;
/** Emits when the SFU reports a key problem for this member. */
encryptionError$: Observable<EncryptionError>;
}
export interface LocalMemberMedia extends MemberMedia {
local: true;
}
// ---------------------------------------------------------------------------
// Factory
/**
* 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 createRtcSession(
scope: ObservableScope,
client: MatrixClient,
room: Room,
localMedia: LocalMediaInputs,
options: RtcSessionOptions,
): RtcSession {
// The interface ships ahead of the implementation; see the module comment.
return {} as RtcSession;
}
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@element-hq/matrixrtc-sdk",
"version": "0.0.0",
"description": "MatrixRTC sessions with LiveKit media, without Element Call's UI. Consumed straight from the repository as a git dependency (github:element-hq/element-call#<ref>&path:/sdk): the host's package manager runs `prepare`, which builds `dist/`.",
"license": "SEE LICENSE IN ../README.md",
"repository": {
"type": "git",
"url": "https://github.com/element-hq/element-call",
"directory": "sdk"
},
"type": "module",
"devEngines": {
"packageManager": {
"name": "pnpm"
}
},
"files": [
"dist"
],
"main": "./dist/matrixrtc-sdk.js",
"module": "./dist/matrixrtc-sdk.js",
"types": "./dist/types/sdk/index.d.ts",
"exports": {
".": {
"types": "./dist/types/sdk/index.d.ts",
"default": "./dist/matrixrtc-sdk.js"
}
},
"scripts": {
"prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:sdk"
},
"peerDependencies": {
"livekit-client": "^2.18.1",
"matrix-js-sdk": "*",
"rxjs": "^7.8.1"
}
}
+14
View File
@@ -0,0 +1,14 @@
# Makes `sdk/` a pnpm project of its own rather than a directory inside the
# repository's workspace. That matters when a host installs the SDK straight
# from this repository (`github:element-hq/element-call#…&path:/sdk`): pnpm
# prepares such a dependency by running `pnpm install` in this directory, and
# only a project root gets its `prepare` script (see package.json) run, which is
# what builds `dist/`. Inside the repository's own workspace the install would
# silently target the repository instead and build nothing.
#
# Consequence for development: pnpm commands run from within this directory see
# this project, not the repository; run them from the repository root.
# Nothing to install here: the peers in package.json are the host's, and the
# build runs against the repository's own node_modules (see `prepare`).
autoInstallPeers: false
+18
View File
@@ -0,0 +1,18 @@
{
// Declaration output for the SDK package (`pnpm build:sdk:types`). The root
// tsconfig only type-checks; this one emits `.d.ts` files, and nothing else,
// for everything the entry point reaches. The layout under `dist/types`
// mirrors the repository (`sdk/index.d.ts`, `src/…`), which is what
// `package.json` points its `types` at.
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": false,
"rootDir": "..",
"outDir": "./dist/types"
},
"include": ["./index.ts", "../src/@types/*.d.ts"],
"exclude": []
}
+5
View File
@@ -20,6 +20,10 @@
"useDefineForClassFields": false,
"allowImportingTsExtensions": true,
"paths": {
// Element Call imports the SDK by its package name, so that the move to a
// published dependency changes nothing but this line (and the matching
// alias in vite.config.ts)
"@element-hq/matrixrtc-sdk": ["./sdk/index.ts"],
// These imports within @livekit/components-core and
// @livekit/components-react are broken under the "bundler" module
// resolution mode, so we need to resolve them manually
@@ -53,6 +57,7 @@
"./src/**/*.ts",
"./src/**/*.tsx",
"./playwright/**/*.ts",
"./sdk/**/*.ts",
"./sdk-target-based-on-call-view-model/**/*.ts",
"./component/**/*.ts",
"./component/**/*.tsx"
+49
View File
@@ -0,0 +1,49 @@
/*
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 { defineConfig, searchForWorkspaceRoot } from "vite";
import { realpathSync } from "node:fs";
import * as fs from "node:fs";
import { vitePluginsConfig } from "./vite.config.ts";
// Serves the SDK's development harness, `sdk/dev`: a page that uses the SDK as a
// host would, and nothing of Element Call. The shape of the component's
// development config, without the styles.
export default defineConfig(({ mode }) => {
const allow = [searchForWorkspaceRoot(process.cwd())];
for (const path of [
"node_modules/matrix-js-sdk/node_modules/@matrix-org/matrix-sdk-crypto-wasm",
"node_modules/@matrix-org/matrix-sdk-crypto-wasm",
]) {
try {
allow.push(realpathSync(path));
} catch {}
}
return {
...vitePluginsConfig({ mode, html: false }),
root: "sdk/dev",
publicDir: false,
server: {
host: true,
// After the app (3000) and the component harness (3001), so all three can
// run at once
port: 3002,
fs: { allow },
// The same certificate the app uses, so that the harness is served from
// a `m.localhost` name the development homeserver's certificate covers
https: {
key: fs.readFileSync("./backend/dev_tls_m.localhost.key"),
cert: fs.readFileSync("./backend/dev_tls_m.localhost.crt"),
},
},
worker: {
format: "es",
},
};
});
+65
View File
@@ -0,0 +1,65 @@
/*
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 { defineConfig } from "vite";
import { vitePluginsConfig } from "./vite.config.ts";
// Config for the MatrixRTC SDK, a library a host imports rather than a page.
// The shape of the component build (vite-component.config.ts) without
// anything to do with styles or React.
export default defineConfig(({ mode }) => {
const base = vitePluginsConfig({ mode, html: false });
return {
...base,
// A library has no public directory to serve
publicDir: false,
build: {
// Into the package directory, so that `sdk/package.json` describes what
// sits next to it and the directory can be installed as a package
outDir: "sdk/dist",
minify: mode === "production",
sourcemap: true,
lib: {
formats: ["es" as const],
entry: { "matrixrtc-sdk": "./sdk/index.ts" },
fileName: (_format, entryName) => `${entryName}.js`,
},
rollupOptions: {
// The host already has these, and a second copy of any of them is worse
// than dead weight: the Matrix client would run two sync loops, and a
// second LiveKit or RxJS would not share state with the host's. Every
// subpath has to be named (see vite-component.config.ts for why), and
// `pnpm lint:externals` fails if the source imports one this list
// misses.
external: [
"livekit-client",
"matrix-js-sdk",
"matrix-js-sdk/lib/browser-index",
"matrix-js-sdk/lib/client",
"matrix-js-sdk/lib/crypto-api",
"matrix-js-sdk/lib/indexeddb-worker",
"matrix-js-sdk/lib/logger",
"matrix-js-sdk/lib/matrix",
"matrix-js-sdk/lib/matrixrtc",
"matrix-js-sdk/lib/matrixrtc/EncryptionManager",
"matrix-js-sdk/lib/matrixrtc/IKeyTransport",
"matrix-js-sdk/lib/matrixrtc/IMembershipManager",
"matrix-js-sdk/lib/models/relations-container",
"matrix-js-sdk/lib/models/room",
"matrix-js-sdk/lib/models/typed-event-emitter",
"matrix-js-sdk/lib/randomstring",
"matrix-js-sdk/lib/sync",
"matrix-js-sdk/lib/types",
"matrix-js-sdk/lib/utils",
"rxjs",
"rxjs/testing",
],
},
},
};
});
+6
View File
@@ -23,6 +23,7 @@ import babel from "@rolldown/plugin-babel";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import { realpathSync } from "fs";
import * as fs from "node:fs";
import { fileURLToPath } from "node:url";
export const vitePluginsConfig = ({
mode,
@@ -161,6 +162,11 @@ export default ({
// which Vite for some reason refuses to work with, so we point it to
// src/index.ts instead
"matrix-widget-api": "matrix-widget-api/src/index.ts",
// The SDK by its package name, resolved to its source: see the matching
// entry in tsconfig.json
"@element-hq/matrixrtc-sdk": fileURLToPath(
new URL("./sdk/index.ts", import.meta.url),
),
},
dedupe: [
"react",
+1
View File
@@ -26,6 +26,7 @@ export default defineConfig((configEnv) =>
"src/**/*.test.tsx",
"component/**/*.test.ts",
"component/**/*.test.tsx",
"sdk/**/*.test.ts",
],
},
},