add support for sending data to the rtcSdk

This commit is contained in:
Timo K.
2026-10-08 13:53:56 +02:00
parent 5c6d2f0891
commit 2af87a760d
9 changed files with 420 additions and 7 deletions
+22
View File
@@ -153,6 +153,28 @@ export interface MatrixRTCClient {
/** Transports the session currently holds a live connection to. */
connectedTransports$: Behavior<TransportMetadata[]>;
/**
* Sends a short text to every member on the local transport, over a
* reliable data channel beside the media. A packet holds about 15 KiB;
* anything larger belongs in a room event. Rejects while the local
* transport is not connected. Encrypted on the wire like the media, but
* not end to end with the media key.
*/
sendData(topic: string, text: string): Promise<void>;
/**
* What remote members sent with `sendData`, on every transport the client
* is connected to. A message from an identity that is not a member is
* dropped, so a host only ever hears from attested members.
*/
data$: Observable<DataMessage>;
}
/** One `sendData` call as it arrives at a remote member. */
export interface DataMessage {
member: RemoteRTCMember;
topic: string;
text: string;
}
// ---------------------------------------------------------------------------
+149
View File
@@ -0,0 +1,149 @@
/*
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 { EventEmitter } from "events";
import { type RemoteParticipant, RoomEvent } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { BehaviorSubject } from "rxjs";
import { describe, expect, it, vi } from "vitest";
import { type DataMessage, type RemoteRTCMember } from "../api";
import { Epoch } from "../reactive/ObservableScope";
import { constant } from "../reactive/Behavior";
import { type Connection, ConnectionState } from "./Connection";
import { ConnectionManagerData } from "./ConnectionManager";
import { createDataChannel$ } from "./DataChannel";
import { exampleTransport, testScope } from "../utils/test";
const encode = (text: string): Uint8Array => new TextEncoder().encode(text);
function fakeConnection(
url: string,
state = ConnectionState.LivekitConnected,
): {
connection: Connection;
room: EventEmitter;
publishData: ReturnType<typeof vi.fn>;
} {
const room = new EventEmitter();
const publishData = vi.fn(async () => Promise.resolve());
const connection = {
transport: { ...exampleTransport, livekit_service_url: url },
state$: constant(state),
livekitRoom: Object.assign(room, { localParticipant: { publishData } }),
} as unknown as Connection;
return { connection, room, publishData };
}
function member(id: string): RemoteRTCMember {
return { id, userId: `@${id}:example.org` } as RemoteRTCMember;
}
function setup(
connections: Connection[],
members: RemoteRTCMember[] = [member("alice")],
): {
received: DataMessage[];
connections$: BehaviorSubject<Epoch<ConnectionManagerData>>;
sendData: (topic: string, text: string) => Promise<void>;
} {
const data = new ConnectionManagerData();
for (const connection of connections) data.add(connection, []);
const connections$ = new BehaviorSubject(new Epoch(data));
const { data$, sendData } = createDataChannel$({
scope: testScope(),
connectionManager: { connectionManagerData$: connections$ },
remoteMembers$: constant(members),
connection$: constant(connections[0] ?? null),
logger,
});
const received: DataMessage[] = [];
data$.subscribe((message) => received.push(message));
return { received, connections$, sendData };
}
describe("data$", () => {
it("delivers a packet from a member with its topic and text", () => {
const { connection, room } = fakeConnection("https://sfu");
const { received } = setup([connection]);
room.emit(
RoomEvent.DataReceived,
encode("hello"),
{ identity: "alice" } as RemoteParticipant,
undefined,
"chat",
);
expect(received).toEqual([
{ member: member("alice"), topic: "chat", text: "hello" },
]);
});
it("drops packets from the server and from identities that are not members", () => {
const { connection, room } = fakeConnection("https://sfu");
const { received } = setup([connection]);
room.emit(RoomEvent.DataReceived, encode("server"), undefined);
room.emit(RoomEvent.DataReceived, encode("stranger"), {
identity: "mallory",
} as RemoteParticipant);
expect(received).toEqual([]);
});
it("listens on every connection, including ones added later", () => {
const first = fakeConnection("https://sfu-1");
const second = fakeConnection("https://sfu-2");
const { received, connections$ } = setup(
[first.connection],
[member("alice"), member("bob")],
);
const data = new ConnectionManagerData();
data.add(first.connection, []);
data.add(second.connection, []);
connections$.next(new Epoch(data, 1));
first.room.emit(RoomEvent.DataReceived, encode("one"), {
identity: "alice",
} as RemoteParticipant);
second.room.emit(RoomEvent.DataReceived, encode("two"), {
identity: "bob",
} as RemoteParticipant);
expect(received.map((m) => [m.member.id, m.text])).toEqual([
["alice", "one"],
["bob", "two"],
]);
});
});
describe("sendData", () => {
it("publishes the text as a reliable packet on the local connection", async () => {
const { connection, publishData } = fakeConnection("https://sfu");
const { sendData } = setup([connection]);
await sendData("chat", "hello");
expect(publishData).toHaveBeenCalledWith(encode("hello"), {
reliable: true,
topic: "chat",
});
});
it("rejects while the local transport is not connected", async () => {
const { connection, publishData } = fakeConnection(
"https://sfu",
ConnectionState.Initialized,
);
const { sendData } = setup([connection]);
await expect(sendData("chat", "hello")).rejects.toThrow("not connected");
expect(publishData).not.toHaveBeenCalled();
});
});
+118
View File
@@ -0,0 +1,118 @@
/*
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 RemoteParticipant,
type Room as LivekitRoom,
RoomEvent,
} from "livekit-client";
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
distinctUntilChanged,
filter,
fromEvent,
map,
merge,
type Observable,
share,
switchMap,
} from "rxjs";
import { type DataMessage, type RemoteRTCMember } from "../api";
import { type Behavior } from "../reactive/Behavior";
import { type ObservableScope } from "../reactive/ObservableScope";
import { type Connection, ConnectionState } from "./Connection";
import { type IConnectionManager } from "./ConnectionManager";
interface Props {
scope: ObservableScope;
connectionManager: IConnectionManager;
/** Used to resolve the sender of each message, by its transport identity. */
remoteMembers$: Behavior<RemoteRTCMember[]>;
/** The local member's connection, which carries what we send. */
connection$: Behavior<Connection | null>;
logger: Logger;
}
export interface DataChannel {
data$: Observable<DataMessage>;
sendData: (topic: string, text: string) => Promise<void>;
}
/**
* A text channel beside the media: LiveKit's reliable data packets, received
* on every connection the client holds and sent on the local member's. The
* sender is matched to a member by its identity, so the packet's own claim of
* who sent it never reaches the host.
*/
export function createDataChannel$({
scope,
connectionManager,
remoteMembers$,
connection$,
logger,
}: Props): DataChannel {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const rooms$ = connectionManager.connectionManagerData$.pipe(
map(({ value }) => value.getConnections().map((c) => c.livekitRoom)),
distinctUntilChanged(
(a, b) => a.length === b.length && a.every((room, i) => room === b[i]),
),
);
const data$ = rooms$.pipe(
switchMap((rooms) => merge(...rooms.map(receivedOn))),
map(({ payload, participant, topic }): DataMessage | null => {
// Only the server sends without a participant; a host has no use for it
if (participant === undefined) return null;
const member = remoteMembers$.value.find(
(m) => m.id === participant.identity,
);
if (member === undefined) {
logger.warn(`Dropping data from ${participant.identity}: not a member`);
return null;
}
return { member, topic: topic ?? "", text: decoder.decode(payload) };
}),
filter((message) => message !== null),
scope.bind(),
share(),
);
const sendData = async (topic: string, text: string): Promise<void> => {
const connection = connection$.value;
if (connection?.state$.value !== ConnectionState.LivekitConnected)
throw new Error("Cannot send data: the local transport is not connected");
await connection.livekitRoom.localParticipant.publishData(
encoder.encode(text),
{ reliable: true, topic },
);
};
return { data$, sendData };
}
interface Received {
payload: Uint8Array;
participant: RemoteParticipant | undefined;
topic: string | undefined;
}
function receivedOn(room: LivekitRoom): Observable<Received> {
return fromEvent(
room,
RoomEvent.DataReceived,
(
payload: Uint8Array,
participant?: RemoteParticipant,
_kind?: unknown,
topic?: string,
): Received => ({ payload, participant, topic }),
);
}
+11
View File
@@ -53,6 +53,7 @@ import {
membershipKeys,
} from "./Members";
import { createConnectionManager$ } from "./ConnectionManager";
import { createDataChannel$ } from "./DataChannel";
import { type DesiredMedia, Publisher } from "./Publisher";
import {
createKeyRotationSuppressed$,
@@ -248,6 +249,14 @@ export function createMatrixRTCClient(
).pipe(map((member) => member ?? null)),
);
const { data$, sendData } = createDataChannel$({
scope,
connectionManager,
remoteMembers$,
connection$: localMembership.connection$,
logger,
});
const status$ = scope.behavior(
combineLatest(
[
@@ -280,6 +289,8 @@ export function createMatrixRTCClient(
),
keyRotationSuppressed$: createKeyRotationSuppressed$(scope, jsSdkSession),
connectedTransports$: transports.connected$,
sendData,
data$,
};
}