diff --git a/README.md b/README.md index ecbabcf2c..792665f9f 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,23 @@ See also: - [Developing with linked packages](./docs/linking.md) +#### Element Call as a component (experimental) + +Element Call can also be embedded directly into another React application +rather than being loaded in an iframe as a widget. `pnpm build:component` +builds it as a library, and + +```sh +pnpm dev:component +``` + +serves a harness on port 3001 that stands in for such an application: it signs +in twice against the development backend and shows two calls side by side, in +resizable boxes, with page furniture of its own around them. Use it to see how +Element Call behaves when it does not own the page — the size it is given, +whether it stays inside its container, and what it says to its host, which is +logged along the bottom. + ### Backend A docker compose file `docker-compose-dev.yml` is provided to start the diff --git a/component/dev/DevHostBridge.ts b/component/dev/DevHostBridge.ts new file mode 100644 index 000000000..417e4833a --- /dev/null +++ b/component/dev/DevHostBridge.ts @@ -0,0 +1,103 @@ +/* +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 { NEVER, Subject } from "rxjs"; + +import { + type DeviceMuteRequest, + type DeviceMuteState, + type HostBridge, + type HostRequest, +} from "../index"; + +/** + * A host bridge that reports everything it is told and can be driven by hand, + * so that the harness can watch both directions of the conversation between + * Element Call and its host. + */ +export interface DevHostBridge extends HostBridge { + /** Tells Element Call the host has changed theme. */ + requestTheme(name: string): void; + /** Tells Element Call to leave the call. */ + requestHangUp(): void; + /** Asks Element Call to change, and report back, its mute state. */ + requestDeviceMute(request: DeviceMuteRequest): void; +} + +export function createDevHostBridge( + log: (message: string) => void, + /** What the host does when Element Call asks to be closed. */ + onClose: () => void, +): DevHostBridge { + const themeChange$ = new Subject>(); + const hangUp$ = new Subject>>(); + const deviceMute$ = new Subject< + HostRequest + >(); + + const ask = ( + subject: Subject>, + name: string, + data: Data, + ): void => { + // Worth saying out loud: a request nobody is subscribed to is silently + // dropped, and that is exactly the sort of thing the harness is for. + if (!subject.observed) { + log(`← ${name}: nothing is listening`); + return; + } + log(`← ${name}`); + subject.next({ + data, + reply: (reply): void => + log( + `→ ${name} acknowledged${reply === undefined ? "" : `: ${JSON.stringify(reply)}`}`, + ), + }); + }; + + /** + * Records something Element Call told the host. Nothing is sent anywhere, so + * this is only asynchronous because a real host's answer would have to be. + */ + const told = async (message: string): Promise => { + log(`→ ${message}`); + await Promise.resolve(); + }; + + return { + setAlwaysOnScreen: async (alwaysOnScreen): Promise => + await told(`setAlwaysOnScreen(${alwaysOnScreen})`), + contentLoaded: async (): Promise => await told("contentLoaded"), + notifyJoined: async (): Promise => await told("notifyJoined"), + notifyHungUp: async (): Promise => await told("notifyHungUp"), + notifyDeviceMute: async (state): Promise => + await told( + `notifyDeviceMute(audio: ${state.audio_enabled}, video: ${state.video_enabled})`, + ), + // Present because this host really can dismiss Element Call, which is what + // makes it offer a close affordance at all + close: async (): Promise => { + await told("close"); + onClose(); + }, + + themeChange$, + // The harness does not preload a call, so this is never asked for + join$: NEVER, + hangUp$, + deviceMute$, + + supportsReactions: true, + + requestTheme: (name): void => + ask(themeChange$, `themeChange(${name})`, { name }), + requestHangUp: (): void => ask(hangUp$, "hangUp", {}), + requestDeviceMute: (request): void => + ask(deviceMute$, `deviceMute(${JSON.stringify(request)})`, request), + }; +} diff --git a/component/dev/Harness.module.css b/component/dev/Harness.module.css new file mode 100644 index 000000000..0b116a242 --- /dev/null +++ b/component/dev/Harness.module.css @@ -0,0 +1,140 @@ +/* +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. +*/ + +.credentials { + display: flex; + flex-direction: column; + gap: 8px; + max-inline-size: 420px; + margin: 48px auto; + padding: 24px; + background-color: #ffffff; + border: 1px solid #d4d4d8; + border-radius: 8px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.error { + color: #b91c1c; +} + +.harness { + display: grid; + grid-template-rows: auto 1fr auto; + block-size: 100%; +} + +.header, +.paneBar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-block-end: 1px solid #d4d4d8; + background-color: #ffffff; +} + +.header h1 { + font-size: 16px; + margin: 0; +} + +.middle { + display: flex; + min-block-size: 0; +} + +.sidebar { + flex: 0 0 220px; + padding: 12px; + border-inline-end: 1px solid #d4d4d8; + background-color: #ffffff; + overflow-y: auto; +} + +.panes { + display: flex; + flex-wrap: wrap; + /* So that a pane is the size it was dragged to, rather than being stretched + to fill the row */ + align-items: flex-start; + align-content: flex-start; + gap: 16px; + padding: 16px; + flex: 1; + min-inline-size: 0; + overflow: auto; +} + +.pane { + display: flex; + flex-direction: column; + border: 1px solid #d4d4d8; + border-radius: 8px; + overflow: hidden; + background-color: #ffffff; +} + +.paneBar { + flex-wrap: wrap; + gap: 6px; + border-block-end: none; +} + +/* The space the host gives Element Call. Resizable so that the sizes it has to +cope with can be found by dragging rather than by rebuilding, and `overflow: +hidden` both to enable the resize handle and to show up anything inside Element +Call that does not fit the box it was given. */ +.paneCall { + inline-size: 560px; + block-size: 420px; + min-inline-size: 180px; + min-block-size: 180px; + resize: both; + overflow: hidden; +} + +.log { + max-block-size: 180px; + overflow-y: auto; + padding: 8px 12px; + border-block-start: 1px solid #d4d4d8; + background-color: #ffffff; + font-size: 12px; +} + +.log h2 { + font-size: 13px; + margin: 0 0 4px; +} + +.log ol { + margin: 0; + padding: 0; + list-style: none; +} + +/* A host overlay, which Element Call must not be able to draw over */ +.dialogScrim { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background-color: rgb(0 0 0 / 50%); + z-index: 10; +} + +.dialog { + padding: 24px; + border-radius: 8px; + background-color: #ffffff; +} diff --git a/component/dev/Harness.tsx b/component/dev/Harness.tsx new file mode 100644 index 000000000..ddb69d327 --- /dev/null +++ b/component/dev/Harness.tsx @@ -0,0 +1,298 @@ +/* +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 FC, + type FormEvent, + type ReactNode, + useCallback, + useMemo, + useState, +} from "react"; +import { type MatrixClient } from "matrix-js-sdk"; +import { logger } from "matrix-js-sdk/lib/logger"; + +import { ElementCall } from "../index"; +import { createDevHostBridge } from "./DevHostBridge"; +import { createSession, joinRoom } from "./session"; +import styles from "./Harness.module.css"; + +interface Credentials { + homeserver: string; + username: string; + password: string; + room: string; +} + +const CREDENTIALS_KEY = "element-call-component-harness"; + +const DEFAULT_CREDENTIALS: Credentials = { + homeserver: "https://synapse.m.localhost", + username: "", + password: "", + room: "", +}; + +/** The last credentials used, so that a reload does not mean typing them again. */ +function loadCredentials(): Credentials { + try { + const stored = localStorage.getItem(CREDENTIALS_KEY); + if (stored !== null) + return { ...DEFAULT_CREDENTIALS, ...(JSON.parse(stored) as Credentials) }; + } catch (e) { + logger.warn("Could not read the stored harness credentials", e); + } + return DEFAULT_CREDENTIALS; +} + +interface Session { + label: string; + client: MatrixClient; +} + +type State = + | { phase: "credentials" } + | { phase: "starting"; progress: string } + | { phase: "started"; roomId: string; sessions: Session[] } + | { phase: "failed"; error: string }; + +interface LogEntry { + pane: string; + message: string; + at: string; +} + +/** + * One embedded Element Call, with the controls a host would have over it: the + * requests it can make of Element Call, and the ability to take it off screen + * altogether. + */ +const Pane: FC<{ + session: Session; + roomId: string; + log: (pane: string, message: string) => void; +}> = ({ session, roomId, log }): ReactNode => { + const [mounted, setMounted] = useState(true); + + const bridge = useMemo( + () => + createDevHostBridge( + (message) => log(session.label, message), + () => setMounted(false), + ), + [log, session.label], + ); + + return ( +
+
+ {session.label} + {session.client.getDeviceId()} + + + + + +
+ {/* Resizable, because how Element Call copes with the size it is given is + one of the things we cannot find out from the standalone app */} +
+ {mounted && ( + + )} +
+
+ ); +}; + +/** Host furniture, to make it visible if Element Call styles anything but itself. */ +const HostChrome: FC = (): ReactNode => ( + +); + +/** + * A dialog of the host's own, over the top of the calls. Element Call embedded + * in a host has to sit underneath this — being unable to is one of the reasons + * for embedding it rather than putting it in an iframe. + */ +const HostDialog: FC<{ onClose: () => void }> = ({ onClose }): ReactNode => ( +
+
+

A dialog belonging to the host

+

This should cover the calls completely.

+ +
+
+); + +/** + * Stands in for a host application embedding Element Call: it owns the Matrix + * clients, the page and the space each call is given, and reaches Element Call + * only through the component's public interface. + * + * Two calls at once, from two devices of the same account, so that a real call + * happens between them and anything Element Call keeps once per process rather + * than once per call shows itself. + */ +export const Harness: FC = (): ReactNode => { + const [credentials, setCredentials] = useState(loadCredentials); + const [state, setState] = useState({ phase: "credentials" }); + const [entries, setEntries] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + + const log = useCallback((pane: string, message: string): void => { + setEntries((entries) => + [ + ...entries, + { pane, message, at: new Date().toLocaleTimeString() }, + ].slice(-100), + ); + }, []); + + const start = useCallback( + (event: FormEvent): void => { + event.preventDefault(); + localStorage.setItem(CREDENTIALS_KEY, JSON.stringify(credentials)); + const { homeserver, username, password, room } = credentials; + + const progress = (message: string): void => + setState({ phase: "starting", progress: message }); + progress("Starting"); + + void (async (): Promise => { + try { + // One at a time: two logins at once from the same account is the + // shape of request homeservers rate limit + const sessions: Session[] = []; + for (const label of ["Call A", "Call B"]) + sessions.push({ + label, + client: await createSession( + homeserver, + username, + password, + (message) => progress(`${label}: ${message}`), + ), + }); + + progress("Joining the room"); + let roomId = room; + for (const { client } of sessions) + roomId = await joinRoom(client, roomId); + + setState({ phase: "started", roomId, sessions }); + } catch (e) { + logger.error("The harness could not start", e); + setState({ phase: "failed", error: `${e}` }); + } + })(); + }, + [credentials], + ); + + const field = ( + name: keyof Credentials, + label: string, + type = "text", + ): ReactNode => ( + + ); + + if (state.phase !== "started") + return ( +
+

Element Call component harness

+

+ Signs in twice and shows Element Call embedded twice, in a page that + is not Element Call's own. +

+ {field("homeserver", "Homeserver")} + {field("username", "Username")} + {field("password", "Password", "password")} + {field("room", "Room ID or alias")} + + {state.phase === "starting" &&

{state.progress}

} + {state.phase === "failed" && ( +

{state.error}

+ )} +
+ ); + + return ( +
+
+

Element Call component harness

+ {state.roomId} + +
+
+ +
+ {state.sessions.map((session) => ( + + ))} +
+
+
+

Host bridge

+
    + {entries.map((entry, i) => ( +
  1. + {entry.at} {entry.pane}{" "} + {entry.message} +
  2. + ))} +
+
+ {dialogOpen && setDialogOpen(false)} />} +
+ ); +}; diff --git a/component/dev/host.css b/component/dev/host.css new file mode 100644 index 000000000..76e70f609 --- /dev/null +++ b/component/dev/host.css @@ -0,0 +1,23 @@ +/* +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 host page's own styles. Deliberately plain, and deliberately not using +Element Call's design tokens: the harness should look like it does because of +this file, not because Element Call styled it. */ + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + font-family: system-ui, sans-serif; + background-color: #f4f4f5; + color: #18181b; +} diff --git a/component/dev/index.html b/component/dev/index.html new file mode 100644 index 000000000..7174e935a --- /dev/null +++ b/component/dev/index.html @@ -0,0 +1,21 @@ + + + + + + + Element Call component harness + + + +
+ + + diff --git a/component/dev/main.tsx b/component/dev/main.tsx new file mode 100644 index 000000000..9150678d5 --- /dev/null +++ b/component/dev/main.tsx @@ -0,0 +1,42 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { logger } from "matrix-js-sdk/lib/logger"; + +import { type ConfigOptions, initializeElementCall } from "../index"; +import { Harness } from "./Harness"; +// After Element Call's, so that the host has the last word on its own page +import "./host.css"; + +/** + * The development app's own `config.json`, so that the harness runs Element + * Call the way `pnpm dev` does. It is not in the repository — developers copy + * it from `config/config.devenv.json` — so its absence is expected rather than + * an error. + */ +async function loadConfig(): Promise { + try { + const response = await fetch("/config.json"); + if (response.ok) return (await response.json()) as ConfigOptions; + logger.warn( + `No config.json (${response.status}); running with Element Call's defaults`, + ); + } catch (e) { + logger.warn("Could not read config.json", e); + } + return {}; +} + +await initializeElementCall(await loadConfig()); + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/component/dev/session.ts b/component/dev/session.ts new file mode 100644 index 000000000..5a424a409 --- /dev/null +++ b/component/dev/session.ts @@ -0,0 +1,79 @@ +/* +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 and brings up a client the way a host application would, so that the + * component is handed a real one rather than something Element Call built for + * itself. + * + * Everything is kept in memory and a fresh login happens on every reload. That + * costs a device on the development homeserver each time, which is harmless, + * and buys the harness two clients that cannot tread on each other's storage. + * Persisting the login to make reloads quicker would mean persisting the + * crypto store too: reusing a device ID with a fresh crypto store generates new + * device keys, and uploading them conflicts with the ones the server already + * holds. + */ +export async function createSession( + homeserver: string, + username: string, + password: string, + onProgress: (message: string) => void, +): Promise { + onProgress("Logging in"); + 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, + }); + + onProgress(`Setting up crypto for ${login.device_id}`); + await client.initRustCrypto({ useIndexedDB: false }); + + onProgress(`Syncing ${login.device_id}`); + await client.startClient(); + await new Promise((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; +} + +/** + * The room to call in, joining it if this session is not in it yet — a host + * hands Element Call a room it already knows about, so the harness has to get + * itself into that position first. + */ +export async function joinRoom( + client: MatrixClient, + roomIdOrAlias: string, +): Promise { + const room = await client.joinRoom(roomIdOrAlias); + return room.roomId; +} diff --git a/knip.ts b/knip.ts index 97ecc0903..d9de80c8c 100644 --- a/knip.ts +++ b/knip.ts @@ -14,6 +14,7 @@ export default { "vite-embedded.config.ts", "vite-sdk.config.ts", "vite-component.config.ts", + "vite-component-dev.config.ts", ], }, entry: ["src/main.tsx", "eslint/index.js", "i18next.config.ts"], diff --git a/package.json b/package.json index 91ba49dea..cf1e1f717 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "pnpm dev:full", "dev:full": "vite", "dev:embedded": "vite --config vite-embedded.config.js", + "dev:component": "vite --config vite-component-dev.config.ts", "build": "pnpm build:full", "build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build", "build:full:production": "pnpm build:full", @@ -22,10 +23,11 @@ "serve": "vite preview", "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", - "lint:oxlint": "oxlint src playwright", - "lint:oxlint-fix": "oxlint --fix src playwright", + "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:knip": "knip", + "lint:externals": "node scripts/check-component-externals.mjs", "lint:types": "tsc", "i18n": "npx i18next-cli extract", "i18n:check": "npx i18next-cli extract --ci", diff --git a/scripts/check-component-externals.mjs b/scripts/check-component-externals.mjs new file mode 100644 index 000000000..ee7d57da0 --- /dev/null +++ b/scripts/check-component-externals.mjs @@ -0,0 +1,140 @@ +/* +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. +*/ + +/** + * Checks that the component build leaves the packages a host must supply to + * the host. + * + * A host application already has React, the Matrix SDK and LiveKit, and a + * second copy of any of them is worse than dead weight: React would hold two + * sets of hooks, and the Matrix client would run two sync loops. 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. + * + * 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. + * + * 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. + */ + +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 packages whose duplication would break a host, rather than merely enlarge it. */ +const MUST_BE_EXTERNAL = [ + "react", + "react-dom", + "matrix-js-sdk", + "livekit-client", +]; + +const isTestFile = (name) => + name.includes(".test.") || name.includes(".stories."); + +/** Every source file under the given directories, recursively. */ +async function* sourceFiles(dir) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) yield* sourceFiles(path); + else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name)) + yield path; + } +} + +/** + * The module specifiers a source file imports. Covers `from "…"` (which is + * both static imports and re-exports), bare `import "…"` for side effects, and + * dynamic `import("…")`. + */ +function imports(source) { + const specifiers = []; + for (const pattern of [ + /\bfrom\s*["']([^"']+)["']/g, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, + /^\s*import\s+["']([^"']+)["']/gm, + ]) + for (const [, specifier] of source.matchAll(pattern)) + specifiers.push(specifier); + return specifiers; +} + +/** + * Whether a specifier is an import of one of the packages we care about. + * + * Imports carrying a resource query — `?worker`, `?inline` and friends — are + * not, whatever package they name. Those ask the bundler for a script to run + * in a context of its own, which has to be self-contained and shares no state + * with the host's copy of anything. Worker sub-builds do not inherit this + * option anyway. + */ +const mustBeExternal = (specifier) => + !specifier.includes("?") && + MUST_BE_EXTERNAL.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.`, + ); + 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 (missing.size > 0) { + console.error( + `${CONFIG} does not declare these imports external, so the component ` + + `build 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); +} + +// 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(", ")}.`, +); diff --git a/vite-component-dev.config.ts b/vite-component-dev.config.ts new file mode 100644 index 000000000..61cb5e512 --- /dev/null +++ b/vite-component-dev.config.ts @@ -0,0 +1,77 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { defineConfig, searchForWorkspaceRoot } from "vite"; +import { realpathSync } from "node:fs"; +import * as fs from "node:fs"; + +import { vitePluginsConfig } from "./vite.config"; + +// Serves the harness under `component/dev`, which embeds Element Call as a +// component the way a host application would. Development only: this is not +// something we build or ship. +// +// It shares the plugins with the standalone app but not its HTML entry point, +// since the harness has a page of its own — and it deliberately does not build +// on the app's config, which would also bring the app's build output along. +export default defineConfig(({ mode }) => { + // The crypto WASM module is imported dynamically, so Vite has to be told + // that reading it is legitimate — including from a linked copy, which is why + // the paths are resolved rather than assumed. Same as the standalone app. + 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: "component/dev", + // So that the harness can read the same config.json the standalone app + // does, if the developer has written one + publicDir: "../../public", + server: { + host: true, + // One up from the standalone app's, so both can run at once — the point + // of the harness is to compare them + port: 3001, + 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", + }, + resolve: { + alias: { + // matrix-widget-api has its transpiled lib/index.js as its entry point, + // 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", + }, + dedupe: [ + "react", + "react-dom", + "matrix-js-sdk", + "react-use-measure", + // These packages modify the document based on some module-level global + // state, and don't play nicely with duplicate copies of themselves + // https://github.com/radix-ui/primitives/issues/1241#issuecomment-1847837850 + "@radix-ui/react-focus-guards", + "@radix-ui/react-dismissable-layer", + ], + }, + }; +}); diff --git a/vite-component.config.ts b/vite-component.config.ts index 1a57abe61..17f625628 100644 --- a/vite-component.config.ts +++ b/vite-component.config.ts @@ -15,7 +15,11 @@ import { vitePluginsConfig } from "./vite.config"; // Deliberately not built on top of the full app's config, which exists to // produce a page and brings an HTML entry point along with it. export default defineConfig(({ mode }) => ({ - ...vitePluginsConfig({ mode }), + ...vitePluginsConfig({ mode, html: false }), + // A library has no public directory to serve. Without this the build copies + // whatever is in `public` — including the developer's own config.json, which + // is not in the repository — into the output we would publish. + publicDir: false, build: { minify: mode === "production", sourcemap: true, @@ -36,7 +40,10 @@ export default defineConfig(({ mode }) => ({ // SDK as `matrix-js-sdk/lib/…`, and a bare "matrix-js-sdk" would not // catch those — while the pattern and callback forms of this option are // silently ignored by the bundler, so they cannot be used to cover them. - // `pnpm lint:externals` fails if an import appears that is not listed. + // `pnpm lint:externals` reads this list and fails if the source imports + // one of these packages by a path it does not name; a few entries below + // are there only because the standalone app imports them, which costs + // nothing. external: [ "react", "react/jsx-runtime", @@ -44,8 +51,10 @@ export default defineConfig(({ mode }) => ({ "react-dom/client", "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", diff --git a/vite.config.ts b/vite.config.ts index 61936cfdf..678a86fab 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,7 +26,15 @@ import * as fs from "node:fs"; export const vitePluginsConfig = ({ mode, -}: Pick): UserConfig => { + html = true, +}: Pick & { + /** + * Whether to inject Element Call's entry point into the HTML page. Builds + * that produce a library, or serve a page of their own, must not have this: + * it would pull the standalone app in alongside whatever they are building. + */ + html?: boolean; +}): UserConfig => { const env = loadEnv(mode, process.cwd()); const plugins: PluginOption[] = [ babel({ @@ -67,7 +75,7 @@ export const vitePluginsConfig = ({ ); } - if (!process.env.STORYBOOK && !process.env.VITEST) { + if (html && !process.env.STORYBOOK && !process.env.VITEST) { plugins.push( createHtmlPlugin({ entry: "src/main.tsx",