mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
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:
co-authored by
Claude Fable 5.1
parent
2b9fdfe4d5
commit
9910496b90
@@ -72,8 +72,13 @@ Code that builds in only one is a bug.
|
|||||||
|
|
||||||
- `build:full` — standalone app, also widget mode.
|
- `build:full` — standalone app, also widget mode.
|
||||||
- `build:embedded` — `@element-hq/element-call-embedded`.
|
- `build:embedded` — `@element-hq/element-call-embedded`.
|
||||||
- `build:sdk-target-based-on-call-view-model` — the SDK demo, one bundle of the
|
- `build:sdk` — `@element-hq/matrixrtc-sdk`, sources in `sdk/`, entry `sdk/index.ts`.
|
||||||
`CallViewModel`; entry `sdk-target-based-on-call-view-model/main.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/`
|
- `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;
|
(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` /
|
`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
|
- `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
|
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.
|
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.
|
- `playwright/mobile/` — Pixel 7, `mobile` project only.
|
||||||
|
|
||||||
Test what a user observes: the peer sees the change, it survives a reconnect, it is
|
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 backend # Synapse + LiveKit, required for e2e
|
||||||
pnpm test:playwright # or :open
|
pnpm test:playwright # or :open
|
||||||
pnpm dev:component # component harness, port 3001
|
pnpm dev:component # component harness, port 3001
|
||||||
|
pnpm dev:sdk # SDK harness, port 3002
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export default {
|
|||||||
config: [
|
config: [
|
||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
"vite-embedded.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-sdk-target-based-on-call-view-model.config.ts",
|
||||||
"vite-component.config.ts",
|
"vite-component.config.ts",
|
||||||
"vite-component-dev.config.ts",
|
"vite-component-dev.config.ts",
|
||||||
|
|||||||
+8
-2
@@ -7,6 +7,7 @@
|
|||||||
"dev:full": "vite",
|
"dev:full": "vite",
|
||||||
"dev:embedded": "vite --config vite-embedded.config.js",
|
"dev:embedded": "vite --config vite-embedded.config.js",
|
||||||
"dev:component": "vite --config vite-component-dev.config.ts",
|
"dev:component": "vite --config vite-component-dev.config.ts",
|
||||||
|
"dev:sdk": "vite --config vite-sdk-dev.config.ts",
|
||||||
"build": "pnpm build:full",
|
"build": "pnpm build:full",
|
||||||
"build:full": "node --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
|
"build:full": "node --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
|
||||||
"build:full:production": "pnpm build:full",
|
"build:full:production": "pnpm build:full",
|
||||||
@@ -14,6 +15,11 @@
|
|||||||
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
|
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
|
||||||
"build:embedded:production": "pnpm build:embedded",
|
"build:embedded:production": "pnpm build:embedded",
|
||||||
"build:embedded:development": "pnpm build:embedded --mode development",
|
"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: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": "pnpm build:component:js && pnpm build:component:types",
|
||||||
"build:component:js": "pnpm build:full --config vite-component.config.js",
|
"build:component:js": "pnpm build:full --config vite-component.config.js",
|
||||||
@@ -24,8 +30,8 @@
|
|||||||
"format": "oxfmt",
|
"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",
|
"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": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip && pnpm lint:externals",
|
||||||
"lint:oxlint": "oxlint src component playwright",
|
"lint:oxlint": "oxlint src component sdk playwright",
|
||||||
"lint:oxlint-fix": "oxlint --fix src component playwright",
|
"lint:oxlint-fix": "oxlint --fix src component sdk playwright",
|
||||||
"lint:knip": "knip",
|
"lint:knip": "knip",
|
||||||
"lint:externals": "node scripts/check-component-externals.mjs",
|
"lint:externals": "node scripts/check-component-externals.mjs",
|
||||||
"lint:types": "tsc",
|
"lint:types": "tsc",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { COMPONENT_HARNESS_URL } from "./playwright/component/harness.ts";
|
import { COMPONENT_HARNESS_URL } from "./playwright/component/harness.ts";
|
||||||
|
import { SDK_HARNESS_URL } from "./playwright/sdk/harness.ts";
|
||||||
|
|
||||||
const baseURL = process.env.USE_DOCKER
|
const baseURL = process.env.USE_DOCKER
|
||||||
? "http://localhost:8080"
|
? "http://localhost:8080"
|
||||||
@@ -146,5 +147,17 @@ export default defineConfig({
|
|||||||
timeout: 500,
|
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,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}`);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
* Checks that the library builds leave the packages a host must supply to the
|
||||||
* the host.
|
* host.
|
||||||
*
|
*
|
||||||
* A host application already has React, the Matrix SDK and Compound,
|
* 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
|
* 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
|
* 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
|
* Compound would style the tooltips it floats into the host's body with class
|
||||||
* names the host's stylesheet does not know. So the
|
* names the host's stylesheet does not know. So each library build lists them
|
||||||
* component build lists them as external — but that list has to name every
|
* as external — but that list has to name every subpath, since the bundler
|
||||||
* subpath, since the bundler silently ignores the pattern and callback forms
|
* silently ignores the pattern and callback forms of the option, and an import
|
||||||
* of the option, and an import it does not cover is bundled with no warning at
|
* it does not cover is bundled with no warning at all. That is the failure
|
||||||
* all. That is the failure this guards against.
|
* this guards against.
|
||||||
*
|
*
|
||||||
* It reads the list from the build config itself, so there is one copy of it,
|
* It reads each 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.
|
* 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`
|
* 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
|
* rather than only the modules a build actually pulls in, so it will sometimes
|
||||||
* sometimes ask for a subpath that only the standalone app imports. Listing
|
* ask for a subpath that only the standalone app imports. Listing one a build
|
||||||
* one the component never imports costs nothing — the bundler ignores it —
|
* never imports costs nothing — the bundler ignores it — whereas missing one
|
||||||
* whereas missing one costs a duplicate package.
|
* costs a duplicate package.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readdir, readFile } from "node:fs/promises";
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { loadConfigFromFile } from "vite";
|
import { loadConfigFromFile } from "vite";
|
||||||
|
|
||||||
const CONFIG = "vite-component.config.ts";
|
/**
|
||||||
const SOURCES = ["src", "component"];
|
* The library builds, each with the directories it is built from and the
|
||||||
// The development harness is a host, not part of the component: it is the
|
* packages whose duplication would break a host rather than merely enlarge it.
|
||||||
// one that imports what a host supplies (Compound's stylesheets, say).
|
* A development harness is a host, not part of the library: it is the one that
|
||||||
const EXCLUDED = ["component/dev"];
|
* imports what a host supplies (Compound's stylesheets, say).
|
||||||
|
*/
|
||||||
/** The packages whose duplication would break a host, rather than merely enlarge it. */
|
const TARGETS = [
|
||||||
const MUST_BE_EXTERNAL = [
|
{
|
||||||
|
config: "vite-component.config.ts",
|
||||||
|
what: "the component build",
|
||||||
|
sources: ["src", "component"],
|
||||||
|
excluded: ["component/dev"],
|
||||||
|
mustBeExternal: [
|
||||||
"react",
|
"react",
|
||||||
"react-dom",
|
"react-dom",
|
||||||
"matrix-js-sdk",
|
"matrix-js-sdk",
|
||||||
"@vector-im/compound-web",
|
"@vector-im/compound-web",
|
||||||
"@vector-im/compound-design-tokens",
|
"@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) =>
|
const isTestFile = (name) =>
|
||||||
name.includes(".test.") || name.includes(".stories.");
|
name.includes(".test.") || name.includes(".stories.");
|
||||||
|
|
||||||
/** Every source file under the given directories, recursively. */
|
/** 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 })) {
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||||
const path = join(dir, entry.name);
|
const path = join(dir, entry.name);
|
||||||
if (EXCLUDED.includes(path)) continue;
|
if (excluded.includes(path)) continue;
|
||||||
if (entry.isDirectory()) yield* sourceFiles(path);
|
if (entry.isDirectory()) yield* sourceFiles(path, excluded);
|
||||||
else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name))
|
else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name))
|
||||||
yield path;
|
yield path;
|
||||||
}
|
}
|
||||||
@@ -88,38 +103,42 @@ function imports(source) {
|
|||||||
* with the host's copy of anything. Worker sub-builds do not inherit this
|
* with the host's copy of anything. Worker sub-builds do not inherit this
|
||||||
* option anyway.
|
* option anyway.
|
||||||
*/
|
*/
|
||||||
const mustBeExternal = (specifier) =>
|
const mustBeExternal = (specifier, packages) =>
|
||||||
!specifier.includes("?") &&
|
!specifier.includes("?") &&
|
||||||
MUST_BE_EXTERNAL.some(
|
packages.some((pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`));
|
||||||
(pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`),
|
|
||||||
);
|
|
||||||
|
|
||||||
const loaded = await loadConfigFromFile(
|
/** @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" },
|
{ command: "build", mode: "production" },
|
||||||
CONFIG,
|
config,
|
||||||
);
|
);
|
||||||
if (loaded === null) {
|
if (loaded === null) {
|
||||||
console.error(`Could not load ${CONFIG}`);
|
console.error(`Could not load ${config}`);
|
||||||
process.exit(1);
|
return false;
|
||||||
}
|
}
|
||||||
const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []);
|
const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []);
|
||||||
if (declared.size === 0) {
|
if (declared.size === 0) {
|
||||||
console.error(
|
console.error(
|
||||||
`${CONFIG} declares nothing external. Either the option moved, or the ` +
|
`${config} declares nothing external. Either the option moved, or the ` +
|
||||||
`list is empty; either way this check is not looking at what it thinks.`,
|
`list is empty; either way this check is not looking at what it thinks.`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where each missing specifier is imported, so the message can point at it
|
// Where each missing specifier is imported, so the message can point at it
|
||||||
const missing = new Map();
|
const missing = new Map();
|
||||||
const seen = new Set();
|
for (const dir of sources)
|
||||||
for (const dir of SOURCES)
|
for await (const file of sourceFiles(dir, excluded)) {
|
||||||
for await (const file of sourceFiles(dir)) {
|
|
||||||
const source = await readFile(file, "utf8");
|
const source = await readFile(file, "utf8");
|
||||||
for (const specifier of imports(source)) {
|
for (const specifier of imports(source)) {
|
||||||
if (!mustBeExternal(specifier)) continue;
|
if (!mustBeExternal(specifier, packages)) continue;
|
||||||
seen.add(specifier);
|
|
||||||
if (declared.has(specifier)) continue;
|
if (declared.has(specifier)) continue;
|
||||||
const files = missing.get(specifier) ?? [];
|
const files = missing.get(specifier) ?? [];
|
||||||
files.push(file);
|
files.push(file);
|
||||||
@@ -127,21 +146,17 @@ for (const dir of SOURCES)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missing.size > 0) {
|
if (missing.size === 0) return true;
|
||||||
console.error(
|
console.error(
|
||||||
`${CONFIG} does not declare these imports external, so the component ` +
|
`${config} does not declare these imports external, so ${what} ` +
|
||||||
`build would bundle its own copy of them:\n`,
|
`would bundle its own copy of them:\n`,
|
||||||
);
|
);
|
||||||
for (const [specifier, files] of [...missing].sort())
|
for (const [specifier, files] of [...missing].sort())
|
||||||
console.error(` ${specifier}\n imported by ${files.join(", ")}`);
|
console.error(` ${specifier}\n imported by ${files.join(", ")}`);
|
||||||
console.error(`\nAdd each one to the \`external\` list in ${CONFIG}.`);
|
console.error(`\nAdd each one to the \`external\` list in ${config}.\n`);
|
||||||
process.exit(1);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deliberately no complaint about declarations nothing imports. Some of them
|
let ok = true;
|
||||||
// cannot be seen from the source at all — `react/jsx-runtime` is injected by
|
for (const target of TARGETS) ok = (await check(target)) && ok;
|
||||||
// the JSX transform — and an extra declaration is inert, so there is nothing
|
if (!ok) process.exit(1);
|
||||||
// to warn about.
|
|
||||||
console.log(
|
|
||||||
`${declared.size} external declarations cover all ${seen.size} imports of ${MUST_BE_EXTERNAL.join(", ")}.`,
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,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": []
|
||||||
|
}
|
||||||
@@ -20,6 +20,10 @@
|
|||||||
"useDefineForClassFields": false,
|
"useDefineForClassFields": false,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"paths": {
|
"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
|
// These imports within @livekit/components-core and
|
||||||
// @livekit/components-react are broken under the "bundler" module
|
// @livekit/components-react are broken under the "bundler" module
|
||||||
// resolution mode, so we need to resolve them manually
|
// resolution mode, so we need to resolve them manually
|
||||||
@@ -53,6 +57,7 @@
|
|||||||
"./src/**/*.ts",
|
"./src/**/*.ts",
|
||||||
"./src/**/*.tsx",
|
"./src/**/*.tsx",
|
||||||
"./playwright/**/*.ts",
|
"./playwright/**/*.ts",
|
||||||
|
"./sdk/**/*.ts",
|
||||||
"./sdk-target-based-on-call-view-model/**/*.ts",
|
"./sdk-target-based-on-call-view-model/**/*.ts",
|
||||||
"./component/**/*.ts",
|
"./component/**/*.ts",
|
||||||
"./component/**/*.tsx"
|
"./component/**/*.tsx"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -23,6 +23,7 @@ import babel from "@rolldown/plugin-babel";
|
|||||||
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
|
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
|
||||||
import { realpathSync } from "fs";
|
import { realpathSync } from "fs";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
export const vitePluginsConfig = ({
|
export const vitePluginsConfig = ({
|
||||||
mode,
|
mode,
|
||||||
@@ -161,6 +162,11 @@ export default ({
|
|||||||
// which Vite for some reason refuses to work with, so we point it to
|
// which Vite for some reason refuses to work with, so we point it to
|
||||||
// src/index.ts instead
|
// src/index.ts instead
|
||||||
"matrix-widget-api": "matrix-widget-api/src/index.ts",
|
"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: [
|
dedupe: [
|
||||||
"react",
|
"react",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default defineConfig((configEnv) =>
|
|||||||
"src/**/*.test.tsx",
|
"src/**/*.test.tsx",
|
||||||
"component/**/*.test.ts",
|
"component/**/*.test.ts",
|
||||||
"component/**/*.test.tsx",
|
"component/**/*.test.tsx",
|
||||||
|
"sdk/**/*.test.ts",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user