From 0e535724cb8104b4344f817568f8ee432a349ce7 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 4 Mar 2026 22:08:29 +0000 Subject: [PATCH] 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> --- apps/web/package.json | 1 + .../rooms/wysiwyg_composer/DocumentView.tsx | 130 ++++++++---- .../rooms/wysiwyg_composer/useDocumentRTC.ts | 192 ++++++++++++++++++ pnpm-lock.yaml | 80 ++++++++ 4 files changed, 359 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/components/views/rooms/wysiwyg_composer/useDocumentRTC.ts diff --git a/apps/web/package.json b/apps/web/package.json index db03aa6557..cea68317f6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx b/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx index 55ada23b30..ca0b2224f3 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx @@ -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, 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 diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/useDocumentRTC.ts b/apps/web/src/components/views/rooms/wysiwyg_composer/useDocumentRTC.ts new file mode 100644 index 0000000000..d3ee741f16 --- /dev/null +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/useDocumentRTC.ts @@ -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(null); + const onDeltaRef = useRef<((bytes: Uint8Array) => void) | null>(null); + + useEffect(() => { + let cancelled = false; + let livekitRoom: LivekitRoom | null = null; + + const connect = async (): Promise => { + // 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[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 }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7b7e4145c..f42e3e8c53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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