mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
Add a harness for Element Call embedded as a component
`pnpm dev:component` serves a page that stands in for a host application: it signs in twice against the development backend and shows two calls side by side, in resizable boxes, with furniture of its own around them. Two devices of one account, so a real call happens between the two components and anything Element Call keeps once per process rather than once per call shows itself. The host bridge is driven by hand and reports both directions in a log along the bottom, which is the first exercise the theme, hang-up and device-mute requests have had outside widget mode. Each pane can be unmounted and remounted to see what Element Call leaves behind, and there is a `position: fixed` dialog belonging to the host to see whether it covers the calls. The page uses none of Element Call's design tokens, so anything that looks styled outside a pane came from Element Call reaching out of its container. It reaches Element Call only through the component's public interface, which is how the exports missing from that interface came to light. Three things about the component build the harness turned up on the way, all too small to be worth their own commits: - It copied `public/` into `dist/`, including the developer's own gitignored config.json, into output we would publish. `publicDir: false`, as the embedded build already does. The sdk build has the same leak; untouched. - `pnpm lint:externals` now exists, which the build config already claimed it did. It reads the external list out of that config and fails if the source imports React, the Matrix SDK or LiveKit by a path the list does not name. Since the bundler silently ignores the pattern form of that option, an unnamed subpath is bundled with no warning at all — which is how a host would end up with a second React. - `lint:oxlint` ran over `src playwright`, so nothing in `component/` had ever been linted. Serving a page also meant the shared plugin list could no longer inject the app's HTML entry point unconditionally, so that is now optional — and off for the library build too, which never had an HTML page to inject it into.
This commit is contained in:
@@ -213,6 +213,23 @@ See also:
|
|||||||
|
|
||||||
- [Developing with linked packages](./docs/linking.md)
|
- [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
|
### Backend
|
||||||
|
|
||||||
A docker compose file `docker-compose-dev.yml` is provided to start the
|
A docker compose file `docker-compose-dev.yml` is provided to start the
|
||||||
|
|||||||
@@ -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<HostRequest<{ name?: string }>>();
|
||||||
|
const hangUp$ = new Subject<HostRequest<Record<string, never>>>();
|
||||||
|
const deviceMute$ = new Subject<
|
||||||
|
HostRequest<DeviceMuteRequest, DeviceMuteState>
|
||||||
|
>();
|
||||||
|
|
||||||
|
const ask = <Data, Reply>(
|
||||||
|
subject: Subject<HostRequest<Data, Reply>>,
|
||||||
|
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<void> => {
|
||||||
|
log(`→ ${message}`);
|
||||||
|
await Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
setAlwaysOnScreen: async (alwaysOnScreen): Promise<void> =>
|
||||||
|
await told(`setAlwaysOnScreen(${alwaysOnScreen})`),
|
||||||
|
contentLoaded: async (): Promise<void> => await told("contentLoaded"),
|
||||||
|
notifyJoined: async (): Promise<void> => await told("notifyJoined"),
|
||||||
|
notifyHungUp: async (): Promise<void> => await told("notifyHungUp"),
|
||||||
|
notifyDeviceMute: async (state): Promise<void> =>
|
||||||
|
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<void> => {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className={styles.pane}>
|
||||||
|
<div className={styles.paneBar}>
|
||||||
|
<strong>{session.label}</strong>
|
||||||
|
<code>{session.client.getDeviceId()}</code>
|
||||||
|
<button onClick={(): void => setMounted((m) => !m)}>
|
||||||
|
{mounted ? "Unmount" : "Mount"}
|
||||||
|
</button>
|
||||||
|
<button onClick={(): void => bridge.requestTheme("light")}>
|
||||||
|
Light
|
||||||
|
</button>
|
||||||
|
<button onClick={(): void => bridge.requestTheme("dark")}>Dark</button>
|
||||||
|
<button
|
||||||
|
onClick={(): void =>
|
||||||
|
bridge.requestDeviceMute({ audio_enabled: false })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Mute
|
||||||
|
</button>
|
||||||
|
<button onClick={(): void => bridge.requestHangUp()}>Hang up</button>
|
||||||
|
</div>
|
||||||
|
{/* 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 */}
|
||||||
|
<div className={styles.paneCall}>
|
||||||
|
{mounted && (
|
||||||
|
<ElementCall
|
||||||
|
client={session.client}
|
||||||
|
roomId={roomId}
|
||||||
|
hostBridge={bridge}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Host furniture, to make it visible if Element Call styles anything but itself. */
|
||||||
|
const HostChrome: FC = (): ReactNode => (
|
||||||
|
<nav className={styles.sidebar}>
|
||||||
|
<h2>Host chrome</h2>
|
||||||
|
<p>
|
||||||
|
This column belongs to the host. If Element Call's stylesheet reaches
|
||||||
|
outside its own container, it shows up here.
|
||||||
|
</p>
|
||||||
|
<hr />
|
||||||
|
<ul>
|
||||||
|
<li>Some room</li>
|
||||||
|
<li>Another room</li>
|
||||||
|
</ul>
|
||||||
|
<button>A host button</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 => (
|
||||||
|
<div className={styles.dialogScrim}>
|
||||||
|
<div className={styles.dialog}>
|
||||||
|
<h2>A dialog belonging to the host</h2>
|
||||||
|
<p>This should cover the calls completely.</p>
|
||||||
|
<button onClick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<State>({ phase: "credentials" });
|
||||||
|
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||||||
|
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<void> => {
|
||||||
|
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 => (
|
||||||
|
<label className={styles.field}>
|
||||||
|
{label}
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={credentials[name]}
|
||||||
|
onChange={(e): void =>
|
||||||
|
setCredentials((c) => ({ ...c, [name]: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (state.phase !== "started")
|
||||||
|
return (
|
||||||
|
<form className={styles.credentials} onSubmit={start}>
|
||||||
|
<h1>Element Call component harness</h1>
|
||||||
|
<p>
|
||||||
|
Signs in twice and shows Element Call embedded twice, in a page that
|
||||||
|
is not Element Call's own.
|
||||||
|
</p>
|
||||||
|
{field("homeserver", "Homeserver")}
|
||||||
|
{field("username", "Username")}
|
||||||
|
{field("password", "Password", "password")}
|
||||||
|
{field("room", "Room ID or alias")}
|
||||||
|
<button type="submit" disabled={state.phase === "starting"}>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
{state.phase === "starting" && <p>{state.progress}</p>}
|
||||||
|
{state.phase === "failed" && (
|
||||||
|
<p className={styles.error}>{state.error}</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.harness}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<h1>Element Call component harness</h1>
|
||||||
|
<code>{state.roomId}</code>
|
||||||
|
<button onClick={(): void => setDialogOpen(true)}>
|
||||||
|
Open a host dialog
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className={styles.middle}>
|
||||||
|
<HostChrome />
|
||||||
|
<main className={styles.panes}>
|
||||||
|
{state.sessions.map((session) => (
|
||||||
|
<Pane
|
||||||
|
key={session.label}
|
||||||
|
session={session}
|
||||||
|
roomId={state.roomId}
|
||||||
|
log={log}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<section className={styles.log}>
|
||||||
|
<h2>Host bridge</h2>
|
||||||
|
<ol>
|
||||||
|
{entries.map((entry, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<code>{entry.at}</code> <strong>{entry.pane}</strong>{" "}
|
||||||
|
{entry.message}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
{dialogOpen && <HostDialog onClose={(): void => setDialogOpen(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!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>Element Call component harness</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Deliberately plain: this page stands in for a host application, so
|
||||||
|
anything it looks like must have come from the host's own styles or from
|
||||||
|
Element Call reaching outside its container. -->
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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<ConfigOptions> {
|
||||||
|
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(
|
||||||
|
<StrictMode>
|
||||||
|
<Harness />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -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<MatrixClient> {
|
||||||
|
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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string> {
|
||||||
|
const room = await client.joinRoom(roomIdOrAlias);
|
||||||
|
return room.roomId;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export default {
|
|||||||
"vite-embedded.config.ts",
|
"vite-embedded.config.ts",
|
||||||
"vite-sdk.config.ts",
|
"vite-sdk.config.ts",
|
||||||
"vite-component.config.ts",
|
"vite-component.config.ts",
|
||||||
|
"vite-component-dev.config.ts",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
entry: ["src/main.tsx", "eslint/index.js", "i18next.config.ts"],
|
entry: ["src/main.tsx", "eslint/index.js", "i18next.config.ts"],
|
||||||
|
|||||||
+5
-3
@@ -6,6 +6,7 @@
|
|||||||
"dev": "pnpm dev:full",
|
"dev": "pnpm dev:full",
|
||||||
"dev:full": "vite",
|
"dev:full": "vite",
|
||||||
"dev:embedded": "vite --config vite-embedded.config.js",
|
"dev:embedded": "vite --config vite-embedded.config.js",
|
||||||
|
"dev:component": "vite --config vite-component-dev.config.ts",
|
||||||
"build": "pnpm build:full",
|
"build": "pnpm build:full",
|
||||||
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
|
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
|
||||||
"build:full:production": "pnpm build:full",
|
"build:full:production": "pnpm build:full",
|
||||||
@@ -22,10 +23,11 @@
|
|||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"format": "oxfmt",
|
"format": "oxfmt",
|
||||||
"format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc",
|
"format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc",
|
||||||
"lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip",
|
"lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip && pnpm lint:externals",
|
||||||
"lint:oxlint": "oxlint src playwright",
|
"lint:oxlint": "oxlint src component playwright",
|
||||||
"lint:oxlint-fix": "oxlint --fix src playwright",
|
"lint:oxlint-fix": "oxlint --fix src component playwright",
|
||||||
"lint:knip": "knip",
|
"lint:knip": "knip",
|
||||||
|
"lint:externals": "node scripts/check-component-externals.mjs",
|
||||||
"lint:types": "tsc",
|
"lint:types": "tsc",
|
||||||
"i18n": "npx i18next-cli extract",
|
"i18n": "npx i18next-cli extract",
|
||||||
"i18n:check": "npx i18next-cli extract --ci",
|
"i18n:check": "npx i18next-cli extract --ci",
|
||||||
|
|||||||
@@ -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(", ")}.`,
|
||||||
|
);
|
||||||
@@ -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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -15,7 +15,11 @@ import { vitePluginsConfig } from "./vite.config";
|
|||||||
// Deliberately not built on top of the full app's config, which exists to
|
// 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.
|
// produce a page and brings an HTML entry point along with it.
|
||||||
export default defineConfig(({ mode }) => ({
|
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: {
|
build: {
|
||||||
minify: mode === "production",
|
minify: mode === "production",
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
@@ -36,7 +40,10 @@ export default defineConfig(({ mode }) => ({
|
|||||||
// SDK as `matrix-js-sdk/lib/…`, and a bare "matrix-js-sdk" would not
|
// 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
|
// 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.
|
// 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: [
|
external: [
|
||||||
"react",
|
"react",
|
||||||
"react/jsx-runtime",
|
"react/jsx-runtime",
|
||||||
@@ -44,8 +51,10 @@ export default defineConfig(({ mode }) => ({
|
|||||||
"react-dom/client",
|
"react-dom/client",
|
||||||
"livekit-client",
|
"livekit-client",
|
||||||
"matrix-js-sdk",
|
"matrix-js-sdk",
|
||||||
|
"matrix-js-sdk/lib/browser-index",
|
||||||
"matrix-js-sdk/lib/client",
|
"matrix-js-sdk/lib/client",
|
||||||
"matrix-js-sdk/lib/crypto-api",
|
"matrix-js-sdk/lib/crypto-api",
|
||||||
|
"matrix-js-sdk/lib/indexeddb-worker",
|
||||||
"matrix-js-sdk/lib/logger",
|
"matrix-js-sdk/lib/logger",
|
||||||
"matrix-js-sdk/lib/matrix",
|
"matrix-js-sdk/lib/matrix",
|
||||||
"matrix-js-sdk/lib/matrixrtc",
|
"matrix-js-sdk/lib/matrixrtc",
|
||||||
|
|||||||
+10
-2
@@ -26,7 +26,15 @@ import * as fs from "node:fs";
|
|||||||
|
|
||||||
export const vitePluginsConfig = ({
|
export const vitePluginsConfig = ({
|
||||||
mode,
|
mode,
|
||||||
}: Pick<ConfigEnv, "mode">): UserConfig => {
|
html = true,
|
||||||
|
}: Pick<ConfigEnv, "mode"> & {
|
||||||
|
/**
|
||||||
|
* 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 env = loadEnv(mode, process.cwd());
|
||||||
const plugins: PluginOption[] = [
|
const plugins: PluginOption[] = [
|
||||||
babel({
|
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(
|
plugins.push(
|
||||||
createHtmlPlugin({
|
createHtmlPlugin({
|
||||||
entry: "src/main.tsx",
|
entry: "src/main.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user