From 12bb87fa4cebf943b3d35b46098d5b78b698d6bb Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Thu, 1 Oct 2026 16:57:34 +0200 Subject: [PATCH] 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 --- docs/agents/architecture.md | 9 +- docs/agents/testing.md | 4 + knip.ts | 2 + package.json | 10 +- playwright.config.ts | 13 ++ playwright/sdk/harness.ts | 88 +++++++++ playwright/sdk/smoke.spec.ts | 56 ++++++ scripts/check-component-externals.mjs | 167 ++++++++-------- sdk/README.md | 74 +++++++ sdk/dev/index.html | 56 ++++++ sdk/dev/main.ts | 157 +++++++++++++++ sdk/dev/session.ts | 49 +++++ sdk/index.ts | 271 ++++++++++++++++++++++++++ sdk/package.json | 37 ++++ sdk/pnpm-workspace.yaml | 14 ++ sdk/tsconfig.build.json | 18 ++ tsconfig.json | 5 + vite-sdk-dev.config.ts | 49 +++++ vite-sdk.config.ts | 65 ++++++ vite.config.ts | 6 + vitest.config.ts | 1 + 21 files changed, 1071 insertions(+), 80 deletions(-) create mode 100644 playwright/sdk/harness.ts create mode 100644 playwright/sdk/smoke.spec.ts create mode 100644 sdk/README.md create mode 100644 sdk/dev/index.html create mode 100644 sdk/dev/main.ts create mode 100644 sdk/dev/session.ts create mode 100644 sdk/index.ts create mode 100644 sdk/package.json create mode 100644 sdk/pnpm-workspace.yaml create mode 100644 sdk/tsconfig.build.json create mode 100644 vite-sdk-dev.config.ts create mode 100644 vite-sdk.config.ts diff --git a/docs/agents/architecture.md b/docs/agents/architecture.md index b909e1bcf..96f8af477 100644 --- a/docs/agents/architecture.md +++ b/docs/agents/architecture.md @@ -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` / diff --git a/docs/agents/testing.md b/docs/agents/testing.md index 35a9548a8..d1a325897 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -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 ``` diff --git a/knip.ts b/knip.ts index 919d2473e..fc3f240e5 100644 --- a/knip.ts +++ b/knip.ts @@ -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", diff --git a/package.json b/package.json index d2deb64d2..199d0ebfe 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index e6dcc5249..847b87c84 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, + }, + }, ], }); diff --git a/playwright/sdk/harness.ts b/playwright/sdk/harness.ts new file mode 100644 index 000000000..999500eb2 --- /dev/null +++ b/playwright/sdk/harness.ts @@ -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 { + 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}`); +} diff --git a/playwright/sdk/smoke.spec.ts b/playwright/sdk/smoke.spec.ts new file mode 100644 index 000000000..70a4b80c0 --- /dev/null +++ b/playwright/sdk/smoke.spec.ts @@ -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 { + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + return context.newPage(); +} diff --git a/scripts/check-component-externals.mjs b/scripts/check-component-externals.mjs index 42d9235df..1b35b8877 100644 --- a/scripts/check-component-externals.mjs +++ b/scripts/check-component-externals.mjs @@ -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); diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 000000000..e750513c3 --- /dev/null +++ b/sdk/README.md @@ -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