feat(docs): LiveKit data channel transport for real-time delta sync

Replace per-keystroke Matrix timeline events with a LiveKit data
channel for real-time Automerge delta delivery:

- Add livekit-client dependency
- New useDocumentRTC hook:
  - Calls client._unstable_getRTCTransports() to get SFU URL
  - Exchanges Matrix OpenID token with SFU (/sfu/get) for LiveKit JWT
  - Connects to LiveKit room in data-only mode (autoSubscribe: false)
  - Exposes publishDelta(bytes) and onDeltaRef callback
  - Falls back gracefully if no LiveKit transport configured
- DocumentView wiring:
  - useDocumentSync now accepts optional rtc transport
  - When LiveKit connected: send deltas via data channel (50ms debounce)
  - When LiveKit not available: fall back to Matrix timeline events (500ms)
  - Matrix event listener disabled when LiveKit is connected
  - Snapshots still go via Matrix state events (every 20 deltas + on close)
  - __docDebug() now shows rtcConnected status

Benefits: no homeserver rate limiting on deltas, ~50ms latency vs
~500ms+sync, no delta events cluttering the room timeline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Langley 2026-03-04 22:08:29 +00:00
parent 8c45fe3736
commit 0e535724cb
4 changed files with 359 additions and 44 deletions

View File

@ -82,6 +82,7 @@
"linkify-react": "4.3.2",
"linkify-string": "4.3.2",
"linkifyjs": "4.3.2",
"livekit-client": "^2.17.2",
"lodash": "npm:lodash-es@^4.17.21",
"maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3",

View File

@ -15,6 +15,7 @@ import { FormattingButtons } from "./components/FormattingButtons.tsx";
import { Editor } from "./components/Editor.tsx";
import { ComposerContext, getDefaultContextValue } from "./ComposerContext.ts";
import { useSetCursorPosition } from "./hooks/useSetCursorPosition.ts";
import { useDocumentRTC } from "./useDocumentRTC.ts";
/**
* Matrix event type for incremental Automerge deltas sent as timeline events.
@ -31,6 +32,12 @@ const DOC_STATE_EVENT_TYPE = "org.element.doc.automerge";
/** Debounce delay (ms) before sending an incremental delta after a keystroke. */
const DELTA_DEBOUNCE_MS = 500;
/**
* Debounce when sending over LiveKit (much lower latency, no rate limits).
* Kept small to batch rapid keystrokes without perceptible delay.
*/
const DELTA_DEBOUNCE_RTC_MS = 50;
/** Save a full snapshot to room state every N deltas (not every send). */
const SNAPSHOT_EVERY_N_DELTAS = 20;
@ -153,6 +160,12 @@ function useDocumentSync(
composerModel: unknown,
editorRef: React.RefObject<HTMLDivElement | null>,
onContentChanged: () => void,
/** If provided, send deltas via LiveKit instead of Matrix events. */
rtc?: {
publishDelta: (bytes: Uint8Array) => void;
onDeltaRef: React.MutableRefObject<((bytes: Uint8Array) => void) | null>;
isConnected: boolean;
},
): {
isLoaded: boolean;
scheduleDeltaSend: () => void;
@ -240,85 +253,109 @@ function useDocumentSync(
composerModelRef.current = composerModel;
});
/** Apply raw Automerge delta bytes to the model and update the DOM. */
const applyDeltaBytes = useCallback((deltaBytes: Uint8Array): void => {
const model = composerModelRef.current;
if (!isCollaborative(model)) {
logger.warn("[DocumentView] Model not collaborative yet, dropping delta");
return;
}
try {
model.receive_changes(deltaBytes);
if (editorRef.current) {
suppressMutations.current = true;
const caretOffset = saveCaretOffset(editorRef.current);
editorRef.current.innerHTML = model.get_content_as_html();
restoreCaretOffset(editorRef.current, caretOffset);
requestAnimationFrame(() => { suppressMutations.current = false; });
onContentChanged();
}
} catch (e) {
logger.warn("[DocumentView] Failed to apply remote delta", e);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editorRef, onContentChanged]);
// Wire the LiveKit onDeltaRef callback when RTC is provided.
useEffect(() => {
logger.info("[DocumentView] Registering delta listeners for room", room.roomId);
if (!rtc) return;
rtc.onDeltaRef.current = applyDeltaBytes;
return () => { rtc.onDeltaRef.current = null; };
}, [rtc, applyDeltaBytes]);
// Matrix event fallback: only used when LiveKit is NOT connected.
useEffect(() => {
// If LiveKit is connected, deltas come through the data channel — skip Matrix listener.
if (rtc?.isConnected) {
logger.info("[DocumentView] LiveKit connected — skipping Matrix delta listener");
return;
}
logger.info("[DocumentView] Registering Matrix delta listener (no LiveKit)");
const applyDeltaEvent = (event: import("matrix-js-sdk/src/matrix").MatrixEvent): void => {
if (event.getRoomId() !== room.roomId) return;
if (event.getType() !== DOC_DELTA_EVENT_TYPE) return;
// Skip our own events. In encrypted rooms device_id may not be in
// unsigned, so also skip by sender alone — the local model already
// has our own changes via save_incremental().
// Skip our own events — local model already has our changes.
if (event.getSender() === client.getUserId()) return;
const model = composerModelRef.current;
if (!isCollaborative(model)) { logger.warn("[DocumentView] Model not collaborative yet, dropping delta"); return; }
const data = event.getContent<{ data?: string }>().data;
if (!data) { logger.warn("[DocumentView] Delta event has no data"); return; }
try {
model.receive_changes(base64Decode(data));
if (editorRef.current) {
// Suppress MutationObserver during DOM update. Use
// requestAnimationFrame to reset the flag AFTER the observer's
// microtask has fired so it doesn't schedule a spurious delta send.
suppressMutations.current = true;
const caretOffset = saveCaretOffset(editorRef.current);
editorRef.current.innerHTML = model.get_content_as_html();
restoreCaretOffset(editorRef.current, caretOffset);
requestAnimationFrame(() => { suppressMutations.current = false; });
onContentChanged();
}
logger.info("[DocumentView] Applied remote delta successfully");
} catch (e) {
logger.warn("[DocumentView] Failed to apply remote delta", e);
}
if (!data) return;
applyDeltaBytes(base64Decode(data));
};
// For unencrypted rooms: events arrive ready to use on Room.timeline.
// For encrypted rooms: events arrive as m.room.encrypted on Room.timeline
// and are only usable after MatrixEventEvent.Decrypted fires on the client.
// For unencrypted rooms: events arrive ready on Room.timeline.
// For encrypted rooms: decrypt fires MatrixEventEvent.Decrypted.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
room.on("Room.timeline" as any, applyDeltaEvent);
client.on(MatrixEventEvent.Decrypted, applyDeltaEvent);
return () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
room.off("Room.timeline" as any, applyDeltaEvent);
client.off(MatrixEventEvent.Decrypted, applyDeltaEvent);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, client, editorRef, onContentChanged]);
}, [room, client, rtc?.isConnected, applyDeltaBytes]);
// Debounced delta send triggered after each keystroke.
// Saves a full snapshot to room state every SNAPSHOT_EVERY_N_DELTAS sends
// so the document persists on refresh without rate-limit pressure.
// When LiveKit is connected: sends via data channel (50ms debounce, no rate limits).
// When falling back: sends as Matrix timeline event (500ms debounce).
// Saves a full snapshot to room state every SNAPSHOT_EVERY_N_DELTAS sends.
const deltaSendCount = useRef(0);
const rtcRef = useRef(rtc);
useEffect(() => { rtcRef.current = rtc; });
const scheduleDeltaSend = useCallback(() => {
if (!isCollaborative(composerModel)) return;
if (debounceTimer.current !== null) clearTimeout(debounceTimer.current);
const useRTC = rtcRef.current?.isConnected ?? false;
const debounceMs = useRTC ? DELTA_DEBOUNCE_RTC_MS : DELTA_DEBOUNCE_MS;
debounceTimer.current = setTimeout(async () => {
debounceTimer.current = null;
if (!isCollaborative(composerModel)) return;
try {
const delta = composerModel.save_incremental();
if (delta.length === 0) return; // Nothing new to send.
if (delta.length === 0) return;
const heads = composerModel.get_heads();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await client.sendEvent(room.roomId, DOC_DELTA_EVENT_TYPE as any, {
data: base64Encode(delta),
heads,
});
if (rtcRef.current?.isConnected) {
// Fast path: send over LiveKit data channel.
rtcRef.current.publishDelta(delta);
} else {
// Fallback: send as Matrix timeline event.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await client.sendEvent(room.roomId, DOC_DELTA_EVENT_TYPE as any, {
data: base64Encode(delta),
heads,
});
}
deltaSendCount.current++;
// Persist a full snapshot periodically so the document survives
// refresh. Includes heads so loaders can skip already-merged deltas.
// Persist a full snapshot periodically regardless of transport.
if (deltaSendCount.current % SNAPSHOT_EVERY_N_DELTAS === 0) {
const snapshot = composerModel.save_document();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -331,7 +368,7 @@ function useDocumentSync(
} catch (e) {
logger.warn("[DocumentView] Failed to send delta", e);
}
}, DELTA_DEBOUNCE_MS);
}, debounceMs);
}, [client, composerModel, room.roomId]);
// Flush pending delta and save a final snapshot when the document view
@ -395,6 +432,9 @@ export const DocumentView = memo(function DocumentView({ room }: DocumentViewPro
const { ref, isWysiwygReady, wysiwyg, actionStates } = wysiwygResult;
const composerModel = wysiwygResult.composerModel;
// LiveKit real-time transport (falls back gracefully if unavailable).
const rtc = useDocumentRTC(room, client);
// Place the cursor at the end and focus the editor once the WASM model is
// ready. Without this the editor is enabled but has no selection, so no
// cursor appears even after the element receives focus.
@ -415,6 +455,7 @@ export const DocumentView = memo(function DocumentView({ room }: DocumentViewPro
composerModel,
ref,
notifyContentChangedRef.current,
rtc,
);
const handleInput = useCallback(() => {
@ -470,6 +511,7 @@ export const DocumentView = memo(function DocumentView({ room }: DocumentViewPro
deviceId: client.getDeviceId(),
roomId: room.roomId,
modelReady: collab,
rtcConnected: rtc.isConnected,
heads: collab ? model.get_heads() : null,
html: collab ? model.get_content_as_html() : null,
docHash: docBytes ? simpleHash(docBytes) : null,
@ -505,7 +547,7 @@ export const DocumentView = memo(function DocumentView({ room }: DocumentViewPro
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any).__docDebug;
};
}, [composerModel, room, client, ref]);
}, [composerModel, room, client, ref, rtc]);
// Always render the Editor so that `ref.current` is attached before
// useComposerModel's effect runs and calls initModel(). The loading

View File

@ -0,0 +1,192 @@
/*
Copyright 2026 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
/**
* useDocumentRTC real-time Automerge delta transport via LiveKit data channel.
*
* Joins a MatrixRTC session (intent: "document") in the room, exchanges the
* user's OpenID token with the LiveKit SFU service for a JWT, connects to the
* LiveKit room in data-only mode, and exposes:
* - `publishDelta(bytes)` send an Automerge incremental delta to all peers
* - `onDeltaRef` callback ref invoked on every received delta
* - `isConnected` whether the LiveKit room is currently connected
*
* Falls back gracefully: if the homeserver has no LiveKit transport configured,
* `isConnected` stays false and the caller should fall back to Matrix events.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { type Room as MatrixRoom, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { isLivekitTransportConfig } from "matrix-js-sdk/src/matrixrtc";
import { logger } from "matrix-js-sdk/src/logger";
import { Room as LivekitRoom, RoomEvent, ConnectionState } from "livekit-client";
const LIVEKIT_DATA_TOPIC = "org.element.doc.delta";
export interface UseDocumentRTCResult {
/** Send an Automerge incremental delta to all connected peers. */
publishDelta: (bytes: Uint8Array) => void;
/** Set this to receive deltas from peers. Updated value is used without re-registering listeners. */
onDeltaRef: React.MutableRefObject<((bytes: Uint8Array) => void) | null>;
/** True when connected to the LiveKit room. */
isConnected: boolean;
}
/**
* Exchange a Matrix OpenID token for a LiveKit JWT from the SFU service.
* The SFU service follows MSC4143: POST {sfu_url}/sfu/get with the openid token.
*/
async function getLivekitJwt(
sfuServiceUrl: string,
roomId: string,
openidToken: {
access_token: string;
token_type: string;
matrix_server_name: string;
expires_in: number;
},
deviceId: string,
): Promise<{ url: string; jwt: string }> {
const response = await fetch(`${sfuServiceUrl}/sfu/get`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
room: roomId,
openid_token: openidToken,
device_id: deviceId,
}),
});
if (!response.ok) {
throw new Error(`SFU JWT exchange failed: ${response.status} ${response.statusText}`);
}
const body = await response.json();
if (!body.jwt || !body.url) {
throw new Error("SFU response missing jwt or url fields");
}
return { url: body.url, jwt: body.jwt };
}
export function useDocumentRTC(
matrixRoom: MatrixRoom,
client: MatrixClient,
): UseDocumentRTCResult {
const [isConnected, setIsConnected] = useState(false);
const livekitRoomRef = useRef<LivekitRoom | null>(null);
const onDeltaRef = useRef<((bytes: Uint8Array) => void) | null>(null);
useEffect(() => {
let cancelled = false;
let livekitRoom: LivekitRoom | null = null;
const connect = async (): Promise<void> => {
// 1. Get available transports from the homeserver.
let transports;
try {
transports = await client._unstable_getRTCTransports();
} catch (e) {
logger.info("[DocumentRTC] Homeserver does not support /rtc/transports, falling back to Matrix events", e);
return;
}
const livekitTransport = transports.find(isLivekitTransportConfig);
if (!livekitTransport) {
logger.info("[DocumentRTC] No LiveKit transport in /rtc/transports, falling back to Matrix events");
return;
}
const sfuServiceUrl = livekitTransport.livekit_service_url;
// 2. Get an OpenID token and exchange it for a LiveKit JWT.
let livekitCreds: { url: string; jwt: string };
try {
const openidToken = await client.getOpenIdToken();
livekitCreds = await getLivekitJwt(
sfuServiceUrl,
matrixRoom.roomId,
openidToken as Parameters<typeof getLivekitJwt>[2],
client.getDeviceId() ?? "unknown",
);
} catch (e) {
logger.warn("[DocumentRTC] Failed to get LiveKit credentials", e);
return;
}
if (cancelled) return;
// 3. Connect to LiveKit room (data-only: no camera/mic).
livekitRoom = new LivekitRoom();
livekitRoomRef.current = livekitRoom;
livekitRoom.on(RoomEvent.Connected, () => {
if (!cancelled) {
logger.info("[DocumentRTC] Connected to LiveKit room");
setIsConnected(true);
}
});
livekitRoom.on(RoomEvent.Disconnected, () => {
logger.info("[DocumentRTC] Disconnected from LiveKit room");
setIsConnected(false);
});
livekitRoom.on(RoomEvent.Reconnecting, () => {
logger.info("[DocumentRTC] Reconnecting to LiveKit room…");
setIsConnected(false);
});
livekitRoom.on(RoomEvent.Reconnected, () => {
if (!cancelled) {
logger.info("[DocumentRTC] Reconnected to LiveKit room");
setIsConnected(true);
}
});
// 4. Receive Automerge deltas from peers.
livekitRoom.on(
RoomEvent.DataReceived,
(payload: Uint8Array, _participant: unknown, _kind: unknown, topic?: string) => {
if (topic !== LIVEKIT_DATA_TOPIC) return;
onDeltaRef.current?.(payload);
},
);
try {
await livekitRoom.connect(livekitCreds.url, livekitCreds.jwt, {
// Data-only: don't auto-subscribe to audio/video tracks.
autoSubscribe: false,
});
logger.info("[DocumentRTC] LiveKit connect() resolved");
} catch (e) {
logger.warn("[DocumentRTC] Failed to connect to LiveKit room", e);
setIsConnected(false);
}
};
connect().catch((e) => logger.error("[DocumentRTC] Unexpected error in connect()", e));
return () => {
cancelled = true;
if (livekitRoomRef.current) {
logger.info("[DocumentRTC] Disconnecting from LiveKit room (unmount)");
livekitRoomRef.current.disconnect().catch(() => {});
livekitRoomRef.current = null;
}
setIsConnected(false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [matrixRoom.roomId]); // reconnect only if room changes
const publishDelta = useCallback((bytes: Uint8Array): void => {
const room = livekitRoomRef.current;
if (!room || room.state !== ConnectionState.Connected) return;
room.localParticipant
.publishData(bytes, { reliable: true, topic: LIVEKIT_DATA_TOPIC })
.catch((e: unknown) => logger.warn("[DocumentRTC] Failed to publish delta", e));
}, []);
return { publishDelta, onDeltaRef, isConnected };
}

80
pnpm-lock.yaml generated
View File

@ -272,6 +272,9 @@ importers:
linkifyjs:
specifier: 4.3.2
version: 4.3.2
livekit-client:
specifier: ^2.17.2
version: 2.17.2(@types/dom-mediacapture-record@1.0.22)
lodash:
specifier: npm:lodash-es@^4.17.21
version: lodash-es@4.17.23
@ -1675,6 +1678,9 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@bufbuild/protobuf@1.10.1':
resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==}
'@cacheable/memory@2.0.8':
resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==}
@ -2643,6 +2649,12 @@ packages:
peerDependencies:
'@types/react': ^19.2.10
'@livekit/mutex@1.1.1':
resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==}
'@livekit/protocol@1.44.0':
resolution: {integrity: sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==}
'@mapbox/geojson-rewind@0.5.2':
resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==}
hasBin: true
@ -4165,6 +4177,9 @@ packages:
'@types/doctrine@0.0.9':
resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==}
'@types/dom-mediacapture-record@1.0.22':
resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==}
'@types/escape-html@1.0.4':
resolution: {integrity: sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg==}
@ -7525,6 +7540,9 @@ packages:
resolution: {integrity: sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==}
engines: {node: '>= 20'}
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
@ -7709,6 +7727,11 @@ packages:
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
engines: {node: '>=20.0.0'}
livekit-client@2.17.2:
resolution: {integrity: sha512-+67y2EtAWZabARlY7kANl/VT1Uu1EJYR5a8qwpT2ub/uBCltsEgEDOxCIMwE9HFR5w+z41HR6GL9hyEvW/y6CQ==}
peerDependencies:
'@types/dom-mediacapture-record': ^1
loader-runner@4.3.1:
resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
engines: {node: '>=6.11.5'}
@ -9406,10 +9429,17 @@ packages:
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
engines: {node: '>= 10.13.0'}
sdp-transform@2.15.0:
resolution: {integrity: sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==}
hasBin: true
sdp-transform@3.0.0:
resolution: {integrity: sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==}
hasBin: true
sdp@3.2.1:
resolution: {integrity: sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==}
seedrandom@3.0.5:
resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==}
@ -10031,6 +10061,9 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
ts-debounce@4.0.0:
resolution: {integrity: sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==}
ts-dedent@2.2.0:
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
engines: {node: '>=6.10'}
@ -10118,6 +10151,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typed-emitter@2.1.0:
resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==}
typedoc-plugin-markdown@4.10.0:
resolution: {integrity: sha512-psrg8Rtnv4HPWCsoxId+MzEN8TVK5jeKCnTbnGAbTBqcDapR9hM41bJT/9eAyKn9C2MDG9Qjh3MkltAYuLDoXg==}
engines: {node: '>= 18'}
@ -10567,6 +10603,10 @@ packages:
webpack-cli:
optional: true
webrtc-adapter@9.0.4:
resolution: {integrity: sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==}
engines: {node: '>=6.0.0', npm: '>=3.10.0'}
websocket-driver@0.7.4:
resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==}
engines: {node: '>=0.8.0'}
@ -11666,6 +11706,8 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@bufbuild/protobuf@1.10.1': {}
'@cacheable/memory@2.0.8':
dependencies:
'@cacheable/utils': 2.4.0
@ -12705,6 +12747,12 @@ snapshots:
dependencies:
'@types/react': 19.2.10
'@livekit/mutex@1.1.1': {}
'@livekit/protocol@1.44.0':
dependencies:
'@bufbuild/protobuf': 1.10.1
'@mapbox/geojson-rewind@0.5.2':
dependencies:
get-stream: 6.0.1
@ -14324,6 +14372,8 @@ snapshots:
'@types/doctrine@0.0.9': {}
'@types/dom-mediacapture-record@1.0.22': {}
'@types/escape-html@1.0.4': {}
'@types/eslint-scope@3.7.7':
@ -18417,6 +18467,8 @@ snapshots:
'@hapi/topo': 6.0.2
'@standard-schema/spec': 1.1.0
jose@6.1.3: {}
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
@ -18616,6 +18668,20 @@ snapshots:
rfdc: 1.4.1
wrap-ansi: 10.0.0
livekit-client@2.17.2(@types/dom-mediacapture-record@1.0.22):
dependencies:
'@livekit/mutex': 1.1.1
'@livekit/protocol': 1.44.0
'@types/dom-mediacapture-record': 1.0.22
events: 3.3.0
jose: 6.1.3
loglevel: 1.9.2
sdp-transform: 2.15.0
ts-debounce: 4.0.0
tslib: 2.8.1
typed-emitter: 2.1.0
webrtc-adapter: 9.0.4
loader-runner@4.3.1: {}
loader-utils@2.0.4:
@ -20544,8 +20610,12 @@ snapshots:
ajv-formats: 2.1.1(ajv@8.18.0)
ajv-keywords: 5.1.0(ajv@8.18.0)
sdp-transform@2.15.0: {}
sdp-transform@3.0.0: {}
sdp@3.2.1: {}
seedrandom@3.0.5: {}
select-hose@2.0.0: {}
@ -21378,6 +21448,8 @@ snapshots:
dependencies:
typescript: 5.9.3
ts-debounce@4.0.0: {}
ts-dedent@2.2.0: {}
tsconfig-paths@3.15.0:
@ -21478,6 +21550,10 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typed-emitter@2.1.0:
optionalDependencies:
rxjs: 7.8.2
typedoc-plugin-markdown@4.10.0(typedoc@0.28.17(typescript@5.9.3)):
dependencies:
typedoc: 0.28.17(typescript@5.9.3)
@ -22021,6 +22097,10 @@ snapshots:
- esbuild
- uglify-js
webrtc-adapter@9.0.4:
dependencies:
sdp: 3.2.1
websocket-driver@0.7.4:
dependencies:
http-parser-js: 0.5.10