mirror of
https://github.com/element-hq/element-call.git
synced 2026-10-05 23:27:15 +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
b0dd1c742f
commit
12bb87fa4c
@@ -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` /
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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,
|
||||
"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"
|
||||
|
||||
@@ -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 { 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",
|
||||
|
||||
@@ -26,6 +26,7 @@ export default defineConfig((configEnv) =>
|
||||
"src/**/*.test.tsx",
|
||||
"component/**/*.test.ts",
|
||||
"component/**/*.test.tsx",
|
||||
"sdk/**/*.test.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user