From f647f8453b417d5b8ccff865f9cee507368a0844 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Mar 2026 12:01:00 -0400 Subject: [PATCH 01/54] feat: add client protocol tracking to remote participants This is so that this client protocol value can be used to know what version of RPC that a remote client supports. --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/room/Room.ts | 7 ++++++- src/room/participant/LocalParticipant.ts | 7 +++++++ src/room/participant/RemoteParticipant.ts | 10 ++++++++++ 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 02c97a227f..f7de11dbc6 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ }, "dependencies": { "@livekit/mutex": "1.1.1", - "@livekit/protocol": "1.44.0", + "@livekit/protocol": "1.45.0", "events": "^3.3.0", "jose": "^6.1.0", "loglevel": "^1.9.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a968f6b2f..e1400fe9c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: 1.1.1 version: 1.1.1 '@livekit/protocol': - specifier: 1.44.0 - version: 1.44.0 + specifier: 1.45.0 + version: 1.45.0 '@types/dom-mediacapture-record': specifier: ^1 version: 1.0.22 @@ -1128,8 +1128,8 @@ packages: '@livekit/mutex@1.1.1': resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} - '@livekit/protocol@1.44.0': - resolution: {integrity: sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==} + '@livekit/protocol@1.45.0': + resolution: {integrity: sha512-z22Ej7RRBFm5uVZpU7kBHOdDwZV6Hz+1crCOrse2g7yx8TcHXG0bKnOKwyN/meD233nEDlU2IHNCoT8Vq8lvtg==} '@livekit/throws-transformer@0.1.3': resolution: {integrity: sha512-PBttE6W6g/2ALGu6kWOunZ5qdrXwP9Ge1An2/62OfE6Rhc0Abd4yp6ex2pWhwUfGxDsSZvFgoB1Ia/5mWAMuKQ==} @@ -5279,7 +5279,7 @@ snapshots: '@livekit/mutex@1.1.1': {} - '@livekit/protocol@1.44.0': + '@livekit/protocol@1.45.0': dependencies: '@bufbuild/protobuf': 1.10.1 diff --git a/src/room/Room.ts b/src/room/Room.ts index 8183ae0a22..1439905ac2 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -77,7 +77,7 @@ import { EngineEvent, ParticipantEvent, RoomEvent, TrackEvent } from './events'; import LocalParticipant from './participant/LocalParticipant'; import Participant from './participant/Participant'; import { type ConnectionQuality, ParticipantKind } from './participant/Participant'; -import RemoteParticipant from './participant/RemoteParticipant'; +import RemoteParticipant, { DEFAULT_CLIENT_PROTOCOL } from './participant/RemoteParticipant'; import { MAX_PAYLOAD_BYTES, RpcError, type RpcInvocationData, byteLength } from './rpc'; import CriticalTimers from './timers'; import LocalAudioTrack from './track/LocalAudioTrack'; @@ -302,6 +302,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.rpcHandlers, this.outgoingDataStreamManager, this.outgoingDataTrackManager, + this.getRemoteParticipantClientProtocol, ); if (this.options.e2ee || this.options.encryption) { @@ -2412,6 +2413,10 @@ class Room extends (EventEmitter as new () => TypedEmitter) } } + private getRemoteParticipantClientProtocol(identity: Participant["identity"]) { + return this.remoteParticipants.get(identity)?.clientProtocol ?? DEFAULT_CLIENT_PROTOCOL; + } + private registerConnectionReconcile() { this.clearConnectionReconcile(); let consecutiveFailures = 0; diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index b7dd493d60..ef24d93fcb 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -178,6 +178,8 @@ export default class LocalParticipant extends Participant { } >(); + private getRemoteParticipantClientProtocol: (identity: Participant["identity"]) => number; + /** @internal */ constructor( sid: string, @@ -187,6 +189,7 @@ export default class LocalParticipant extends Participant { roomRpcHandlers: Map Promise>, roomOutgoingDataStreamManager: OutgoingDataStreamManager, roomOutgoingDataTrackManager: OutgoingDataTrackManager, + getRemoteParticipantClientProtocol: (identity: Participant["identity"]) => number, ) { super(sid, identity, undefined, undefined, undefined, { loggerName: options.loggerName, @@ -207,6 +210,7 @@ export default class LocalParticipant extends Participant { this.rpcHandlers = roomRpcHandlers; this.roomOutgoingDataStreamManager = roomOutgoingDataStreamManager; this.roomOutgoingDataTrackManager = roomOutgoingDataTrackManager; + this.getRemoteParticipantClientProtocol = getRemoteParticipantClientProtocol; } get lastCameraError(): Error | undefined { @@ -1849,6 +1853,9 @@ export default class LocalParticipant extends Participant { const effectiveTimeout = Math.max(responseTimeout, minEffectiveTimeout); const id = crypto.randomUUID(); + + const remoteClientProtocol = this.getRemoteParticipantClientProtocol(destinationIdentity); + // FIXME: use remoteClientProtocol await this.publishRpcRequest(destinationIdentity, id, method, payload, effectiveTimeout); const ackTimeoutId = setTimeout(() => { diff --git a/src/room/participant/RemoteParticipant.ts b/src/room/participant/RemoteParticipant.ts index 8c9b8436dd..06d380c23d 100644 --- a/src/room/participant/RemoteParticipant.ts +++ b/src/room/participant/RemoteParticipant.ts @@ -22,6 +22,8 @@ import { isAudioTrack, isRemoteTrack } from '../utils'; import Participant, { ParticipantKind } from './Participant'; import type { ParticipantEventCallbacks } from './Participant'; +export const DEFAULT_CLIENT_PROTOCOL = 0; + export default class RemoteParticipant extends Participant { audioTrackPublications: Map; @@ -39,6 +41,11 @@ export default class RemoteParticipant extends Participant { signalClient: SignalClient; + /** A version number indicating the set of features that the report participant's client supports. + * @internal + **/ + clientProtocol: number; + private volumeMap: Map; private audioOutput?: AudioOutputOptions; @@ -58,6 +65,7 @@ export default class RemoteParticipant extends Participant { pi.attributes, loggerOptions, pi.kind, + pi.clientProtocol, ); } @@ -79,6 +87,7 @@ export default class RemoteParticipant extends Participant { attributes?: Record, loggerOptions?: LoggerOptions, kind: ParticipantKind = ParticipantKind.STANDARD, + clientProtocol: number = DEFAULT_CLIENT_PROTOCOL, ) { super(sid, identity || '', name, metadata, attributes, loggerOptions, kind); this.signalClient = signalClient; @@ -87,6 +96,7 @@ export default class RemoteParticipant extends Participant { this.videoTrackPublications = new Map(); this.dataTracks = new DeferrableMap(); this.volumeMap = new Map(); + this.clientProtocol = clientProtocol; } protected addTrackPublication(publication: RemoteTrackPublication) { From 44568bc6b024670a5c84d26dd230c8387ec684d7 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Mar 2026 12:07:07 -0400 Subject: [PATCH 02/54] feat: add client protocol advertisement code, only advertise client protocol "0" for now though --- src/room/Room.ts | 5 +++-- src/room/participant/RemoteParticipant.ts | 5 ++--- src/room/utils.ts | 4 +++- src/version.ts | 7 +++++++ 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index 1439905ac2..1fb5719474 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -77,7 +77,7 @@ import { EngineEvent, ParticipantEvent, RoomEvent, TrackEvent } from './events'; import LocalParticipant from './participant/LocalParticipant'; import Participant from './participant/Participant'; import { type ConnectionQuality, ParticipantKind } from './participant/Participant'; -import RemoteParticipant, { DEFAULT_CLIENT_PROTOCOL } from './participant/RemoteParticipant'; +import RemoteParticipant from './participant/RemoteParticipant'; import { MAX_PAYLOAD_BYTES, RpcError, type RpcInvocationData, byteLength } from './rpc'; import CriticalTimers from './timers'; import LocalAudioTrack from './track/LocalAudioTrack'; @@ -119,6 +119,7 @@ import { unpackStreamId, unwrapConstraint, } from './utils'; +import { CLIENT_PROTOCOL_DEFAULT } from '../version'; export enum ConnectionState { Disconnected = 'disconnected', @@ -2414,7 +2415,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) } private getRemoteParticipantClientProtocol(identity: Participant["identity"]) { - return this.remoteParticipants.get(identity)?.clientProtocol ?? DEFAULT_CLIENT_PROTOCOL; + return this.remoteParticipants.get(identity)?.clientProtocol ?? CLIENT_PROTOCOL_DEFAULT; } private registerConnectionReconcile() { diff --git a/src/room/participant/RemoteParticipant.ts b/src/room/participant/RemoteParticipant.ts index 06d380c23d..1a3c1e2213 100644 --- a/src/room/participant/RemoteParticipant.ts +++ b/src/room/participant/RemoteParticipant.ts @@ -21,8 +21,7 @@ import type { LoggerOptions } from '../types'; import { isAudioTrack, isRemoteTrack } from '../utils'; import Participant, { ParticipantKind } from './Participant'; import type { ParticipantEventCallbacks } from './Participant'; - -export const DEFAULT_CLIENT_PROTOCOL = 0; +import { CLIENT_PROTOCOL_DEFAULT } from '../../version'; export default class RemoteParticipant extends Participant { audioTrackPublications: Map; @@ -87,7 +86,7 @@ export default class RemoteParticipant extends Participant { attributes?: Record, loggerOptions?: LoggerOptions, kind: ParticipantKind = ParticipantKind.STANDARD, - clientProtocol: number = DEFAULT_CLIENT_PROTOCOL, + clientProtocol: number = CLIENT_PROTOCOL_DEFAULT, ) { super(sid, identity || '', name, metadata, attributes, loggerOptions, kind); this.signalClient = signalClient; diff --git a/src/room/utils.ts b/src/room/utils.ts index 49df9b6fca..79905aff9c 100644 --- a/src/room/utils.ts +++ b/src/room/utils.ts @@ -9,7 +9,8 @@ import { type Throws } from '@livekit/throws-transformer/throws'; import TypedPromise from '../utils/TypedPromise'; import { getBrowser } from '../utils/browserParser'; import type { BrowserDetails } from '../utils/browserParser'; -import { protocolVersion, version } from '../version'; +import { type Throws } from '../utils/throws'; +import { clientProtocol, protocolVersion, version } from '../version'; import { type ConnectionError, ConnectionErrorReason } from './errors'; import type LocalParticipant from './participant/LocalParticipant'; import type Participant from './participant/Participant'; @@ -368,6 +369,7 @@ export function getClientInfo(): ClientInfo { const info = new ClientInfo({ sdk: ClientInfo_SDK.JS, protocol: protocolVersion, + clientProtocol, version, }); diff --git a/src/version.ts b/src/version.ts index e0d96ee71b..f50f881a4a 100644 --- a/src/version.ts +++ b/src/version.ts @@ -2,3 +2,10 @@ import { version as v } from '../package.json'; export const version = v; export const protocolVersion = 16; + +export const CLIENT_PROTOCOL_DEFAULT = 0; +// const CLIENT_PROTOCOL_GZIP_RPC = 1; + +/** The client protocol version indicates what level of support that the client has for + * client <-> client api interactions. */ +export const clientProtocol = CLIENT_PROTOCOL_DEFAULT; From 46217926adf27b317a236a226fceccccb5bf4b9b Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Mar 2026 14:35:43 -0400 Subject: [PATCH 03/54] feat: add initial first implementation pass --- src/room/RTCEngine.ts | 21 +++ src/room/Room.ts | 160 +++++++++++++++++++++-- src/room/participant/LocalParticipant.ts | 94 ++++++++++++- src/room/rpc.test.ts | 7 + src/room/rpc.ts | 82 +++++++++++- src/version.ts | 4 +- 6 files changed, 350 insertions(+), 18 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 69f9023552..f8354571d9 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1415,6 +1415,27 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit await this.sendDataPacket(packet, DataChannelKind.RELIABLE); } + /** @internal */ + async publishRpcResponseCompressed( + destinationIdentity: string, + requestId: string, + compressedPayload: Uint8Array, + ) { + const packet = new DataPacket({ + destinationIdentities: [destinationIdentity], + kind: DataPacket_Kind.RELIABLE, + value: { + case: 'rpcResponse', + value: new RpcResponse({ + requestId, + value: { case: 'compressedPayload', value: compressedPayload }, + }), + }, + }); + + await this.sendDataPacket(packet, DataPacket_Kind.RELIABLE); + } + /** @internal */ async publishRpcAck(destinationIdentity: string, requestId: string) { const packet = new DataPacket({ diff --git a/src/room/Room.ts b/src/room/Room.ts index 1fb5719474..a4c3d103ee 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -78,7 +78,18 @@ import LocalParticipant from './participant/LocalParticipant'; import Participant from './participant/Participant'; import { type ConnectionQuality, ParticipantKind } from './participant/Participant'; import RemoteParticipant from './participant/RemoteParticipant'; -import { MAX_PAYLOAD_BYTES, RpcError, type RpcInvocationData, byteLength } from './rpc'; +import { + COMPRESS_MIN_BYTES, + DATA_STREAM_MIN_BYTES, + DATA_STREAM_PREFIX, + MAX_PAYLOAD_BYTES, + RPC_DATA_STREAM_TOPIC, + RpcError, + type RpcInvocationData, + byteLength, + gzipCompress, + gzipDecompress, +} from './rpc'; import CriticalTimers from './timers'; import LocalAudioTrack from './track/LocalAudioTrack'; import type LocalTrack from './track/LocalTrack'; @@ -217,6 +228,8 @@ class Room extends (EventEmitter as new () => TypedEmitter) private rpcHandlers: Map Promise> = new Map(); + private pendingRpcDataStreams: Map> = new Map(); + get hasE2EESetup(): boolean { return this.e2eeManager !== undefined; } @@ -293,6 +306,8 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.engine.sendLossyBytes(bytes, DataChannelKind.DATA_TRACK_LOSSY, 'wait'); }); + this.registerRpcDataStreamHandler(); + this.disconnectLock = new Mutex(); this.localParticipant = new LocalParticipant( @@ -304,6 +319,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.outgoingDataStreamManager, this.outgoingDataTrackManager, this.getRemoteParticipantClientProtocol, + this.waitForRpcDataStream, ); if (this.options.e2ee || this.options.encryption) { @@ -1946,6 +1962,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) rpc.id, rpc.method, rpc.payload, + rpc.compressedPayload, rpc.responseTimeoutMs, rpc.version, ); @@ -2017,6 +2034,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) requestId: string, method: string, payload: string, + compressedPayload: Uint8Array, responseTimeout: number, version: number, ) { @@ -2032,6 +2050,37 @@ class Room extends (EventEmitter as new () => TypedEmitter) return; } + // Resolve the actual payload from compressed or data stream sources + let resolvedPayload = payload; + if (compressedPayload && compressedPayload.length > 0) { + try { + resolvedPayload = await gzipDecompress(compressedPayload); + } catch (e) { + this.log.error('Failed to decompress RPC request payload', e); + await this.engine.publishRpcResponse( + callerIdentity, + requestId, + null, + RpcError.builtIn('APPLICATION_ERROR'), + ); + return; + } + } else if (payload.startsWith(DATA_STREAM_PREFIX)) { + const streamId = payload.slice(DATA_STREAM_PREFIX.length); + try { + resolvedPayload = await this.waitForRpcDataStream(streamId); + } catch (e) { + this.log.error('Failed to receive RPC data stream payload', e); + await this.engine.publishRpcResponse( + callerIdentity, + requestId, + null, + RpcError.builtIn('APPLICATION_ERROR'), + ); + return; + } + } + const handler = this.rpcHandlers.get(method); if (!handler) { @@ -2051,15 +2100,10 @@ class Room extends (EventEmitter as new () => TypedEmitter) const response = await handler({ requestId, callerIdentity, - payload, + payload: resolvedPayload, responseTimeout, }); - if (byteLength(response) > MAX_PAYLOAD_BYTES) { - responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); - this.log.warn(`RPC Response payload too large for ${method}`); - } else { - responsePayload = response; - } + responsePayload = response; } catch (error) { if (error instanceof RpcError) { responseError = error; @@ -2071,7 +2115,51 @@ class Room extends (EventEmitter as new () => TypedEmitter) responseError = RpcError.builtIn('APPLICATION_ERROR'); } } - await this.engine.publishRpcResponse(callerIdentity, requestId, responsePayload, responseError); + + // Determine how to send the response based on the caller's client protocol + const callerClientProtocol = this.getRemoteParticipantClientProtocol(callerIdentity); + + if (responseError) { + await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + return; + } + + const responseBytes = byteLength(responsePayload ?? ''); + + if (callerClientProtocol >= 1 && responseBytes >= DATA_STREAM_MIN_BYTES) { + // Large response: send via data stream + const streamId = crypto.randomUUID(); + const compressed = await gzipCompress(responsePayload!); + + const writer = await this.outgoingDataStreamManager.streamBytes({ + streamId, + topic: RPC_DATA_STREAM_TOPIC, + destinationIdentities: [callerIdentity], + mimeType: 'application/octet-stream', + totalSize: compressed.byteLength, + }); + await writer.write(compressed); + await writer.close(); + + await this.engine.publishRpcResponse( + callerIdentity, + requestId, + `${DATA_STREAM_PREFIX}${streamId}`, + null, + ); + } else if (callerClientProtocol >= 1 && responseBytes >= COMPRESS_MIN_BYTES) { + // Medium response: compress inline + const compressed = await gzipCompress(responsePayload!); + await this.engine.publishRpcResponseCompressed(callerIdentity, requestId, compressed); + } else if (responseBytes > MAX_PAYLOAD_BYTES) { + // Legacy client can't handle large payloads + responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); + this.log.warn(`RPC Response payload too large for ${method}`); + await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + } else { + // Small response or legacy client: send uncompressed + await this.engine.publishRpcResponse(callerIdentity, requestId, responsePayload, null); + } } bufferedSegments: Map = new Map(); @@ -2418,6 +2506,60 @@ class Room extends (EventEmitter as new () => TypedEmitter) return this.remoteParticipants.get(identity)?.clientProtocol ?? CLIENT_PROTOCOL_DEFAULT; } + private registerRpcDataStreamHandler() { + this.incomingDataStreamManager.registerByteStreamHandler( + RPC_DATA_STREAM_TOPIC, + async (reader, _participantInfo) => { + const streamId = reader.info.id; + const chunks = await reader.readAll(); + + // Concatenate all chunks into a single Uint8Array + const totalLength = chunks.reduce((sum, c) => sum + c.length, 0); + const combined = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + + // Decompress the data + let decompressed: string; + try { + decompressed = await gzipDecompress(combined); + } catch (e) { + const future = this.pendingRpcDataStreams.get(streamId); + if (future) { + future.reject?.(e instanceof Error ? e : new Error(String(e))); + this.pendingRpcDataStreams.delete(streamId); + } + return; + } + + // Resolve the pending future, or create one that's pre-resolved + const existing = this.pendingRpcDataStreams.get(streamId); + if (existing) { + existing.resolve?.(decompressed); + this.pendingRpcDataStreams.delete(streamId); + } else { + const future = new Future(); + future.resolve?.(decompressed); + this.pendingRpcDataStreams.set(streamId, future); + } + }, + ); + } + + private waitForRpcDataStream = (streamId: string): Promise => { + const existing = this.pendingRpcDataStreams.get(streamId); + if (existing) { + return existing.promise; + } + + const future = new Future(); + this.pendingRpcDataStreams.set(streamId, future); + return future.promise; + }; + private registerConnectionReconcile() { this.clearConnectionReconcile(); let consecutiveFailures = 0; diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index ef24d93fcb..c8303eff00 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -45,11 +45,17 @@ import { } from '../errors'; import { EngineEvent, ParticipantEvent, TrackEvent } from '../events'; import { + COMPRESS_MIN_BYTES, + DATA_STREAM_MIN_BYTES, + DATA_STREAM_PREFIX, MAX_PAYLOAD_BYTES, + RPC_DATA_STREAM_TOPIC, type PerformRpcParams, RpcError, type RpcInvocationData, byteLength, + gzipCompress, + gzipDecompress, } from '../rpc'; import LocalAudioTrack from '../track/LocalAudioTrack'; import LocalTrack from '../track/LocalTrack'; @@ -180,6 +186,8 @@ export default class LocalParticipant extends Participant { private getRemoteParticipantClientProtocol: (identity: Participant["identity"]) => number; + private waitForRpcDataStream: (streamId: string) => Promise; + /** @internal */ constructor( sid: string, @@ -190,6 +198,7 @@ export default class LocalParticipant extends Participant { roomOutgoingDataStreamManager: OutgoingDataStreamManager, roomOutgoingDataTrackManager: OutgoingDataTrackManager, getRemoteParticipantClientProtocol: (identity: Participant["identity"]) => number, + waitForRpcDataStream: (streamId: string) => Promise, ) { super(sid, identity, undefined, undefined, undefined, { loggerName: options.loggerName, @@ -211,6 +220,7 @@ export default class LocalParticipant extends Participant { this.roomOutgoingDataStreamManager = roomOutgoingDataStreamManager; this.roomOutgoingDataTrackManager = roomOutgoingDataTrackManager; this.getRemoteParticipantClientProtocol = getRemoteParticipantClientProtocol; + this.waitForRpcDataStream = waitForRpcDataStream; } get lastCameraError(): Error | undefined { @@ -355,7 +365,7 @@ export default class LocalParticipant extends Participant { } }; - private handleDataPacket = (packet: DataPacket) => { + private handleDataPacket = async (packet: DataPacket) => { switch (packet.value.case) { case 'rpcResponse': let rpcResponse = packet.value.value as RpcResponse; @@ -366,7 +376,27 @@ export default class LocalParticipant extends Participant { payload = rpcResponse.value.value; } else if (rpcResponse.value.case === 'error') { error = RpcError.fromProto(rpcResponse.value.value); + } else if (rpcResponse.value.case === 'compressedPayload') { + try { + payload = await gzipDecompress(rpcResponse.value.value); + } catch (e) { + this.log.error('Failed to decompress RPC response', e); + error = RpcError.builtIn('APPLICATION_ERROR'); + } + } + + // Handle data stream payload + if (payload && payload.startsWith(DATA_STREAM_PREFIX)) { + const streamId = payload.slice(DATA_STREAM_PREFIX.length); + try { + payload = await this.waitForRpcDataStream(streamId); + } catch (e) { + this.log.error('Failed to receive RPC data stream response', e); + error = RpcError.builtIn('APPLICATION_ERROR'); + payload = null; + } } + this.handleIncomingRpcResponse(rpcResponse.requestId, payload, error); break; case 'rpcAck': @@ -1838,7 +1868,11 @@ export default class LocalParticipant extends Participant { const minEffectiveTimeout = maxRoundTripLatency + 1000; return new TypedPromise(async (resolve, reject) => { - if (byteLength(payload) > MAX_PAYLOAD_BYTES) { + const remoteClientProtocol = this.getRemoteParticipantClientProtocol(destinationIdentity); + const payloadBytes = byteLength(payload); + + // Only enforce the legacy size limit when compression is not available + if (payloadBytes > MAX_PAYLOAD_BYTES && remoteClientProtocol < 1) { reject(RpcError.builtIn('REQUEST_PAYLOAD_TOO_LARGE')); return; } @@ -1854,9 +1888,14 @@ export default class LocalParticipant extends Participant { const effectiveTimeout = Math.max(responseTimeout, minEffectiveTimeout); const id = crypto.randomUUID(); - const remoteClientProtocol = this.getRemoteParticipantClientProtocol(destinationIdentity); - // FIXME: use remoteClientProtocol - await this.publishRpcRequest(destinationIdentity, id, method, payload, effectiveTimeout); + await this.publishRpcRequest( + destinationIdentity, + id, + method, + payload, + effectiveTimeout, + remoteClientProtocol, + ); const ackTimeoutId = setTimeout(() => { this.pendingAcks.delete(id); @@ -1976,7 +2015,49 @@ export default class LocalParticipant extends Participant { method: string, payload: string, responseTimeout: number, + remoteClientProtocol: number, ) { + const payloadBytes = byteLength(payload); + + let mode: 'regular' | 'compressed' | 'compressed-data-stream' = 'regular'; + if (remoteClientProtocol >= 1 && payloadBytes >= COMPRESS_MIN_BYTES) { + mode = 'compressed'; + } + if (mode === 'compressed' && payloadBytes >= DATA_STREAM_MIN_BYTES) { + mode = 'compressed-data-stream'; + } + + let requestPayload = payload; + let requestCompressedPayload: Uint8Array | undefined; + + switch (mode) { + case 'compressed-data-stream': + // Large payload: send via data stream + const streamId = crypto.randomUUID(); + const compressed = await gzipCompress(payload); + + // Create and send the data stream before the RPC request + const writer = await this.streamBytes({ + streamId, + topic: RPC_DATA_STREAM_TOPIC, + destinationIdentities: [destinationIdentity], + mimeType: 'application/octet-stream', + totalSize: compressed.byteLength, + }); + await writer.write(compressed); + await writer.close(); + + requestPayload = `${DATA_STREAM_PREFIX}${streamId}`; + requestCompressedPayload = undefined; + break; + + case 'compressed': + // Medium payload: compress inline + requestCompressedPayload = await gzipCompress(payload); + requestPayload = ''; + break; + } + const packet = new DataPacket({ destinationIdentities: [destinationIdentity], kind: DataPacket_Kind.RELIABLE, @@ -1985,7 +2066,8 @@ export default class LocalParticipant extends Participant { value: new RpcRequest({ id: requestId, method, - payload, + payload: requestPayload, + compressedPayload: requestCompressedPayload ?? new Uint8Array(), responseTimeoutMs: responseTimeout, version: 1, }), diff --git a/src/room/rpc.test.ts b/src/room/rpc.test.ts index 5ac3d78606..bda0a2c712 100644 --- a/src/room/rpc.test.ts +++ b/src/room/rpc.test.ts @@ -49,6 +49,7 @@ describe('LocalParticipant', () => { 'test-request-id', methodName, 'test payload', + new Uint8Array(), 5000, 1, ); @@ -95,6 +96,7 @@ describe('LocalParticipant', () => { 'test-error-request-id', methodName, 'test payload', + new Uint8Array(), 5000, 1, ); @@ -138,6 +140,7 @@ describe('LocalParticipant', () => { 'test-rpc-error-request-id', methodName, 'test payload', + new Uint8Array(), 5000, 1, ); @@ -183,6 +186,10 @@ describe('LocalParticipant', () => { 'local-identity', mockEngine, mockRoomOptions, + new Map(), + {} as any, + () => 0, + () => Promise.resolve(''), ); mockRemoteParticipant = new RemoteParticipant( diff --git a/src/room/rpc.ts b/src/room/rpc.ts index 60a6dfc8c8..5cc2e51296 100644 --- a/src/room/rpc.ts +++ b/src/room/rpc.ts @@ -139,11 +139,91 @@ export class RpcError extends Error { } /* - * Maximum payload size for RPC requests and responses. If a payload exceeds this size, + * Maximum payload size for RPC requests and responses when using the legacy (uncompressed) path. + * If a payload exceeds this size and the remote client does not support compression, * the RPC call will fail with a REQUEST_PAYLOAD_TOO_LARGE(1402) or RESPONSE_PAYLOAD_TOO_LARGE(1504) error. */ export const MAX_PAYLOAD_BYTES = 15360; // 15 KB +/** + * Payloads smaller than this are sent uncompressed (legacy path). + * @internal + */ +export const COMPRESS_MIN_BYTES = 1024; // 1 KB + +/** + * Payloads at or above this size are sent via a data stream instead of inline. + * @internal + */ +export const DATA_STREAM_MIN_BYTES = 15360; // 15 KB + +/** + * Prefix used in the payload field to indicate data is arriving via a data stream. + * @internal + */ +export const DATA_STREAM_PREFIX = 'data_stream:'; + +/** + * Topic used for RPC payload data streams. + * @internal + */ +export const RPC_DATA_STREAM_TOPIC = '_lk_rpc'; + +/** + * Compress a string payload using gzip. + * @internal + */ +export async function gzipCompress(data: string): Promise { + const input = new TextEncoder().encode(data); + const cs = new CompressionStream('gzip'); + const writer = cs.writable.getWriter(); + writer.write(input); + writer.close(); + + const reader = cs.readable.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + chunks.push(value); + } + + const totalLength = chunks.reduce((sum, c) => sum + c.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} + +/** + * Decompress a gzip-compressed payload back to a string. + * @internal + */ +export async function gzipDecompress(data: Uint8Array): Promise { + const ds = new DecompressionStream('gzip'); + const writer = ds.writable.getWriter(); + writer.write(data); + writer.close(); + + const reader = ds.readable.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + chunks.push(value); + } + + const decoder = new TextDecoder(); + return chunks.map((c) => decoder.decode(c, { stream: true })).join('') + decoder.decode(); +} + /** * @internal */ diff --git a/src/version.ts b/src/version.ts index f50f881a4a..a28d7302c2 100644 --- a/src/version.ts +++ b/src/version.ts @@ -4,8 +4,8 @@ export const version = v; export const protocolVersion = 16; export const CLIENT_PROTOCOL_DEFAULT = 0; -// const CLIENT_PROTOCOL_GZIP_RPC = 1; +export const CLIENT_PROTOCOL_GZIP_RPC = 1; /** The client protocol version indicates what level of support that the client has for * client <-> client api interactions. */ -export const clientProtocol = CLIENT_PROTOCOL_DEFAULT; +export const clientProtocol = CLIENT_PROTOCOL_GZIP_RPC; From 0d07cf3f90f642dec15c5c6bfbb60437f16d254c Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Mar 2026 15:04:08 -0400 Subject: [PATCH 04/54] feat: ensure that payload is streamed when compressed, not all buffered in memory at once --- src/room/Room.ts | 46 +++++++++--------- src/room/participant/LocalParticipant.ts | 62 ++++++++++++++++++------ src/room/rpc.ts | 26 +++++++++- 3 files changed, 96 insertions(+), 38 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index a4c3d103ee..972c5670fd 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -88,6 +88,7 @@ import { type RpcInvocationData, byteLength, gzipCompress, + gzipCompressToWriter, gzipDecompress, } from './rpc'; import CriticalTimers from './timers'; @@ -130,7 +131,7 @@ import { unpackStreamId, unwrapConstraint, } from './utils'; -import { CLIENT_PROTOCOL_DEFAULT } from '../version'; +import { CLIENT_PROTOCOL_DEFAULT, CLIENT_PROTOCOL_GZIP_RPC } from '../version'; export enum ConnectionState { Disconnected = 'disconnected', @@ -2061,7 +2062,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) callerIdentity, requestId, null, - RpcError.builtIn('APPLICATION_ERROR'), + RpcError.builtIn('APPLICATION_ERROR'), // FIXME: what should this error be? Discuss in review. ); return; } @@ -2093,18 +2094,16 @@ class Room extends (EventEmitter as new () => TypedEmitter) return; } - let responseError: RpcError | null = null; - let responsePayload: string | null = null; - + let response: string | null = null; try { - const response = await handler({ + response = await handler({ requestId, callerIdentity, payload: resolvedPayload, responseTimeout, }); - responsePayload = response; } catch (error) { + let responseError; if (error instanceof RpcError) { responseError = error; } else { @@ -2114,32 +2113,27 @@ class Room extends (EventEmitter as new () => TypedEmitter) ); responseError = RpcError.builtIn('APPLICATION_ERROR'); } - } - - // Determine how to send the response based on the caller's client protocol - const callerClientProtocol = this.getRemoteParticipantClientProtocol(callerIdentity); - if (responseError) { await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); return; } - const responseBytes = byteLength(responsePayload ?? ''); + // Determine how to send the response based on the caller's client protocol + const callerClientProtocol = this.getRemoteParticipantClientProtocol(callerIdentity); - if (callerClientProtocol >= 1 && responseBytes >= DATA_STREAM_MIN_BYTES) { - // Large response: send via data stream + const responseBytes = byteLength(response ?? ''); + + if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && responseBytes >= DATA_STREAM_MIN_BYTES) { + // Large response: create the data stream, send the RPC response referencing it, + // then stream compressed chunks for lower TTFB const streamId = crypto.randomUUID(); - const compressed = await gzipCompress(responsePayload!); const writer = await this.outgoingDataStreamManager.streamBytes({ streamId, topic: RPC_DATA_STREAM_TOPIC, destinationIdentities: [callerIdentity], mimeType: 'application/octet-stream', - totalSize: compressed.byteLength, }); - await writer.write(compressed); - await writer.close(); await this.engine.publishRpcResponse( callerIdentity, @@ -2147,18 +2141,24 @@ class Room extends (EventEmitter as new () => TypedEmitter) `${DATA_STREAM_PREFIX}${streamId}`, null, ); - } else if (callerClientProtocol >= 1 && responseBytes >= COMPRESS_MIN_BYTES) { + + await gzipCompressToWriter(response, writer); + await writer.close(); + + } else if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && responseBytes >= COMPRESS_MIN_BYTES) { // Medium response: compress inline - const compressed = await gzipCompress(responsePayload!); + const compressed = await gzipCompress(response); await this.engine.publishRpcResponseCompressed(callerIdentity, requestId, compressed); + } else if (responseBytes > MAX_PAYLOAD_BYTES) { // Legacy client can't handle large payloads - responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); + const responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); this.log.warn(`RPC Response payload too large for ${method}`); await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + } else { // Small response or legacy client: send uncompressed - await this.engine.publishRpcResponse(callerIdentity, requestId, responsePayload, null); + await this.engine.publishRpcResponse(callerIdentity, requestId, response, null); } } diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index c8303eff00..1da147a3c3 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -55,6 +55,7 @@ import { type RpcInvocationData, byteLength, gzipCompress, + gzipCompressToWriter, gzipDecompress, } from '../rpc'; import LocalAudioTrack from '../track/LocalAudioTrack'; @@ -2027,37 +2028,70 @@ export default class LocalParticipant extends Participant { mode = 'compressed-data-stream'; } - let requestPayload = payload; - let requestCompressedPayload: Uint8Array | undefined; - + let requestPayload; + let requestCompressedPayload; switch (mode) { - case 'compressed-data-stream': - // Large payload: send via data stream + case 'compressed-data-stream': { + // Large payload: create the data stream, send the RPC request referencing it, + // then stream compressed chunks for lower TTFB const streamId = crypto.randomUUID(); - const compressed = await gzipCompress(payload); - // Create and send the data stream before the RPC request - const writer = await this.streamBytes({ + const writer = await this.roomOutgoingDataStreamManager.streamBytes({ streamId, topic: RPC_DATA_STREAM_TOPIC, destinationIdentities: [destinationIdentity], mimeType: 'application/octet-stream', - totalSize: compressed.byteLength, }); - await writer.write(compressed); - await writer.close(); requestPayload = `${DATA_STREAM_PREFIX}${streamId}`; requestCompressedPayload = undefined; - break; + + // Send the RPC request now so the receiver knows to expect this stream, + // then stream the compressed payload chunks + await this.sendRpcRequestPacket( + destinationIdentity, + requestId, + method, + requestPayload, + undefined, + responseTimeout, + ); + await gzipCompressToWriter(payload, writer); + await writer.close(); + return; + } case 'compressed': // Medium payload: compress inline requestCompressedPayload = await gzipCompress(payload); requestPayload = ''; break; + + case 'regular': + default: + // Small payload: just include the payload directly, uncompressed + requestPayload = payload; + break; } + await this.sendRpcRequestPacket( + destinationIdentity, + requestId, + method, + requestPayload, + requestCompressedPayload, + responseTimeout, + ); + } + + private async sendRpcRequestPacket( + destinationIdentity: string, + requestId: string, + method: string, + payload: string | undefined, + compressedPayload: Uint8Array | undefined, + responseTimeout: number, + ) { const packet = new DataPacket({ destinationIdentities: [destinationIdentity], kind: DataPacket_Kind.RELIABLE, @@ -2066,8 +2100,8 @@ export default class LocalParticipant extends Participant { value: new RpcRequest({ id: requestId, method, - payload: requestPayload, - compressedPayload: requestCompressedPayload ?? new Uint8Array(), + payload: payload ?? "", + compressedPayload: compressedPayload ?? new Uint8Array(), responseTimeoutMs: responseTimeout, version: 1, }), diff --git a/src/room/rpc.ts b/src/room/rpc.ts index 5cc2e51296..c2e4b6d960 100644 --- a/src/room/rpc.ts +++ b/src/room/rpc.ts @@ -167,7 +167,7 @@ export const DATA_STREAM_PREFIX = 'data_stream:'; * Topic used for RPC payload data streams. * @internal */ -export const RPC_DATA_STREAM_TOPIC = '_lk_rpc'; +export const RPC_DATA_STREAM_TOPIC = 'lk.rpc_response'; /** * Compress a string payload using gzip. @@ -200,6 +200,30 @@ export async function gzipCompress(data: string): Promise { return result; } +/** + * Compress a string payload using gzip, streaming each compressed chunk to the provided writer. + * @internal + */ +export async function gzipCompressToWriter( + data: string, + writer: { write(chunk: Uint8Array): Promise }, +): Promise { + const input = new TextEncoder().encode(data); + const cs = new CompressionStream('gzip'); + const csWriter = cs.writable.getWriter(); + csWriter.write(input); + csWriter.close(); + + const reader = cs.readable.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + await writer.write(value); + } +} + /** * Decompress a gzip-compressed payload back to a string. * @internal From f7e2af301a1e78c7a7515cbb04b62c6938c8264a Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Mar 2026 16:06:10 -0400 Subject: [PATCH 05/54] feat: use a data streams transmission approach much closer to what lukas did in the original web example Register a data stream with an attribute, and listen for data streams with that attribute on the other end. --- src/room/Room.ts | 73 ++++++++++++------------ src/room/participant/LocalParticipant.ts | 27 ++++----- src/room/rpc.ts | 54 ++++++++++++++++-- 3 files changed, 94 insertions(+), 60 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index 972c5670fd..83d9999d7a 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -81,15 +81,17 @@ import RemoteParticipant from './participant/RemoteParticipant'; import { COMPRESS_MIN_BYTES, DATA_STREAM_MIN_BYTES, - DATA_STREAM_PREFIX, MAX_PAYLOAD_BYTES, RPC_DATA_STREAM_TOPIC, + RPC_REQUEST_ID_ATTR, + RPC_RESPONSE_ID_ATTR, RpcError, type RpcInvocationData, byteLength, gzipCompress, gzipCompressToWriter, gzipDecompress, + gzipDecompressFromReader, } from './rpc'; import CriticalTimers from './timers'; import LocalAudioTrack from './track/LocalAudioTrack'; @@ -319,7 +321,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.rpcHandlers, this.outgoingDataStreamManager, this.outgoingDataTrackManager, - this.getRemoteParticipantClientProtocol, + this.getRemoteParticipantClientProtocol.bind(this), this.waitForRpcDataStream, ); @@ -2062,14 +2064,16 @@ class Room extends (EventEmitter as new () => TypedEmitter) callerIdentity, requestId, null, - RpcError.builtIn('APPLICATION_ERROR'), // FIXME: what should this error be? Discuss in review. + RpcError.builtIn('APPLICATION_ERROR'), ); return; } - } else if (payload.startsWith(DATA_STREAM_PREFIX)) { - const streamId = payload.slice(DATA_STREAM_PREFIX.length); + + } else if (payload === '') { + // Empty payload with empty compressedPayload means the request payload + // is arriving via a data stream tagged with lk.rpc_request_id try { - resolvedPayload = await this.waitForRpcDataStream(streamId); + resolvedPayload = await this.waitForRpcDataStream(requestId); } catch (e) { this.log.error('Failed to receive RPC data stream payload', e); await this.engine.publishRpcResponse( @@ -2124,25 +2128,19 @@ class Room extends (EventEmitter as new () => TypedEmitter) const responseBytes = byteLength(response ?? ''); if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && responseBytes >= DATA_STREAM_MIN_BYTES) { - // Large response: create the data stream, send the RPC response referencing it, - // then stream compressed chunks for lower TTFB - const streamId = crypto.randomUUID(); - + // Large response: create the data stream tagged with the request ID, + // send the RPC response with empty payload, then stream compressed chunks + // for lower TTFB. const writer = await this.outgoingDataStreamManager.streamBytes({ - streamId, topic: RPC_DATA_STREAM_TOPIC, destinationIdentities: [callerIdentity], mimeType: 'application/octet-stream', + attributes: { [RPC_RESPONSE_ID_ATTR]: requestId }, }); - await this.engine.publishRpcResponse( - callerIdentity, - requestId, - `${DATA_STREAM_PREFIX}${streamId}`, - null, - ); + await this.engine.publishRpcResponse(callerIdentity, requestId, '', null); - await gzipCompressToWriter(response, writer); + await gzipCompressToWriter(response!, writer); await writer.close(); } else if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && responseBytes >= COMPRESS_MIN_BYTES) { @@ -2510,53 +2508,52 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.incomingDataStreamManager.registerByteStreamHandler( RPC_DATA_STREAM_TOPIC, async (reader, _participantInfo) => { - const streamId = reader.info.id; - const chunks = await reader.readAll(); - - // Concatenate all chunks into a single Uint8Array - const totalLength = chunks.reduce((sum, c) => sum + c.length, 0); - const combined = new Uint8Array(totalLength); - let offset = 0; - for (const chunk of chunks) { - combined.set(chunk, offset); - offset += chunk.length; + const attrs = reader.info.attributes ?? {}; + const rpcId = attrs[RPC_REQUEST_ID_ATTR] ?? attrs[RPC_RESPONSE_ID_ATTR]; + if (!rpcId) { + this.log.warn('Received RPC DataStream without a request/response ID attribute'); + return; } - // Decompress the data + // Stream compressed chunks directly into the decompressor let decompressed: string; try { - decompressed = await gzipDecompress(combined); + decompressed = await gzipDecompressFromReader(reader); } catch (e) { - const future = this.pendingRpcDataStreams.get(streamId); + const future = this.pendingRpcDataStreams.get(rpcId); if (future) { future.reject?.(e instanceof Error ? e : new Error(String(e))); - this.pendingRpcDataStreams.delete(streamId); + this.pendingRpcDataStreams.delete(rpcId); } return; } // Resolve the pending future, or create one that's pre-resolved - const existing = this.pendingRpcDataStreams.get(streamId); + const existing = this.pendingRpcDataStreams.get(rpcId); if (existing) { existing.resolve?.(decompressed); - this.pendingRpcDataStreams.delete(streamId); + this.pendingRpcDataStreams.delete(rpcId); } else { const future = new Future(); future.resolve?.(decompressed); - this.pendingRpcDataStreams.set(streamId, future); + this.pendingRpcDataStreams.set(rpcId, future); } }, ); } - private waitForRpcDataStream = (streamId: string): Promise => { - const existing = this.pendingRpcDataStreams.get(streamId); + /** + * Wait for an RPC data stream to arrive and return its decompressed payload. + * Keyed by the RPC request ID (used as the attribute value on the data stream). + */ + private waitForRpcDataStream = (requestId: string): Promise => { + const existing = this.pendingRpcDataStreams.get(requestId); if (existing) { return existing.promise; } const future = new Future(); - this.pendingRpcDataStreams.set(streamId, future); + this.pendingRpcDataStreams.set(requestId, future); return future.promise; }; diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index 1da147a3c3..eeb03655ae 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -47,9 +47,9 @@ import { EngineEvent, ParticipantEvent, TrackEvent } from '../events'; import { COMPRESS_MIN_BYTES, DATA_STREAM_MIN_BYTES, - DATA_STREAM_PREFIX, MAX_PAYLOAD_BYTES, RPC_DATA_STREAM_TOPIC, + RPC_REQUEST_ID_ATTR, type PerformRpcParams, RpcError, type RpcInvocationData, @@ -386,11 +386,11 @@ export default class LocalParticipant extends Participant { } } - // Handle data stream payload - if (payload && payload.startsWith(DATA_STREAM_PREFIX)) { - const streamId = payload.slice(DATA_STREAM_PREFIX.length); + // Empty payload with no error and no compressedPayload means the response + // payload is arriving via a data stream tagged with lk.rpc_response_id + if (!error && payload === '') { try { - payload = await this.waitForRpcDataStream(streamId); + payload = await this.waitForRpcDataStream(rpcResponse.requestId); } catch (e) { this.log.error('Failed to receive RPC data stream response', e); error = RpcError.builtIn('APPLICATION_ERROR'); @@ -2032,27 +2032,22 @@ export default class LocalParticipant extends Participant { let requestCompressedPayload; switch (mode) { case 'compressed-data-stream': { - // Large payload: create the data stream, send the RPC request referencing it, - // then stream compressed chunks for lower TTFB - const streamId = crypto.randomUUID(); - + // Large payload: create the data stream tagged with the request ID, + // send the RPC request with empty payload/compressedPayload, then + // stream compressed chunks for lower TTFB const writer = await this.roomOutgoingDataStreamManager.streamBytes({ - streamId, topic: RPC_DATA_STREAM_TOPIC, destinationIdentities: [destinationIdentity], mimeType: 'application/octet-stream', + attributes: { [RPC_REQUEST_ID_ATTR]: requestId }, }); - requestPayload = `${DATA_STREAM_PREFIX}${streamId}`; - requestCompressedPayload = undefined; - - // Send the RPC request now so the receiver knows to expect this stream, - // then stream the compressed payload chunks + // Send the RPC request now so the receiver knows to expect a data stream await this.sendRpcRequestPacket( destinationIdentity, requestId, method, - requestPayload, + '', undefined, responseTimeout, ); diff --git a/src/room/rpc.ts b/src/room/rpc.ts index c2e4b6d960..f4cff119e6 100644 --- a/src/room/rpc.ts +++ b/src/room/rpc.ts @@ -158,16 +158,22 @@ export const COMPRESS_MIN_BYTES = 1024; // 1 KB export const DATA_STREAM_MIN_BYTES = 15360; // 15 KB /** - * Prefix used in the payload field to indicate data is arriving via a data stream. + * Attribute key set on a data stream to associate it with an RPC request. * @internal */ -export const DATA_STREAM_PREFIX = 'data_stream:'; +export const RPC_REQUEST_ID_ATTR = 'lk.rpc_request_id'; + +/** + * Attribute key set on a data stream to associate it with an RPC response. + * @internal + */ +export const RPC_RESPONSE_ID_ATTR = 'lk.rpc_response_id'; /** * Topic used for RPC payload data streams. * @internal */ -export const RPC_DATA_STREAM_TOPIC = 'lk.rpc_response'; +export const RPC_DATA_STREAM_TOPIC = 'lk.rpc_payload'; /** * Compress a string payload using gzip. @@ -235,17 +241,53 @@ export async function gzipDecompress(data: Uint8Array): Promise { writer.close(); const reader = ds.readable.getReader(); - const chunks: Uint8Array[] = []; + const decoder = new TextDecoder(); + let result = ''; while (true) { const { done, value } = await reader.read(); if (done) { break; } - chunks.push(value); + result += decoder.decode(value, { stream: true }); } + result += decoder.decode(); + return result; +} +/** + * Decompress a gzip-compressed stream of chunks back to a string, feeding each chunk + * into the decompression stream as it arrives rather than buffering first. + * @internal + */ +export async function gzipDecompressFromReader( + reader: AsyncIterable, +): Promise { + const ds = new DecompressionStream('gzip'); + const dsWriter = ds.writable.getWriter(); + + // Feed compressed chunks into the decompression stream as they arrive + const pipePromise = (async () => { + for await (const chunk of reader) { + await dsWriter.write(chunk); + } + await dsWriter.close(); + })(); + + // Read decompressed output concurrently + const dsReader = ds.readable.getReader(); const decoder = new TextDecoder(); - return chunks.map((c) => decoder.decode(c, { stream: true })).join('') + decoder.decode(); + let result = ''; + while (true) { + const { done, value } = await dsReader.read(); + if (done) { + break; + } + result += decoder.decode(value, { stream: true }); + } + result += decoder.decode(); + + await pipePromise; + return result; } /** From e3475fe397a157556a43bd47fdaff69f3e59524d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Mar 2026 16:07:40 -0400 Subject: [PATCH 06/54] fix: add long rpc message to example --- examples/rpc/rpc-demo.ts | 32 ++ src/room/Room.ts | 227 +++---------- src/room/participant/LocalParticipant.ts | 302 +----------------- src/room/rpc/RpcClientManager.ts | 389 +++++++++++++++++++++++ src/room/rpc/RpcServerManager.ts | 255 +++++++++++++++ src/room/rpc/index.ts | 22 ++ src/room/{rpc.ts => rpc/utils.ts} | 0 7 files changed, 752 insertions(+), 475 deletions(-) create mode 100644 src/room/rpc/RpcClientManager.ts create mode 100644 src/room/rpc/RpcServerManager.ts create mode 100644 src/room/rpc/index.ts rename src/room/{rpc.ts => rpc/utils.ts} (100%) diff --git a/examples/rpc/rpc-demo.ts b/examples/rpc/rpc-demo.ts index 4815754781..b62ceaa2b1 100644 --- a/examples/rpc/rpc-demo.ts +++ b/examples/rpc/rpc-demo.ts @@ -36,6 +36,13 @@ async function main() { console.error('Error:', error); } + try { + console.log('\n\nRunning send long info example...'); + await Promise.all([performSendVeryLongInfo(callersRoom)]); + } catch (error) { + console.error('Error:', error); + } + try { console.log('\n\nRunning error handling example...'); await Promise.all([performDivision(callersRoom)]); @@ -85,6 +92,16 @@ const registerReceiverMethods = async (greetersRoom: Room, mathGeniusRoom: Room) }, ); + await greetersRoom.registerRpcMethod( + 'exchanging-long-info', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async (data: RpcInvocationData) => { + console.log(`[Greeter] ${data.callerIdentity} has arrived and said that its long info is "${data.payload}"`); + await new Promise((resolve) => setTimeout(resolve, 2000)); + return new Array(10_000).fill('Y').join(''); + }, + ); + await mathGeniusRoom.registerRpcMethod('square-root', async (data: RpcInvocationData) => { const jsonData = JSON.parse(data.payload); const number = jsonData.number; @@ -136,6 +153,21 @@ const performGreeting = async (room: Room): Promise => { } }; +const performSendVeryLongInfo = async (room: Room): Promise => { + console.log("[Caller] Sending the greeter a very long message"); + try { + const response = await room.localParticipant.performRpc({ + destinationIdentity: 'greeter', + method: 'exchanging-long-info', + payload: new Array(10_000).fill('X').join(''), + }); + console.log(`[Caller] The greeter's long info is: "${response}"`); + } catch (error) { + console.error('[Caller] RPC call failed:', error); + throw error; + } +}; + const performDisconnection = async (room: Room): Promise => { console.log('[Caller] Checking back in on the greeter...'); try { diff --git a/src/room/Room.ts b/src/room/Room.ts index 83d9999d7a..d530633cd1 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -79,19 +79,12 @@ import Participant from './participant/Participant'; import { type ConnectionQuality, ParticipantKind } from './participant/Participant'; import RemoteParticipant from './participant/RemoteParticipant'; import { - COMPRESS_MIN_BYTES, - DATA_STREAM_MIN_BYTES, - MAX_PAYLOAD_BYTES, RPC_DATA_STREAM_TOPIC, RPC_REQUEST_ID_ATTR, RPC_RESPONSE_ID_ATTR, - RpcError, + RpcClientManager, type RpcInvocationData, - byteLength, - gzipCompress, - gzipCompressToWriter, - gzipDecompress, - gzipDecompressFromReader, + RpcServerManager, } from './rpc'; import CriticalTimers from './timers'; import LocalAudioTrack from './track/LocalAudioTrack'; @@ -133,7 +126,7 @@ import { unpackStreamId, unwrapConstraint, } from './utils'; -import { CLIENT_PROTOCOL_DEFAULT, CLIENT_PROTOCOL_GZIP_RPC } from '../version'; +import { CLIENT_PROTOCOL_DEFAULT } from '../version'; export enum ConnectionState { Disconnected = 'disconnected', @@ -231,7 +224,9 @@ class Room extends (EventEmitter as new () => TypedEmitter) private rpcHandlers: Map Promise> = new Map(); - private pendingRpcDataStreams: Map> = new Map(); + private rpcClientManager: RpcClientManager; + + private rpcServerManager: RpcServerManager; get hasE2EESetup(): boolean { return this.e2eeManager !== undefined; @@ -311,6 +306,19 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.registerRpcDataStreamHandler(); + this.rpcClientManager = new RpcClientManager( + this.engine, + this.log, + this.outgoingDataStreamManager, + this.getRemoteParticipantClientProtocol, + ); + this.rpcServerManager = new RpcServerManager( + this.engine, + this.log, + this.outgoingDataStreamManager, + this.getRemoteParticipantClientProtocol, + ); + this.disconnectLock = new Mutex(); this.localParticipant = new LocalParticipant( @@ -318,11 +326,12 @@ class Room extends (EventEmitter as new () => TypedEmitter) '', this.engine, this.options, - this.rpcHandlers, this.outgoingDataStreamManager, this.outgoingDataTrackManager, this.getRemoteParticipantClientProtocol.bind(this), this.waitForRpcDataStream, + this.rpcClientManager, + this.rpcServerManager, ); if (this.options.e2ee || this.options.encryption) { @@ -411,12 +420,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) * Other errors thrown in your handler will not be transmitted as-is, and will instead arrive to the caller as `1500` ("Application Error"). */ registerRpcMethod(method: string, handler: (data: RpcInvocationData) => Promise) { - if (this.rpcHandlers.has(method)) { - throw Error( - `RPC handler already registered for method ${method}, unregisterRpcMethod before trying to register again`, - ); - } - this.rpcHandlers.set(method, handler); + this.rpcServerManager.registerRpcMethod(method, handler); } /** @@ -425,7 +429,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) * @param method - The name of the RPC method to unregister */ unregisterRpcMethod(method: string) { - this.rpcHandlers.delete(method); + this.rpcServerManager.unregisterRpcMethod(method); } /** @@ -705,6 +709,12 @@ class Room extends (EventEmitter as new () => TypedEmitter) if (this.outgoingDataStreamManager) { this.outgoingDataStreamManager.setupEngine(this.engine); } + if (this.rpcClientManager) { + this.rpcClientManager.setupEngine(this.engine); + } + if (this.rpcServerManager) { + this.rpcServerManager.setupEngine(this.engine); + } } /** @@ -1960,7 +1970,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.handleDataStream(packet, encryptionType); } else if (packet.value.case === 'rpcRequest') { const rpc = packet.value.value; - this.handleIncomingRpcRequest( + this.rpcServerManager.handleIncomingRpcRequest( packet.participantIdentity, rpc.id, rpc.method, @@ -2032,134 +2042,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.incomingDataStreamManager.handleDataStreamPacket(packet, encryptionType); }; - private async handleIncomingRpcRequest( - callerIdentity: string, - requestId: string, - method: string, - payload: string, - compressedPayload: Uint8Array, - responseTimeout: number, - version: number, - ) { - await this.engine.publishRpcAck(callerIdentity, requestId); - - if (version !== 1) { - await this.engine.publishRpcResponse( - callerIdentity, - requestId, - null, - RpcError.builtIn('UNSUPPORTED_VERSION'), - ); - return; - } - - // Resolve the actual payload from compressed or data stream sources - let resolvedPayload = payload; - if (compressedPayload && compressedPayload.length > 0) { - try { - resolvedPayload = await gzipDecompress(compressedPayload); - } catch (e) { - this.log.error('Failed to decompress RPC request payload', e); - await this.engine.publishRpcResponse( - callerIdentity, - requestId, - null, - RpcError.builtIn('APPLICATION_ERROR'), - ); - return; - } - - } else if (payload === '') { - // Empty payload with empty compressedPayload means the request payload - // is arriving via a data stream tagged with lk.rpc_request_id - try { - resolvedPayload = await this.waitForRpcDataStream(requestId); - } catch (e) { - this.log.error('Failed to receive RPC data stream payload', e); - await this.engine.publishRpcResponse( - callerIdentity, - requestId, - null, - RpcError.builtIn('APPLICATION_ERROR'), - ); - return; - } - } - - const handler = this.rpcHandlers.get(method); - - if (!handler) { - await this.engine.publishRpcResponse( - callerIdentity, - requestId, - null, - RpcError.builtIn('UNSUPPORTED_METHOD'), - ); - return; - } - - let response: string | null = null; - try { - response = await handler({ - requestId, - callerIdentity, - payload: resolvedPayload, - responseTimeout, - }); - } catch (error) { - let responseError; - if (error instanceof RpcError) { - responseError = error; - } else { - this.log.warn( - `Uncaught error returned by RPC handler for ${method}. Returning APPLICATION_ERROR instead.`, - error, - ); - responseError = RpcError.builtIn('APPLICATION_ERROR'); - } - - await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); - return; - } - - // Determine how to send the response based on the caller's client protocol - const callerClientProtocol = this.getRemoteParticipantClientProtocol(callerIdentity); - - const responseBytes = byteLength(response ?? ''); - - if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && responseBytes >= DATA_STREAM_MIN_BYTES) { - // Large response: create the data stream tagged with the request ID, - // send the RPC response with empty payload, then stream compressed chunks - // for lower TTFB. - const writer = await this.outgoingDataStreamManager.streamBytes({ - topic: RPC_DATA_STREAM_TOPIC, - destinationIdentities: [callerIdentity], - mimeType: 'application/octet-stream', - attributes: { [RPC_RESPONSE_ID_ATTR]: requestId }, - }); - - await this.engine.publishRpcResponse(callerIdentity, requestId, '', null); - - await gzipCompressToWriter(response!, writer); - await writer.close(); - - } else if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && responseBytes >= COMPRESS_MIN_BYTES) { - // Medium response: compress inline - const compressed = await gzipCompress(response); - await this.engine.publishRpcResponseCompressed(callerIdentity, requestId, compressed); - - } else if (responseBytes > MAX_PAYLOAD_BYTES) { - // Legacy client can't handle large payloads - const responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); - this.log.warn(`RPC Response payload too large for ${method}`); - await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); - - } else { - // Small response or legacy client: send uncompressed - await this.engine.publishRpcResponse(callerIdentity, requestId, response, null); - } - } - bufferedSegments: Map = new Map(); private handleAudioPlaybackStarted = () => { @@ -2501,6 +2383,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) } private getRemoteParticipantClientProtocol(identity: Participant["identity"]) { + console.info('REMOTE', identity, this.remoteParticipants); return this.remoteParticipants.get(identity)?.clientProtocol ?? CLIENT_PROTOCOL_DEFAULT; } @@ -2509,54 +2392,20 @@ class Room extends (EventEmitter as new () => TypedEmitter) RPC_DATA_STREAM_TOPIC, async (reader, _participantInfo) => { const attrs = reader.info.attributes ?? {}; - const rpcId = attrs[RPC_REQUEST_ID_ATTR] ?? attrs[RPC_RESPONSE_ID_ATTR]; - if (!rpcId) { - this.log.warn('Received RPC DataStream without a request/response ID attribute'); - return; - } - - // Stream compressed chunks directly into the decompressor - let decompressed: string; - try { - decompressed = await gzipDecompressFromReader(reader); - } catch (e) { - const future = this.pendingRpcDataStreams.get(rpcId); - if (future) { - future.reject?.(e instanceof Error ? e : new Error(String(e))); - this.pendingRpcDataStreams.delete(rpcId); - } - return; - } + const requestId = attrs[RPC_REQUEST_ID_ATTR]; + const responseId = attrs[RPC_RESPONSE_ID_ATTR]; - // Resolve the pending future, or create one that's pre-resolved - const existing = this.pendingRpcDataStreams.get(rpcId); - if (existing) { - existing.resolve?.(decompressed); - this.pendingRpcDataStreams.delete(rpcId); + if (requestId) { + await this.rpcServerManager.handleIncomingDataStream(reader, requestId); + } else if (responseId) { + await this.rpcClientManager.handleIncomingDataStream(reader, responseId); } else { - const future = new Future(); - future.resolve?.(decompressed); - this.pendingRpcDataStreams.set(rpcId, future); + this.log.warn('Received RPC DataStream without a request/response ID attribute'); } }, ); } - /** - * Wait for an RPC data stream to arrive and return its decompressed payload. - * Keyed by the RPC request ID (used as the attribute value on the data stream). - */ - private waitForRpcDataStream = (requestId: string): Promise => { - const existing = this.pendingRpcDataStreams.get(requestId); - if (existing) { - return existing.promise; - } - - const future = new Future(); - this.pendingRpcDataStreams.set(requestId, future); - return future.promise; - }; - private registerConnectionReconcile() { this.clearConnectionReconcile(); let consecutiveFailures = 0; diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index eeb03655ae..f0153d4e29 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -11,9 +11,6 @@ import { ParticipantInfo, RequestResponse, RequestResponse_Reason, - RpcAck, - RpcRequest, - RpcResponse, SimulcastCodec, SipDTMF, SubscribedQualityUpdate, @@ -45,18 +42,11 @@ import { } from '../errors'; import { EngineEvent, ParticipantEvent, TrackEvent } from '../events'; import { - COMPRESS_MIN_BYTES, - DATA_STREAM_MIN_BYTES, - MAX_PAYLOAD_BYTES, - RPC_DATA_STREAM_TOPIC, - RPC_REQUEST_ID_ATTR, type PerformRpcParams, + RpcClientManager, RpcError, type RpcInvocationData, - byteLength, - gzipCompress, - gzipCompressToWriter, - gzipDecompress, + RpcServerManager, } from '../rpc'; import LocalAudioTrack from '../track/LocalAudioTrack'; import LocalTrack from '../track/LocalTrack'; @@ -92,7 +82,6 @@ import { } from '../types'; import { Future, - compareVersions, isAudioTrack, isE2EESimulcastSupported, isFireFox, @@ -158,12 +147,14 @@ export default class LocalParticipant extends Participant { private firstActiveAgent?: RemoteParticipant; - private rpcHandlers: Map Promise>; - private roomOutgoingDataStreamManager: OutgoingDataStreamManager; private roomOutgoingDataTrackManager: OutgoingDataTrackManager; + private rpcClientManager: RpcClientManager; + + private rpcServerManager: RpcServerManager; + private pendingSignalRequests: Map< number, { @@ -175,31 +166,18 @@ export default class LocalParticipant extends Participant { private enabledPublishVideoCodecs: Codec[] = []; - private pendingAcks = new Map void; participantIdentity: string }>(); - - private pendingResponses = new Map< - string, - { - resolve: (payload: string | null, error: RpcError | null) => void; - participantIdentity: string; - } - >(); - - private getRemoteParticipantClientProtocol: (identity: Participant["identity"]) => number; - - private waitForRpcDataStream: (streamId: string) => Promise; - /** @internal */ constructor( sid: string, identity: string, engine: RTCEngine, options: InternalRoomOptions, - roomRpcHandlers: Map Promise>, roomOutgoingDataStreamManager: OutgoingDataStreamManager, roomOutgoingDataTrackManager: OutgoingDataTrackManager, getRemoteParticipantClientProtocol: (identity: Participant["identity"]) => number, waitForRpcDataStream: (streamId: string) => Promise, + rpcClientManager: RpcClientManager, + rpcServerManager: RpcServerManager, ) { super(sid, identity, undefined, undefined, undefined, { loggerName: options.loggerName, @@ -217,11 +195,12 @@ export default class LocalParticipant extends Participant { ['audiooutput', 'default'], ]); this.pendingSignalRequests = new Map(); - this.rpcHandlers = roomRpcHandlers; this.roomOutgoingDataStreamManager = roomOutgoingDataStreamManager; this.roomOutgoingDataTrackManager = roomOutgoingDataTrackManager; this.getRemoteParticipantClientProtocol = getRemoteParticipantClientProtocol; this.waitForRpcDataStream = waitForRpcDataStream; + this.rpcClientManager = rpcClientManager; + this.rpcServerManager = rpcServerManager; } get lastCameraError(): Error | undefined { @@ -367,44 +346,7 @@ export default class LocalParticipant extends Participant { }; private handleDataPacket = async (packet: DataPacket) => { - switch (packet.value.case) { - case 'rpcResponse': - let rpcResponse = packet.value.value as RpcResponse; - let payload: string | null = null; - let error: RpcError | null = null; - - if (rpcResponse.value.case === 'payload') { - payload = rpcResponse.value.value; - } else if (rpcResponse.value.case === 'error') { - error = RpcError.fromProto(rpcResponse.value.value); - } else if (rpcResponse.value.case === 'compressedPayload') { - try { - payload = await gzipDecompress(rpcResponse.value.value); - } catch (e) { - this.log.error('Failed to decompress RPC response', e); - error = RpcError.builtIn('APPLICATION_ERROR'); - } - } - - // Empty payload with no error and no compressedPayload means the response - // payload is arriving via a data stream tagged with lk.rpc_response_id - if (!error && payload === '') { - try { - payload = await this.waitForRpcDataStream(rpcResponse.requestId); - } catch (e) { - this.log.error('Failed to receive RPC data stream response', e); - error = RpcError.builtIn('APPLICATION_ERROR'); - payload = null; - } - } - - this.handleIncomingRpcResponse(rpcResponse.requestId, payload, error); - break; - case 'rpcAck': - let rpcAck = packet.value.value as RpcAck; - this.handleIncomingRpcAck(rpcAck.requestId); - break; - } + await this.rpcClientManager.handleDataPacket(packet); }; /** @@ -1859,102 +1801,22 @@ export default class LocalParticipant extends Participant { * @returns A promise that resolves with the response payload or rejects with an error. * @throws Error on failure. Details in `message`. */ - performRpc({ - destinationIdentity, - method, - payload, - responseTimeout = 15000, - }: PerformRpcParams): TypedPromise { - const maxRoundTripLatency = 7000; - const minEffectiveTimeout = maxRoundTripLatency + 1000; - - return new TypedPromise(async (resolve, reject) => { - const remoteClientProtocol = this.getRemoteParticipantClientProtocol(destinationIdentity); - const payloadBytes = byteLength(payload); - - // Only enforce the legacy size limit when compression is not available - if (payloadBytes > MAX_PAYLOAD_BYTES && remoteClientProtocol < 1) { - reject(RpcError.builtIn('REQUEST_PAYLOAD_TOO_LARGE')); - return; - } - - if ( - this.engine.latestJoinResponse?.serverInfo?.version && - compareVersions(this.engine.latestJoinResponse?.serverInfo?.version, '1.8.0') < 0 - ) { - reject(RpcError.builtIn('UNSUPPORTED_SERVER')); - return; - } - - const effectiveTimeout = Math.max(responseTimeout, minEffectiveTimeout); - const id = crypto.randomUUID(); - - await this.publishRpcRequest( - destinationIdentity, - id, - method, - payload, - effectiveTimeout, - remoteClientProtocol, - ); - - const ackTimeoutId = setTimeout(() => { - this.pendingAcks.delete(id); - reject(RpcError.builtIn('CONNECTION_TIMEOUT')); - this.pendingResponses.delete(id); - clearTimeout(responseTimeoutId); - }, maxRoundTripLatency); - - this.pendingAcks.set(id, { - resolve: () => { - clearTimeout(ackTimeoutId); - }, - participantIdentity: destinationIdentity, - }); - - const responseTimeoutId = setTimeout(() => { - this.pendingResponses.delete(id); - reject(RpcError.builtIn('RESPONSE_TIMEOUT')); - }, responseTimeout); - - this.pendingResponses.set(id, { - resolve: (responsePayload: string | null, responseError: RpcError | null) => { - clearTimeout(responseTimeoutId); - if (this.pendingAcks.has(id)) { - this.log.warn('RPC response received before ack', id); - this.pendingAcks.delete(id); - clearTimeout(ackTimeoutId); - } - - if (responseError) { - reject(responseError); - } else { - resolve(responsePayload ?? ''); - } - }, - participantIdentity: destinationIdentity, - }); - }); + performRpc(params: PerformRpcParams): TypedPromise { + return this.rpcClientManager.performRpc(params); } /** * @deprecated use `room.registerRpcMethod` instead */ registerRpcMethod(method: string, handler: (data: RpcInvocationData) => Promise) { - if (this.rpcHandlers.has(method)) { - this.log.warn( - `you're overriding the RPC handler for method ${method}, in the future this will throw an error`, - ); - } - - this.rpcHandlers.set(method, handler); + this.rpcServerManager.registerRpcMethod(method, handler); } /** * @deprecated use `room.unregisterRpcMethod` instead */ unregisterRpcMethod(method: string) { - this.rpcHandlers.delete(method); + this.rpcServerManager.unregisterRpcMethod(method); } /** @@ -1985,141 +1847,9 @@ export default class LocalParticipant extends Participant { } } - private handleIncomingRpcAck(requestId: string) { - const handler = this.pendingAcks.get(requestId); - if (handler) { - handler.resolve(); - this.pendingAcks.delete(requestId); - } else { - console.error('Ack received for unexpected RPC request', requestId); - } - } - - private handleIncomingRpcResponse( - requestId: string, - payload: string | null, - error: RpcError | null, - ) { - const handler = this.pendingResponses.get(requestId); - if (handler) { - handler.resolve(payload, error); - this.pendingResponses.delete(requestId); - } else { - console.error('Response received for unexpected RPC request', requestId); - } - } - - /** @internal */ - private async publishRpcRequest( - destinationIdentity: string, - requestId: string, - method: string, - payload: string, - responseTimeout: number, - remoteClientProtocol: number, - ) { - const payloadBytes = byteLength(payload); - - let mode: 'regular' | 'compressed' | 'compressed-data-stream' = 'regular'; - if (remoteClientProtocol >= 1 && payloadBytes >= COMPRESS_MIN_BYTES) { - mode = 'compressed'; - } - if (mode === 'compressed' && payloadBytes >= DATA_STREAM_MIN_BYTES) { - mode = 'compressed-data-stream'; - } - - let requestPayload; - let requestCompressedPayload; - switch (mode) { - case 'compressed-data-stream': { - // Large payload: create the data stream tagged with the request ID, - // send the RPC request with empty payload/compressedPayload, then - // stream compressed chunks for lower TTFB - const writer = await this.roomOutgoingDataStreamManager.streamBytes({ - topic: RPC_DATA_STREAM_TOPIC, - destinationIdentities: [destinationIdentity], - mimeType: 'application/octet-stream', - attributes: { [RPC_REQUEST_ID_ATTR]: requestId }, - }); - - // Send the RPC request now so the receiver knows to expect a data stream - await this.sendRpcRequestPacket( - destinationIdentity, - requestId, - method, - '', - undefined, - responseTimeout, - ); - await gzipCompressToWriter(payload, writer); - await writer.close(); - return; - } - - case 'compressed': - // Medium payload: compress inline - requestCompressedPayload = await gzipCompress(payload); - requestPayload = ''; - break; - - case 'regular': - default: - // Small payload: just include the payload directly, uncompressed - requestPayload = payload; - break; - } - - await this.sendRpcRequestPacket( - destinationIdentity, - requestId, - method, - requestPayload, - requestCompressedPayload, - responseTimeout, - ); - } - - private async sendRpcRequestPacket( - destinationIdentity: string, - requestId: string, - method: string, - payload: string | undefined, - compressedPayload: Uint8Array | undefined, - responseTimeout: number, - ) { - const packet = new DataPacket({ - destinationIdentities: [destinationIdentity], - kind: DataPacket_Kind.RELIABLE, - value: { - case: 'rpcRequest', - value: new RpcRequest({ - id: requestId, - method, - payload: payload ?? "", - compressedPayload: compressedPayload ?? new Uint8Array(), - responseTimeoutMs: responseTimeout, - version: 1, - }), - }, - }); - - await this.engine.sendDataPacket(packet, DataChannelKind.RELIABLE); - } - /** @internal */ handleParticipantDisconnected(participantIdentity: string) { - for (const [id, { participantIdentity: pendingIdentity }] of this.pendingAcks) { - if (pendingIdentity === participantIdentity) { - this.pendingAcks.delete(id); - } - } - - for (const [id, { participantIdentity: pendingIdentity, resolve }] of this.pendingResponses) { - if (pendingIdentity === participantIdentity) { - resolve(null, RpcError.builtIn('RECIPIENT_DISCONNECTED')); - this.pendingResponses.delete(id); - } - } + this.rpcClientManager.handleParticipantDisconnected(participantIdentity); } /** @internal */ diff --git a/src/room/rpc/RpcClientManager.ts b/src/room/rpc/RpcClientManager.ts new file mode 100644 index 0000000000..b7963bc07f --- /dev/null +++ b/src/room/rpc/RpcClientManager.ts @@ -0,0 +1,389 @@ +// SPDX-FileCopyrightText: 2024 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +import { + DataPacket, + DataPacket_Kind, + RpcAck, + RpcRequest, + RpcResponse, +} from '@livekit/protocol'; +import { type StructuredLogger } from '../../logger'; +import TypedPromise from '../../utils/TypedPromise'; +import type RTCEngine from '../RTCEngine'; +import type OutgoingDataStreamManager from '../data-stream/outgoing/OutgoingDataStreamManager'; +import type Participant from '../participant/Participant'; +import { Future, compareVersions } from '../utils'; +import type { ByteStreamReader } from '../data-stream/incoming/StreamReader'; +import { + COMPRESS_MIN_BYTES, + DATA_STREAM_MIN_BYTES, + MAX_PAYLOAD_BYTES, + type PerformRpcParams, + RPC_DATA_STREAM_TOPIC, + RPC_REQUEST_ID_ATTR, + RpcError, + byteLength, + gzipCompress, + gzipCompressToWriter, + gzipDecompress, + gzipDecompressFromReader, +} from './utils'; + +/** + * Manages the client (caller) side of RPC: sending requests, tracking pending + * ack/response state, and handling incoming ack/response packets. + * @internal + */ +export default class RpcClientManager { + private engine: RTCEngine; + + private log: StructuredLogger; + + private outgoingDataStreamManager: OutgoingDataStreamManager; + + private getRemoteParticipantClientProtocol: (identity: Participant['identity']) => number; + + private pendingAcks = new Map void; participantIdentity: string }>(); + + private pendingResponses = new Map< + string, + { + resolve: (payload: string | null, error: RpcError | null) => void; + participantIdentity: string; + } + >(); + + private pendingDataStreams = new Map>(); + + constructor( + engine: RTCEngine, + log: StructuredLogger, + outgoingDataStreamManager: OutgoingDataStreamManager, + getRemoteParticipantClientProtocol: (identity: Participant['identity']) => number, + ) { + this.engine = engine; + this.log = log; + this.outgoingDataStreamManager = outgoingDataStreamManager; + this.getRemoteParticipantClientProtocol = getRemoteParticipantClientProtocol; + } + + setupEngine(engine: RTCEngine) { + this.engine = engine; + } + + performRpc({ + destinationIdentity, + method, + payload, + responseTimeout = 15000, + }: PerformRpcParams): TypedPromise { + const maxRoundTripLatency = 7000; + const minEffectiveTimeout = maxRoundTripLatency + 1000; + + return new TypedPromise(async (resolve, reject) => { + const remoteClientProtocol = this.getRemoteParticipantClientProtocol(destinationIdentity); + const payloadBytes = byteLength(payload); + + // Only enforce the legacy size limit when compression is not available + if (payloadBytes > MAX_PAYLOAD_BYTES && remoteClientProtocol < 1) { + reject(RpcError.builtIn('REQUEST_PAYLOAD_TOO_LARGE')); + return; + } + + if ( + this.engine.latestJoinResponse?.serverInfo?.version && + compareVersions(this.engine.latestJoinResponse?.serverInfo?.version, '1.8.0') < 0 + ) { + reject(RpcError.builtIn('UNSUPPORTED_SERVER')); + return; + } + + const effectiveTimeout = Math.max(responseTimeout, minEffectiveTimeout); + const id = crypto.randomUUID(); + + await this.publishRpcRequest( + destinationIdentity, + id, + method, + payload, + effectiveTimeout, + remoteClientProtocol, + ); + + const ackTimeoutId = setTimeout(() => { + this.pendingAcks.delete(id); + reject(RpcError.builtIn('CONNECTION_TIMEOUT')); + this.pendingResponses.delete(id); + clearTimeout(responseTimeoutId); + }, maxRoundTripLatency); + + this.pendingAcks.set(id, { + resolve: () => { + clearTimeout(ackTimeoutId); + }, + participantIdentity: destinationIdentity, + }); + + const responseTimeoutId = setTimeout(() => { + this.pendingResponses.delete(id); + reject(RpcError.builtIn('RESPONSE_TIMEOUT')); + }, responseTimeout); + + this.pendingResponses.set(id, { + resolve: (responsePayload: string | null, responseError: RpcError | null) => { + clearTimeout(responseTimeoutId); + if (this.pendingAcks.has(id)) { + this.log.warn('RPC response received before ack', id); + this.pendingAcks.delete(id); + clearTimeout(ackTimeoutId); + } + + if (responseError) { + reject(responseError); + } else { + resolve(responsePayload ?? ''); + } + }, + participantIdentity: destinationIdentity, + }); + }); + } + + /** + * Handle an incoming data packet that may contain an RPC ack or response. + * Returns true if the packet was handled. + */ + async handleDataPacket(packet: DataPacket): Promise { + switch (packet.value.case) { + case 'rpcResponse': { + const rpcResponse = packet.value.value as RpcResponse; + let payload: string | null = null; + let error: RpcError | null = null; + + if (rpcResponse.value.case === 'payload') { + payload = rpcResponse.value.value; + } else if (rpcResponse.value.case === 'error') { + error = RpcError.fromProto(rpcResponse.value.value); + } else if (rpcResponse.value.case === 'compressedPayload') { + try { + payload = await gzipDecompress(rpcResponse.value.value); + } catch (e) { + this.log.error('Failed to decompress RPC response', e); + error = RpcError.builtIn('APPLICATION_ERROR'); + } + } + + // Empty payload with no error means the response payload is arriving + // via a data stream tagged with lk.rpc_response_id + if (!error && payload === '') { + try { + payload = await this.waitForDataStream(rpcResponse.requestId); + } catch (e) { + this.log.error('Failed to receive RPC data stream response', e); + error = RpcError.builtIn('APPLICATION_ERROR'); + payload = null; + } + } + + this.handleIncomingRpcResponse(rpcResponse.requestId, payload, error); + return true; + } + case 'rpcAck': { + const rpcAck = packet.value.value as RpcAck; + this.handleIncomingRpcAck(rpcAck.requestId); + return true; + } + default: + return false; + } + } + + handleParticipantDisconnected(participantIdentity: string) { + for (const [id, { participantIdentity: pendingIdentity }] of this.pendingAcks) { + if (pendingIdentity === participantIdentity) { + this.pendingAcks.delete(id); + } + } + + for (const [id, { participantIdentity: pendingIdentity, resolve }] of this.pendingResponses) { + if (pendingIdentity === participantIdentity) { + resolve(null, RpcError.builtIn('RECIPIENT_DISCONNECTED')); + this.pendingResponses.delete(id); + } + } + } + + private handleIncomingRpcAck(requestId: string) { + const handler = this.pendingAcks.get(requestId); + if (handler) { + handler.resolve(); + this.pendingAcks.delete(requestId); + } else { + console.error('Ack received for unexpected RPC request', requestId); + } + } + + private handleIncomingRpcResponse( + requestId: string, + payload: string | null, + error: RpcError | null, + ) { + const handler = this.pendingResponses.get(requestId); + if (handler) { + handler.resolve(payload, error); + this.pendingResponses.delete(requestId); + } else { + console.error('Response received for unexpected RPC request', requestId); + } + } + + private async publishRpcRequest( + destinationIdentity: string, + requestId: string, + method: string, + payload: string, + responseTimeout: number, + remoteClientProtocol: number, + ) { + const payloadBytes = byteLength(payload); + + let mode: 'regular' | 'compressed' | 'compressed-data-stream' = 'regular'; + if (remoteClientProtocol >= 1 && payloadBytes >= COMPRESS_MIN_BYTES) { + mode = 'compressed'; + } + if (mode === 'compressed' && payloadBytes >= DATA_STREAM_MIN_BYTES) { + mode = 'compressed-data-stream'; + } + + let requestPayload; + let requestCompressedPayload; + switch (mode) { + case 'compressed-data-stream': { + // Large payload: create the data stream tagged with the request ID, + // send the RPC request with empty payload/compressedPayload, then + // stream compressed chunks for lower TTFB + const writer = await this.outgoingDataStreamManager.streamBytes({ + topic: RPC_DATA_STREAM_TOPIC, + destinationIdentities: [destinationIdentity], + mimeType: 'application/octet-stream', + attributes: { [RPC_REQUEST_ID_ATTR]: requestId }, + }); + + // Send the RPC request now so the receiver knows to expect a data stream + await this.sendRpcRequestPacket( + destinationIdentity, + requestId, + method, + '', + undefined, + responseTimeout, + ); + await gzipCompressToWriter(payload, writer); + await writer.close(); + return; + } + + case 'compressed': + // Medium payload: compress inline + requestCompressedPayload = await gzipCompress(payload); + requestPayload = ''; + break; + + case 'regular': + default: + // Small payload: just include the payload directly, uncompressed + requestPayload = payload; + break; + } + + await this.sendRpcRequestPacket( + destinationIdentity, + requestId, + method, + requestPayload, + requestCompressedPayload, + responseTimeout, + ); + } + + private async sendRpcRequestPacket( + destinationIdentity: string, + requestId: string, + method: string, + payload: string | undefined, + compressedPayload: Uint8Array | undefined, + responseTimeout: number, + ) { + const packet = new DataPacket({ + destinationIdentities: [destinationIdentity], + kind: DataPacket_Kind.RELIABLE, + value: { + case: 'rpcRequest', + value: new RpcRequest({ + id: requestId, + method, + payload: payload ?? '', + compressedPayload: compressedPayload ?? new Uint8Array(), + responseTimeoutMs: responseTimeout, + version: 1, + }), + }, + }); + + await this.engine.sendDataPacket(packet, DataPacket_Kind.RELIABLE); + } + + /** + * Handle an incoming byte stream containing an RPC response payload. + * Decompresses the stream and resolves/rejects the pending data stream future. + */ + async handleIncomingDataStream(reader: ByteStreamReader, rpcId: string) { + let decompressed: string; + try { + decompressed = await gzipDecompressFromReader(reader); + } catch (e) { + this.rejectDataStream(rpcId, e instanceof Error ? e : new Error(String(e))); + return; + } + this.resolveDataStream(rpcId, decompressed); + } + + /** + * Wait for an RPC response data stream to arrive and return its decompressed payload. + */ + private waitForDataStream(requestId: string): Promise { + const existing = this.pendingDataStreams.get(requestId); + if (existing) { + return existing.promise; + } + + const future = new Future(); + this.pendingDataStreams.set(requestId, future); + return future.promise; + } + + /** + * Called by Room's byte stream handler when a data stream tagged with + * lk.rpc_response_id arrives. + */ + resolveDataStream(requestId: string, payload: string) { + const existing = this.pendingDataStreams.get(requestId); + if (existing) { + existing.resolve?.(payload); + this.pendingDataStreams.delete(requestId); + } else { + const future = new Future(); + future.resolve?.(payload); + this.pendingDataStreams.set(requestId, future); + } + } + + rejectDataStream(requestId: string, error: Error) { + const existing = this.pendingDataStreams.get(requestId); + if (existing) { + existing.reject?.(error); + this.pendingDataStreams.delete(requestId); + } + } +} diff --git a/src/room/rpc/RpcServerManager.ts b/src/room/rpc/RpcServerManager.ts new file mode 100644 index 0000000000..d26010d9a2 --- /dev/null +++ b/src/room/rpc/RpcServerManager.ts @@ -0,0 +1,255 @@ +// SPDX-FileCopyrightText: 2024 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +import { type StructuredLogger } from '../../logger'; +import { CLIENT_PROTOCOL_GZIP_RPC } from '../../version'; +import type RTCEngine from '../RTCEngine'; +import type { ByteStreamReader } from '../data-stream/incoming/StreamReader'; +import type OutgoingDataStreamManager from '../data-stream/outgoing/OutgoingDataStreamManager'; +import type Participant from '../participant/Participant'; +import { Future } from '../utils'; +import { + COMPRESS_MIN_BYTES, + DATA_STREAM_MIN_BYTES, + MAX_PAYLOAD_BYTES, + RPC_DATA_STREAM_TOPIC, + RPC_RESPONSE_ID_ATTR, + RpcError, + type RpcInvocationData, + byteLength, + gzipCompress, + gzipCompressToWriter, + gzipDecompress, + gzipDecompressFromReader, +} from './utils'; + +/** + * Manages the server (handler) side of RPC: processing incoming requests, + * managing registered method handlers, and sending responses. + * @internal + */ +export default class RpcServerManager { + private engine: RTCEngine; + + private log: StructuredLogger; + + private outgoingDataStreamManager: OutgoingDataStreamManager; + + private getRemoteParticipantClientProtocol: (identity: Participant['identity']) => number; + + private rpcHandlers: Map Promise> = new Map(); + + private pendingDataStreams = new Map>(); + + constructor( + engine: RTCEngine, + log: StructuredLogger, + outgoingDataStreamManager: OutgoingDataStreamManager, + getRemoteParticipantClientProtocol: (identity: Participant['identity']) => number, + ) { + this.engine = engine; + this.log = log; + this.outgoingDataStreamManager = outgoingDataStreamManager; + this.getRemoteParticipantClientProtocol = getRemoteParticipantClientProtocol; + } + + setupEngine(engine: RTCEngine) { + this.engine = engine; + } + + registerRpcMethod(method: string, handler: (data: RpcInvocationData) => Promise) { + if (this.rpcHandlers.has(method)) { + throw Error( + `RPC handler already registered for method ${method}, unregisterRpcMethod before trying to register again`, + ); + } + this.rpcHandlers.set(method, handler); + } + + unregisterRpcMethod(method: string) { + this.rpcHandlers.delete(method); + } + + async handleIncomingRpcRequest( + callerIdentity: string, + requestId: string, + method: string, + payload: string, + compressedPayload: Uint8Array, + responseTimeout: number, + version: number, + ) { + await this.engine.publishRpcAck(callerIdentity, requestId); + + if (version !== 1) { + await this.engine.publishRpcResponse( + callerIdentity, + requestId, + null, + RpcError.builtIn('UNSUPPORTED_VERSION'), + ); + return; + } + + // Resolve the actual payload from compressed or data stream sources + let resolvedPayload = payload; + if (compressedPayload && compressedPayload.length > 0) { + try { + resolvedPayload = await gzipDecompress(compressedPayload); + } catch (e) { + this.log.error('Failed to decompress RPC request payload', e); + await this.engine.publishRpcResponse( + callerIdentity, + requestId, + null, + RpcError.builtIn('APPLICATION_ERROR'), + ); + return; + } + } else if (payload === '') { + // Empty payload with empty compressedPayload means the request payload + // is arriving via a data stream tagged with lk.rpc_request_id + try { + resolvedPayload = await this.waitForDataStream(requestId); + } catch (e) { + this.log.error('Failed to receive RPC data stream payload', e); + await this.engine.publishRpcResponse( + callerIdentity, + requestId, + null, + RpcError.builtIn('APPLICATION_ERROR'), + ); + return; + } + } + + const handler = this.rpcHandlers.get(method); + + if (!handler) { + await this.engine.publishRpcResponse( + callerIdentity, + requestId, + null, + RpcError.builtIn('UNSUPPORTED_METHOD'), + ); + return; + } + + let response: string | null = null; + try { + response = await handler({ + requestId, + callerIdentity, + payload: resolvedPayload, + responseTimeout, + }); + } catch (error) { + let responseError; + if (error instanceof RpcError) { + responseError = error; + } else { + this.log.warn( + `Uncaught error returned by RPC handler for ${method}. Returning APPLICATION_ERROR instead.`, + error, + ); + responseError = RpcError.builtIn('APPLICATION_ERROR'); + } + + await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + return; + } + + // Determine how to send the response based on the caller's client protocol + const callerClientProtocol = this.getRemoteParticipantClientProtocol(callerIdentity); + + const responseBytes = byteLength(response ?? ''); + + if ( + callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && + responseBytes >= DATA_STREAM_MIN_BYTES + ) { + // Large response: create the data stream tagged with the request ID, + // send the RPC response with empty payload, then stream compressed chunks + // for lower TTFB + const writer = await this.outgoingDataStreamManager.streamBytes({ + topic: RPC_DATA_STREAM_TOPIC, + destinationIdentities: [callerIdentity], + mimeType: 'application/octet-stream', + attributes: { [RPC_RESPONSE_ID_ATTR]: requestId }, + }); + + await this.engine.publishRpcResponse(callerIdentity, requestId, '', null); + + await gzipCompressToWriter(response!, writer); + await writer.close(); + } else if ( + callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && + responseBytes >= COMPRESS_MIN_BYTES + ) { + // Medium response: compress inline + const compressed = await gzipCompress(response!); + await this.engine.publishRpcResponseCompressed(callerIdentity, requestId, compressed); + } else if (responseBytes > MAX_PAYLOAD_BYTES) { + // Legacy client can't handle large payloads + const responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); + this.log.warn(`RPC Response payload too large for ${method}`); + await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + } else { + // Small response or legacy client: send uncompressed + await this.engine.publishRpcResponse(callerIdentity, requestId, response, null); + } + } + + /** + * Handle an incoming byte stream containing an RPC request payload. + * Decompresses the stream and resolves/rejects the pending data stream future. + */ + async handleIncomingDataStream(reader: ByteStreamReader, rpcId: string) { + let decompressed: string; + try { + decompressed = await gzipDecompressFromReader(reader); + } catch (e) { + this.rejectDataStream(rpcId, e instanceof Error ? e : new Error(String(e))); + return; + } + this.resolveDataStream(rpcId, decompressed); + } + + /** + * Wait for an RPC request data stream to arrive and return its decompressed payload. + */ + private waitForDataStream(requestId: string): Promise { + const existing = this.pendingDataStreams.get(requestId); + if (existing) { + return existing.promise; + } + + const future = new Future(); + this.pendingDataStreams.set(requestId, future); + return future.promise; + } + + /** + * Called by Room's byte stream handler when a data stream tagged with + * lk.rpc_request_id arrives. + */ + resolveDataStream(requestId: string, payload: string) { + const existing = this.pendingDataStreams.get(requestId); + if (existing) { + existing.resolve?.(payload); + this.pendingDataStreams.delete(requestId); + } else { + const future = new Future(); + future.resolve?.(payload); + this.pendingDataStreams.set(requestId, future); + } + } + + rejectDataStream(requestId: string, error: Error) { + const existing = this.pendingDataStreams.get(requestId); + if (existing) { + existing.reject?.(error); + this.pendingDataStreams.delete(requestId); + } + } +} diff --git a/src/room/rpc/index.ts b/src/room/rpc/index.ts new file mode 100644 index 0000000000..656cf14188 --- /dev/null +++ b/src/room/rpc/index.ts @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2024 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +export { default as RpcClientManager } from './RpcClientManager'; +export { default as RpcServerManager } from './RpcServerManager'; +export { + COMPRESS_MIN_BYTES, + DATA_STREAM_MIN_BYTES, + MAX_PAYLOAD_BYTES, + type PerformRpcParams, + RPC_DATA_STREAM_TOPIC, + RPC_REQUEST_ID_ATTR, + RPC_RESPONSE_ID_ATTR, + RpcError, + type RpcInvocationData, + byteLength, + gzipCompress, + gzipCompressToWriter, + gzipDecompress, + gzipDecompressFromReader, + truncateBytes, +} from './utils'; diff --git a/src/room/rpc.ts b/src/room/rpc/utils.ts similarity index 100% rename from src/room/rpc.ts rename to src/room/rpc/utils.ts From c7ca54b35a54c5652b38235590df3a3d9bc86ab3 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Mar 2026 12:41:07 -0400 Subject: [PATCH 07/54] feat: cleanup code and don't create RPCRequest / RPCResponse in data stream path --- src/room/Room.ts | 19 +- src/room/participant/LocalParticipant.ts | 12 +- src/room/rpc/RpcClientManager.ts | 393 +++++++++++------------ src/room/rpc/RpcServerManager.ts | 158 +++++---- src/room/rpc/utils.ts | 7 + 5 files changed, 300 insertions(+), 289 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index d530633cd1..efaa46d2b7 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -80,7 +80,6 @@ import { type ConnectionQuality, ParticipantKind } from './participant/Participa import RemoteParticipant from './participant/RemoteParticipant'; import { RPC_DATA_STREAM_TOPIC, - RPC_REQUEST_ID_ATTR, RPC_RESPONSE_ID_ATTR, RpcClientManager, type RpcInvocationData, @@ -1826,7 +1825,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) }); this.emit(RoomEvent.ParticipantDisconnected, participant); participant.setDisconnected(); - this.localParticipant?.handleParticipantDisconnected(participant.identity); + this.rpcClientManager.handleParticipantDisconnected(participant.identity); } // updates are sent only when there's a change to speaker ordering @@ -2383,24 +2382,20 @@ class Room extends (EventEmitter as new () => TypedEmitter) } private getRemoteParticipantClientProtocol(identity: Participant["identity"]) { - console.info('REMOTE', identity, this.remoteParticipants); return this.remoteParticipants.get(identity)?.clientProtocol ?? CLIENT_PROTOCOL_DEFAULT; } private registerRpcDataStreamHandler() { this.incomingDataStreamManager.registerByteStreamHandler( RPC_DATA_STREAM_TOPIC, - async (reader, _participantInfo) => { - const attrs = reader.info.attributes ?? {}; - const requestId = attrs[RPC_REQUEST_ID_ATTR]; - const responseId = attrs[RPC_RESPONSE_ID_ATTR]; - - if (requestId) { - await this.rpcServerManager.handleIncomingDataStream(reader, requestId); - } else if (responseId) { + async (reader, { identity }) => { + const attributes = reader.info.attributes ?? {}; + const responseId = attributes[RPC_RESPONSE_ID_ATTR]; + + if (responseId) { await this.rpcClientManager.handleIncomingDataStream(reader, responseId); } else { - this.log.warn('Received RPC DataStream without a request/response ID attribute'); + await this.rpcServerManager.handleIncomingDataStream(reader, identity, attributes); } }, ); diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index f0153d4e29..04b8456f3d 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -260,8 +260,7 @@ export default class LocalParticipant extends Participant { .on(EngineEvent.LocalTrackUnpublished, this.handleLocalTrackUnpublished) .on(EngineEvent.SubscribedQualityUpdate, this.handleSubscribedQualityUpdate) .on(EngineEvent.Closing, this.handleClosing) - .on(EngineEvent.SignalRequestResponse, this.handleSignalRequestResponse) - .on(EngineEvent.DataPacketReceived, this.handleDataPacket); + .on(EngineEvent.SignalRequestResponse, this.handleSignalRequestResponse); } private handleReconnecting = () => { @@ -345,10 +344,6 @@ export default class LocalParticipant extends Participant { } }; - private handleDataPacket = async (packet: DataPacket) => { - await this.rpcClientManager.handleDataPacket(packet); - }; - /** * Sets and updates the metadata of the local participant. * Note: this requires `canUpdateOwnMetadata` permission. @@ -1847,11 +1842,6 @@ export default class LocalParticipant extends Participant { } } - /** @internal */ - handleParticipantDisconnected(participantIdentity: string) { - this.rpcClientManager.handleParticipantDisconnected(participantIdentity); - } - /** @internal */ setEnabledPublishCodecs(codecs: Codec[]) { this.enabledPublishVideoCodecs = codecs.filter( diff --git a/src/room/rpc/RpcClientManager.ts b/src/room/rpc/RpcClientManager.ts index b7963bc07f..26ccf76e08 100644 --- a/src/room/rpc/RpcClientManager.ts +++ b/src/room/rpc/RpcClientManager.ts @@ -4,12 +4,9 @@ import { DataPacket, DataPacket_Kind, - RpcAck, RpcRequest, - RpcResponse, } from '@livekit/protocol'; import { type StructuredLogger } from '../../logger'; -import TypedPromise from '../../utils/TypedPromise'; import type RTCEngine from '../RTCEngine'; import type OutgoingDataStreamManager from '../data-stream/outgoing/OutgoingDataStreamManager'; import type Participant from '../participant/Participant'; @@ -22,6 +19,8 @@ import { type PerformRpcParams, RPC_DATA_STREAM_TOPIC, RPC_REQUEST_ID_ATTR, + RPC_REQUEST_METHOD_ATTR, + RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR, RpcError, byteLength, gzipCompress, @@ -29,6 +28,8 @@ import { gzipDecompress, gzipDecompressFromReader, } from './utils'; +import { CLIENT_PROTOCOL_GZIP_RPC } from '../../version'; +import { EngineEvent } from '../events'; /** * Manages the client (caller) side of RPC: sending requests, tracking pending @@ -47,15 +48,13 @@ export default class RpcClientManager { private pendingAcks = new Map void; participantIdentity: string }>(); private pendingResponses = new Map< - string, + string /* request id */, { - resolve: (payload: string | null, error: RpcError | null) => void; + completionFuture: Future, participantIdentity: string; } >(); - private pendingDataStreams = new Map>(); - constructor( engine: RTCEngine, log: StructuredLogger, @@ -70,172 +69,81 @@ export default class RpcClientManager { setupEngine(engine: RTCEngine) { this.engine = engine; + + this.engine.on(EngineEvent.DataPacketReceived, this.handleDataPacket); } - performRpc({ + async performRpc({ destinationIdentity, method, payload, - responseTimeout = 15000, - }: PerformRpcParams): TypedPromise { - const maxRoundTripLatency = 7000; - const minEffectiveTimeout = maxRoundTripLatency + 1000; - - return new TypedPromise(async (resolve, reject) => { - const remoteClientProtocol = this.getRemoteParticipantClientProtocol(destinationIdentity); - const payloadBytes = byteLength(payload); - - // Only enforce the legacy size limit when compression is not available - if (payloadBytes > MAX_PAYLOAD_BYTES && remoteClientProtocol < 1) { - reject(RpcError.builtIn('REQUEST_PAYLOAD_TOO_LARGE')); - return; - } + responseTimeout: responseTimeoutMs = 15000, + }: PerformRpcParams): Promise { + const maxRoundTripLatencyMs = 7000; + const minEffectiveTimeoutMs = maxRoundTripLatencyMs + 1000; - if ( - this.engine.latestJoinResponse?.serverInfo?.version && - compareVersions(this.engine.latestJoinResponse?.serverInfo?.version, '1.8.0') < 0 - ) { - reject(RpcError.builtIn('UNSUPPORTED_SERVER')); - return; - } + const remoteClientProtocol = this.getRemoteParticipantClientProtocol(destinationIdentity); + const payloadBytes = byteLength(payload); + + // Only enforce the legacy size limit when compression is not available + if (payloadBytes > MAX_PAYLOAD_BYTES && remoteClientProtocol < 1) { + throw RpcError.builtIn('REQUEST_PAYLOAD_TOO_LARGE'); + } - const effectiveTimeout = Math.max(responseTimeout, minEffectiveTimeout); - const id = crypto.randomUUID(); + if ( + this.engine.latestJoinResponse?.serverInfo?.version && + compareVersions(this.engine.latestJoinResponse?.serverInfo?.version, '1.8.0') < 0 + ) { + throw RpcError.builtIn('UNSUPPORTED_SERVER'); + } - await this.publishRpcRequest( - destinationIdentity, - id, - method, - payload, - effectiveTimeout, - remoteClientProtocol, - ); + const effectiveTimeoutMs = Math.max(responseTimeoutMs, minEffectiveTimeoutMs); + const id = crypto.randomUUID(); - const ackTimeoutId = setTimeout(() => { - this.pendingAcks.delete(id); - reject(RpcError.builtIn('CONNECTION_TIMEOUT')); - this.pendingResponses.delete(id); - clearTimeout(responseTimeoutId); - }, maxRoundTripLatency); + await this.publishRpcRequest( + destinationIdentity, + id, + method, + payload, + effectiveTimeoutMs, + remoteClientProtocol, + ); - this.pendingAcks.set(id, { - resolve: () => { - clearTimeout(ackTimeoutId); - }, - participantIdentity: destinationIdentity, - }); + const completionFuture = new Future(); - const responseTimeoutId = setTimeout(() => { - this.pendingResponses.delete(id); - reject(RpcError.builtIn('RESPONSE_TIMEOUT')); - }, responseTimeout); - - this.pendingResponses.set(id, { - resolve: (responsePayload: string | null, responseError: RpcError | null) => { - clearTimeout(responseTimeoutId); - if (this.pendingAcks.has(id)) { - this.log.warn('RPC response received before ack', id); - this.pendingAcks.delete(id); - clearTimeout(ackTimeoutId); - } + const ackTimeoutId = setTimeout(() => { + this.pendingAcks.delete(id); + completionFuture.reject?.(RpcError.builtIn('CONNECTION_TIMEOUT')); + this.pendingResponses.delete(id); + clearTimeout(responseTimeoutId); + }, maxRoundTripLatencyMs); - if (responseError) { - reject(responseError); - } else { - resolve(responsePayload ?? ''); - } - }, - participantIdentity: destinationIdentity, - }); + this.pendingAcks.set(id, { + resolve: () => { + clearTimeout(ackTimeoutId); + }, + participantIdentity: destinationIdentity, }); - } - /** - * Handle an incoming data packet that may contain an RPC ack or response. - * Returns true if the packet was handled. - */ - async handleDataPacket(packet: DataPacket): Promise { - switch (packet.value.case) { - case 'rpcResponse': { - const rpcResponse = packet.value.value as RpcResponse; - let payload: string | null = null; - let error: RpcError | null = null; - - if (rpcResponse.value.case === 'payload') { - payload = rpcResponse.value.value; - } else if (rpcResponse.value.case === 'error') { - error = RpcError.fromProto(rpcResponse.value.value); - } else if (rpcResponse.value.case === 'compressedPayload') { - try { - payload = await gzipDecompress(rpcResponse.value.value); - } catch (e) { - this.log.error('Failed to decompress RPC response', e); - error = RpcError.builtIn('APPLICATION_ERROR'); - } - } + const responseTimeoutId = setTimeout(() => { + this.pendingResponses.delete(id); + completionFuture.reject?.(RpcError.builtIn('RESPONSE_TIMEOUT')); + }, responseTimeoutMs); - // Empty payload with no error means the response payload is arriving - // via a data stream tagged with lk.rpc_response_id - if (!error && payload === '') { - try { - payload = await this.waitForDataStream(rpcResponse.requestId); - } catch (e) { - this.log.error('Failed to receive RPC data stream response', e); - error = RpcError.builtIn('APPLICATION_ERROR'); - payload = null; - } - } + this.pendingResponses.set(id, { + completionFuture, + participantIdentity: destinationIdentity, + }); - this.handleIncomingRpcResponse(rpcResponse.requestId, payload, error); - return true; - } - case 'rpcAck': { - const rpcAck = packet.value.value as RpcAck; - this.handleIncomingRpcAck(rpcAck.requestId); - return true; - } - default: - return false; - } - } + return completionFuture.promise.finally(() => { + clearTimeout(responseTimeoutId); - handleParticipantDisconnected(participantIdentity: string) { - for (const [id, { participantIdentity: pendingIdentity }] of this.pendingAcks) { - if (pendingIdentity === participantIdentity) { + if (this.pendingAcks.has(id)) { + this.log.warn('RPC response received before ack', id); this.pendingAcks.delete(id); + clearTimeout(ackTimeoutId); } - } - - for (const [id, { participantIdentity: pendingIdentity, resolve }] of this.pendingResponses) { - if (pendingIdentity === participantIdentity) { - resolve(null, RpcError.builtIn('RECIPIENT_DISCONNECTED')); - this.pendingResponses.delete(id); - } - } - } - - private handleIncomingRpcAck(requestId: string) { - const handler = this.pendingAcks.get(requestId); - if (handler) { - handler.resolve(); - this.pendingAcks.delete(requestId); - } else { - console.error('Ack received for unexpected RPC request', requestId); - } - } - - private handleIncomingRpcResponse( - requestId: string, - payload: string | null, - error: RpcError | null, - ) { - const handler = this.pendingResponses.get(requestId); - if (handler) { - handler.resolve(payload, error); - this.pendingResponses.delete(requestId); - } else { - console.error('Response received for unexpected RPC request', requestId); - } + }); } private async publishRpcRequest( @@ -249,15 +157,13 @@ export default class RpcClientManager { const payloadBytes = byteLength(payload); let mode: 'regular' | 'compressed' | 'compressed-data-stream' = 'regular'; - if (remoteClientProtocol >= 1 && payloadBytes >= COMPRESS_MIN_BYTES) { + if (remoteClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && payloadBytes >= COMPRESS_MIN_BYTES) { mode = 'compressed'; } if (mode === 'compressed' && payloadBytes >= DATA_STREAM_MIN_BYTES) { mode = 'compressed-data-stream'; } - let requestPayload; - let requestCompressedPayload; switch (mode) { case 'compressed-data-stream': { // Large payload: create the data stream tagged with the request ID, @@ -267,44 +173,43 @@ export default class RpcClientManager { topic: RPC_DATA_STREAM_TOPIC, destinationIdentities: [destinationIdentity], mimeType: 'application/octet-stream', - attributes: { [RPC_REQUEST_ID_ATTR]: requestId }, + attributes: { + [RPC_REQUEST_ID_ATTR]: requestId, + [RPC_REQUEST_METHOD_ATTR]: method, + [RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR]: `${responseTimeout}`, + }, }); + await gzipCompressToWriter(payload, writer); + await writer.close(); + return; + } - // Send the RPC request now so the receiver knows to expect a data stream + case 'compressed': + // Medium payload: compress inline + const compressedPayload = await gzipCompress(payload); await this.sendRpcRequestPacket( destinationIdentity, requestId, method, '', - undefined, + compressedPayload, responseTimeout, ); - await gzipCompressToWriter(payload, writer); - await writer.close(); - return; - } - - case 'compressed': - // Medium payload: compress inline - requestCompressedPayload = await gzipCompress(payload); - requestPayload = ''; break; case 'regular': default: // Small payload: just include the payload directly, uncompressed - requestPayload = payload; + await this.sendRpcRequestPacket( + destinationIdentity, + requestId, + method, + payload, + undefined, + responseTimeout, + ); break; } - - await this.sendRpcRequestPacket( - destinationIdentity, - requestId, - method, - requestPayload, - requestCompressedPayload, - responseTimeout, - ); } private async sendRpcRequestPacket( @@ -334,56 +239,120 @@ export default class RpcClientManager { await this.engine.sendDataPacket(packet, DataPacket_Kind.RELIABLE); } + /** + * Handle an incoming data packet that may contain an RPC ack or response. + * Returns true if the packet was handled. + */ + private async handleDataPacket(packet: DataPacket): Promise { + switch (packet.value.case) { + case 'rpcResponse': { + const rpcResponse = packet.value.value; + + switch (rpcResponse.value.case) { + case 'payload': { + this.handleIncomingRpcResponseSuccess(rpcResponse.requestId, rpcResponse.value.value); + return true; + } + + case 'compressedPayload': { + let payload; + try { + payload = await gzipDecompress(rpcResponse.value.value); + } catch (e) { + this.log.error('Failed to decompress RPC response', e); + this.handleIncomingRpcResponseFailure( + rpcResponse.requestId, + RpcError.builtIn('APPLICATION_ERROR'), + ); + return true; + } + this.handleIncomingRpcResponseSuccess(rpcResponse.requestId, payload); + return true; + } + + case 'error': { + const error = RpcError.fromProto(rpcResponse.value.value); + this.handleIncomingRpcResponseFailure(rpcResponse.requestId, error); + return true; + } + + default: { + this.log.warn(`Error handling RPC response data packet: unknown rpcResponse.value.case found (${rpcResponse.value.case})`); + return false; + } + } + } + case 'rpcAck': { + const rpcAck = packet.value.value; + + const handler = this.pendingAcks.get(rpcAck.requestId); + if (handler) { + handler.resolve(); + this.pendingAcks.delete(rpcAck.requestId); + } else { + this.log.error(`Ack received for unexpected RPC request: ${rpcAck.requestId}`); + } + + return true; + } + default: + return false; + } + } + /** * Handle an incoming byte stream containing an RPC response payload. * Decompresses the stream and resolves/rejects the pending data stream future. */ - async handleIncomingDataStream(reader: ByteStreamReader, rpcId: string) { - let decompressed: string; + async handleIncomingDataStream( + reader: ByteStreamReader, + responseId: string, + ) { + let decompressedPayload: string; try { - decompressed = await gzipDecompressFromReader(reader); + decompressedPayload = await gzipDecompressFromReader(reader); } catch (e) { - this.rejectDataStream(rpcId, e instanceof Error ? e : new Error(String(e))); + this.log.warn(`Error decompressing RPC response payload: ${e}`); + this.handleIncomingRpcResponseFailure(responseId, RpcError.builtIn('APPLICATION_ERROR')); return; } - this.resolveDataStream(rpcId, decompressed); + + this.handleIncomingRpcResponseSuccess(responseId, decompressedPayload); } - /** - * Wait for an RPC response data stream to arrive and return its decompressed payload. - */ - private waitForDataStream(requestId: string): Promise { - const existing = this.pendingDataStreams.get(requestId); - if (existing) { - return existing.promise; + private handleIncomingRpcResponseSuccess(requestId: string, payload: string) { + const handler = this.pendingResponses.get(requestId); + if (handler) { + handler.completionFuture.resolve?.(payload); + this.pendingResponses.delete(requestId); + } else { + console.error('Response received for unexpected RPC request', requestId); } - - const future = new Future(); - this.pendingDataStreams.set(requestId, future); - return future.promise; } - /** - * Called by Room's byte stream handler when a data stream tagged with - * lk.rpc_response_id arrives. - */ - resolveDataStream(requestId: string, payload: string) { - const existing = this.pendingDataStreams.get(requestId); - if (existing) { - existing.resolve?.(payload); - this.pendingDataStreams.delete(requestId); + private handleIncomingRpcResponseFailure(requestId: string, error: RpcError) { + const handler = this.pendingResponses.get(requestId); + if (handler) { + handler.completionFuture.reject?.(error); + this.pendingResponses.delete(requestId); } else { - const future = new Future(); - future.resolve?.(payload); - this.pendingDataStreams.set(requestId, future); + console.error('Response received for unexpected RPC request', requestId); } } - rejectDataStream(requestId: string, error: Error) { - const existing = this.pendingDataStreams.get(requestId); - if (existing) { - existing.reject?.(error); - this.pendingDataStreams.delete(requestId); + /** @internal */ + handleParticipantDisconnected(participantIdentity: string) { + for (const [id, { participantIdentity: pendingIdentity }] of this.pendingAcks) { + if (pendingIdentity === participantIdentity) { + this.pendingAcks.delete(id); + } + } + + for (const [id, { participantIdentity: pendingIdentity, completionFuture }] of this.pendingResponses) { + if (pendingIdentity === participantIdentity) { + completionFuture.reject?.(RpcError.builtIn('RECIPIENT_DISCONNECTED')); + this.pendingResponses.delete(id); + } } } } diff --git a/src/room/rpc/RpcServerManager.ts b/src/room/rpc/RpcServerManager.ts index d26010d9a2..d89fb78bdb 100644 --- a/src/room/rpc/RpcServerManager.ts +++ b/src/room/rpc/RpcServerManager.ts @@ -7,12 +7,14 @@ import type RTCEngine from '../RTCEngine'; import type { ByteStreamReader } from '../data-stream/incoming/StreamReader'; import type OutgoingDataStreamManager from '../data-stream/outgoing/OutgoingDataStreamManager'; import type Participant from '../participant/Participant'; -import { Future } from '../utils'; import { COMPRESS_MIN_BYTES, DATA_STREAM_MIN_BYTES, MAX_PAYLOAD_BYTES, RPC_DATA_STREAM_TOPIC, + RPC_REQUEST_ID_ATTR, + RPC_REQUEST_METHOD_ATTR, + RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR, RPC_RESPONSE_ID_ATTR, RpcError, type RpcInvocationData, @@ -39,8 +41,6 @@ export default class RpcServerManager { private rpcHandlers: Map Promise> = new Map(); - private pendingDataStreams = new Map>(); - constructor( engine: RTCEngine, log: StructuredLogger, @@ -106,21 +106,6 @@ export default class RpcServerManager { ); return; } - } else if (payload === '') { - // Empty payload with empty compressedPayload means the request payload - // is arriving via a data stream tagged with lk.rpc_request_id - try { - resolvedPayload = await this.waitForDataStream(requestId); - } catch (e) { - this.log.error('Failed to receive RPC data stream payload', e); - await this.engine.publishRpcResponse( - callerIdentity, - requestId, - null, - RpcError.builtIn('APPLICATION_ERROR'), - ); - return; - } } const handler = this.rpcHandlers.get(method); @@ -204,52 +189,117 @@ export default class RpcServerManager { * Handle an incoming byte stream containing an RPC request payload. * Decompresses the stream and resolves/rejects the pending data stream future. */ - async handleIncomingDataStream(reader: ByteStreamReader, rpcId: string) { - let decompressed: string; + async handleIncomingDataStream( + reader: ByteStreamReader, + callerIdentity: Participant["identity"], + dataStreamAttrs: Record + ) { + const requestId = dataStreamAttrs[RPC_REQUEST_ID_ATTR]; + const method = dataStreamAttrs[RPC_REQUEST_METHOD_ATTR]; + const responseTimeout = parseInt(dataStreamAttrs[RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR], 10); + + if (!requestId || !method || !isNaN(responseTimeout)) { + this.log.warn(`RPC data stream malformed: ${RPC_REQUEST_ID_ATTR} / ${RPC_REQUEST_METHOD_ATTR} / ${RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR} not set.`); + await this.engine.publishRpcResponse( + callerIdentity, + requestId, + null, + RpcError.builtIn('APPLICATION_ERROR'), + ); + } + + let decompressedPayload: string; try { - decompressed = await gzipDecompressFromReader(reader); + decompressedPayload = await gzipDecompressFromReader(reader); } catch (e) { - this.rejectDataStream(rpcId, e instanceof Error ? e : new Error(String(e))); + this.log.warn(`Error decompressing RPC request payload: ${e}`); + await this.engine.publishRpcResponse( + callerIdentity, + requestId, + null, + RpcError.builtIn('APPLICATION_ERROR'), + ); return; } - this.resolveDataStream(rpcId, decompressed); - } - /** - * Wait for an RPC request data stream to arrive and return its decompressed payload. - */ - private waitForDataStream(requestId: string): Promise { - const existing = this.pendingDataStreams.get(requestId); - if (existing) { - return existing.promise; + const handler = this.rpcHandlers.get(method); + + if (!handler) { + await this.engine.publishRpcResponse( + callerIdentity, + requestId, + null, + RpcError.builtIn('UNSUPPORTED_METHOD'), + ); + return; } - const future = new Future(); - this.pendingDataStreams.set(requestId, future); - return future.promise; - } + let response: string | null = null; + try { + response = await handler({ + requestId, + callerIdentity, + payload: decompressedPayload, + responseTimeout, + }); + } catch (error) { + let responseError; + if (error instanceof RpcError) { + responseError = error; + } else { + this.log.warn( + `Uncaught error returned by RPC handler for ${method}. Returning APPLICATION_ERROR instead.`, + error, + ); + responseError = RpcError.builtIn('APPLICATION_ERROR'); + } - /** - * Called by Room's byte stream handler when a data stream tagged with - * lk.rpc_request_id arrives. - */ - resolveDataStream(requestId: string, payload: string) { - const existing = this.pendingDataStreams.get(requestId); - if (existing) { - existing.resolve?.(payload); - this.pendingDataStreams.delete(requestId); - } else { - const future = new Future(); - future.resolve?.(payload); - this.pendingDataStreams.set(requestId, future); + await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + return; + } + + // Determine how to send the response based on the caller's client protocol + const callerClientProtocol = this.getRemoteParticipantClientProtocol(callerIdentity); + + const responseBytes = byteLength(response ?? ''); + + if ( + callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && + responseBytes >= DATA_STREAM_MIN_BYTES + ) { + // Large response: create the data stream tagged with the request ID, + // send the RPC response with empty payload, then stream compressed chunks + // for lower TTFB + const writer = await this.outgoingDataStreamManager.streamBytes({ + topic: RPC_DATA_STREAM_TOPIC, + destinationIdentities: [callerIdentity], + mimeType: 'application/octet-stream', + attributes: { [RPC_RESPONSE_ID_ATTR]: requestId }, + }); + await gzipCompressToWriter(response!, writer); + await writer.close(); + return; } - } - rejectDataStream(requestId: string, error: Error) { - const existing = this.pendingDataStreams.get(requestId); - if (existing) { - existing.reject?.(error); - this.pendingDataStreams.delete(requestId); + if ( + callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && + responseBytes >= COMPRESS_MIN_BYTES + ) { + // Medium response: compress inline + const compressed = await gzipCompress(response); + await this.engine.publishRpcResponseCompressed(callerIdentity, requestId, compressed); + return; + } + + if (responseBytes > MAX_PAYLOAD_BYTES) { + // Legacy client can't handle large payloads + const responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); + this.log.warn(`RPC Response payload too large for ${method}`); + await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + return; } + + // Small response or legacy client: send uncompressed + await this.engine.publishRpcResponse(callerIdentity, requestId, response, null); } } diff --git a/src/room/rpc/utils.ts b/src/room/rpc/utils.ts index f4cff119e6..17062668ec 100644 --- a/src/room/rpc/utils.ts +++ b/src/room/rpc/utils.ts @@ -163,6 +163,13 @@ export const DATA_STREAM_MIN_BYTES = 15360; // 15 KB */ export const RPC_REQUEST_ID_ATTR = 'lk.rpc_request_id'; + +/** @internal */ +export const RPC_REQUEST_METHOD_ATTR = 'lk.rpc_request_method'; + +/** @internal */ +export const RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR = 'lk.rpc_request_response_timeout_ms'; + /** * Attribute key set on a data stream to associate it with an RPC response. * @internal From 23fd785dde0bc73e5b26ed877f5dfae60d299768 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Mar 2026 12:50:30 -0400 Subject: [PATCH 08/54] fix: make functions into arrow fns to work around `this` being unset --- src/room/Room.ts | 2 +- src/room/rpc/RpcClientManager.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index efaa46d2b7..ddcfa812c1 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -2381,7 +2381,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) } } - private getRemoteParticipantClientProtocol(identity: Participant["identity"]) { + private getRemoteParticipantClientProtocol = (identity: Participant["identity"]) => { return this.remoteParticipants.get(identity)?.clientProtocol ?? CLIENT_PROTOCOL_DEFAULT; } diff --git a/src/room/rpc/RpcClientManager.ts b/src/room/rpc/RpcClientManager.ts index 26ccf76e08..e9a127ae69 100644 --- a/src/room/rpc/RpcClientManager.ts +++ b/src/room/rpc/RpcClientManager.ts @@ -243,7 +243,7 @@ export default class RpcClientManager { * Handle an incoming data packet that may contain an RPC ack or response. * Returns true if the packet was handled. */ - private async handleDataPacket(packet: DataPacket): Promise { + private handleDataPacket = async (packet: DataPacket): Promise => { switch (packet.value.case) { case 'rpcResponse': { const rpcResponse = packet.value.value; @@ -298,7 +298,7 @@ export default class RpcClientManager { default: return false; } - } + }; /** * Handle an incoming byte stream containing an RPC response payload. From d6332fbdff93f6e5f9ccbcfad54aead344ff7782 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Mar 2026 13:06:37 -0400 Subject: [PATCH 09/54] refactor: break up long if / else if / else chain --- src/room/rpc/RpcServerManager.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/room/rpc/RpcServerManager.ts b/src/room/rpc/RpcServerManager.ts index d89fb78bdb..a3dcea4257 100644 --- a/src/room/rpc/RpcServerManager.ts +++ b/src/room/rpc/RpcServerManager.ts @@ -167,18 +167,25 @@ export default class RpcServerManager { await gzipCompressToWriter(response!, writer); await writer.close(); - } else if ( + return; + } + + if ( callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && responseBytes >= COMPRESS_MIN_BYTES ) { // Medium response: compress inline const compressed = await gzipCompress(response!); await this.engine.publishRpcResponseCompressed(callerIdentity, requestId, compressed); - } else if (responseBytes > MAX_PAYLOAD_BYTES) { + return; + } + + if (responseBytes > MAX_PAYLOAD_BYTES) { // Legacy client can't handle large payloads const responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); this.log.warn(`RPC Response payload too large for ${method}`); await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + } else { // Small response or legacy client: send uncompressed await this.engine.publishRpcResponse(callerIdentity, requestId, response, null); From 1678a6316d2d0a97c6fb33f3577432f5441d0fb1 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Mar 2026 13:18:05 -0400 Subject: [PATCH 10/54] fix: add isCallerStillConnected to ensure that rpc drops responses for rpc calls that take a long time If a RPC call takes a long time and a participant disconnects half way through, just drop the return value. --- src/room/Room.ts | 1 + src/room/rpc/RpcClientManager.ts | 4 +- src/room/rpc/RpcServerManager.ts | 77 ++++++++++++++++++-------------- src/room/rpc/utils.ts | 6 +-- 4 files changed, 50 insertions(+), 38 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index ddcfa812c1..f57372a06d 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -1977,6 +1977,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) rpc.compressedPayload, rpc.responseTimeoutMs, rpc.version, + () => this.remoteParticipants.has(packet.participantIdentity), ); } }; diff --git a/src/room/rpc/RpcClientManager.ts b/src/room/rpc/RpcClientManager.ts index e9a127ae69..2872d5eff0 100644 --- a/src/room/rpc/RpcClientManager.ts +++ b/src/room/rpc/RpcClientManager.ts @@ -15,7 +15,7 @@ import type { ByteStreamReader } from '../data-stream/incoming/StreamReader'; import { COMPRESS_MIN_BYTES, DATA_STREAM_MIN_BYTES, - MAX_PAYLOAD_BYTES, + MAX_LEGACY_PAYLOAD_BYTES, type PerformRpcParams, RPC_DATA_STREAM_TOPIC, RPC_REQUEST_ID_ATTR, @@ -86,7 +86,7 @@ export default class RpcClientManager { const payloadBytes = byteLength(payload); // Only enforce the legacy size limit when compression is not available - if (payloadBytes > MAX_PAYLOAD_BYTES && remoteClientProtocol < 1) { + if (payloadBytes > MAX_LEGACY_PAYLOAD_BYTES && remoteClientProtocol < 1) { throw RpcError.builtIn('REQUEST_PAYLOAD_TOO_LARGE'); } diff --git a/src/room/rpc/RpcServerManager.ts b/src/room/rpc/RpcServerManager.ts index a3dcea4257..02fd2a39f0 100644 --- a/src/room/rpc/RpcServerManager.ts +++ b/src/room/rpc/RpcServerManager.ts @@ -10,7 +10,7 @@ import type Participant from '../participant/Participant'; import { COMPRESS_MIN_BYTES, DATA_STREAM_MIN_BYTES, - MAX_PAYLOAD_BYTES, + MAX_LEGACY_PAYLOAD_BYTES, RPC_DATA_STREAM_TOPIC, RPC_REQUEST_ID_ATTR, RPC_REQUEST_METHOD_ATTR, @@ -78,16 +78,19 @@ export default class RpcServerManager { compressedPayload: Uint8Array, responseTimeout: number, version: number, + isCallerStillConnected: () => boolean, ) { await this.engine.publishRpcAck(callerIdentity, requestId); if (version !== 1) { - await this.engine.publishRpcResponse( - callerIdentity, - requestId, - null, - RpcError.builtIn('UNSUPPORTED_VERSION'), - ); + if (isCallerStillConnected()) { + await this.engine.publishRpcResponse( + callerIdentity, + requestId, + null, + RpcError.builtIn('UNSUPPORTED_VERSION'), + ); + } return; } @@ -98,12 +101,14 @@ export default class RpcServerManager { resolvedPayload = await gzipDecompress(compressedPayload); } catch (e) { this.log.error('Failed to decompress RPC request payload', e); - await this.engine.publishRpcResponse( - callerIdentity, - requestId, - null, - RpcError.builtIn('APPLICATION_ERROR'), - ); + if (isCallerStillConnected()) { + await this.engine.publishRpcResponse( + callerIdentity, + requestId, + null, + RpcError.builtIn('APPLICATION_ERROR'), + ); + } return; } } @@ -111,12 +116,14 @@ export default class RpcServerManager { const handler = this.rpcHandlers.get(method); if (!handler) { - await this.engine.publishRpcResponse( - callerIdentity, - requestId, - null, - RpcError.builtIn('UNSUPPORTED_METHOD'), - ); + if (isCallerStillConnected()) { + await this.engine.publishRpcResponse( + callerIdentity, + requestId, + null, + RpcError.builtIn('UNSUPPORTED_METHOD'), + ); + } return; } @@ -140,7 +147,9 @@ export default class RpcServerManager { responseError = RpcError.builtIn('APPLICATION_ERROR'); } - await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + if (isCallerStillConnected()) { + await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + } return; } @@ -149,13 +158,13 @@ export default class RpcServerManager { const responseBytes = byteLength(response ?? ''); + // Large response: create the data stream tagged with the request ID, + // send the RPC response with empty payload, then stream compressed chunks + // for lower TTFB if ( callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && responseBytes >= DATA_STREAM_MIN_BYTES ) { - // Large response: create the data stream tagged with the request ID, - // send the RPC response with empty payload, then stream compressed chunks - // for lower TTFB const writer = await this.outgoingDataStreamManager.streamBytes({ topic: RPC_DATA_STREAM_TOPIC, destinationIdentities: [callerIdentity], @@ -163,31 +172,33 @@ export default class RpcServerManager { attributes: { [RPC_RESPONSE_ID_ATTR]: requestId }, }); - await this.engine.publishRpcResponse(callerIdentity, requestId, '', null); - - await gzipCompressToWriter(response!, writer); + await gzipCompressToWriter(response, writer); await writer.close(); return; } + // Medium response: compress inline if ( callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && responseBytes >= COMPRESS_MIN_BYTES ) { - // Medium response: compress inline const compressed = await gzipCompress(response!); await this.engine.publishRpcResponseCompressed(callerIdentity, requestId, compressed); return; } - if (responseBytes > MAX_PAYLOAD_BYTES) { - // Legacy client can't handle large payloads + // Legacy client can't handle large payloads + if (responseBytes > MAX_LEGACY_PAYLOAD_BYTES) { const responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); this.log.warn(`RPC Response payload too large for ${method}`); - await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + if (isCallerStillConnected()) { + await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + } + return; + } - } else { - // Small response or legacy client: send uncompressed + // Small response or legacy client: send uncompressed + if (isCallerStillConnected()) { await this.engine.publishRpcResponse(callerIdentity, requestId, response, null); } } @@ -298,7 +309,7 @@ export default class RpcServerManager { return; } - if (responseBytes > MAX_PAYLOAD_BYTES) { + if (responseBytes > MAX_LEGACY_PAYLOAD_BYTES) { // Legacy client can't handle large payloads const responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); this.log.warn(`RPC Response payload too large for ${method}`); diff --git a/src/room/rpc/utils.ts b/src/room/rpc/utils.ts index 17062668ec..50127c5b0e 100644 --- a/src/room/rpc/utils.ts +++ b/src/room/rpc/utils.ts @@ -139,11 +139,11 @@ export class RpcError extends Error { } /* - * Maximum payload size for RPC requests and responses when using the legacy (uncompressed) path. - * If a payload exceeds this size and the remote client does not support compression, + * Maximum payload size for RPC requests and responses when using the legacy (uncompressed / no data + * streams) path. If a payload exceeds this size and the remote client does not support compression, * the RPC call will fail with a REQUEST_PAYLOAD_TOO_LARGE(1402) or RESPONSE_PAYLOAD_TOO_LARGE(1504) error. */ -export const MAX_PAYLOAD_BYTES = 15360; // 15 KB +export const MAX_LEGACY_PAYLOAD_BYTES = 15360; // 15 KB /** * Payloads smaller than this are sent uncompressed (legacy path). From 7c3eafdae45ad5dc4d9bc20fc9d4c762e999829d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Mar 2026 13:22:03 -0400 Subject: [PATCH 11/54] fix: adjust copyright headers and remove obsolete rpc exports --- src/room/rpc/RpcClientManager.ts | 2 +- src/room/rpc/RpcServerManager.ts | 2 +- src/room/rpc/index.ts | 6 +----- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/room/rpc/RpcClientManager.ts b/src/room/rpc/RpcClientManager.ts index 2872d5eff0..73e1403e5c 100644 --- a/src/room/rpc/RpcClientManager.ts +++ b/src/room/rpc/RpcClientManager.ts @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 LiveKit, Inc. +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 import { diff --git a/src/room/rpc/RpcServerManager.ts b/src/room/rpc/RpcServerManager.ts index 02fd2a39f0..7bef2d8cd3 100644 --- a/src/room/rpc/RpcServerManager.ts +++ b/src/room/rpc/RpcServerManager.ts @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 LiveKit, Inc. +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 import { type StructuredLogger } from '../../logger'; diff --git a/src/room/rpc/index.ts b/src/room/rpc/index.ts index 656cf14188..338c46dd51 100644 --- a/src/room/rpc/index.ts +++ b/src/room/rpc/index.ts @@ -1,15 +1,11 @@ -// SPDX-FileCopyrightText: 2024 LiveKit, Inc. +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 export { default as RpcClientManager } from './RpcClientManager'; export { default as RpcServerManager } from './RpcServerManager'; export { - COMPRESS_MIN_BYTES, - DATA_STREAM_MIN_BYTES, - MAX_PAYLOAD_BYTES, type PerformRpcParams, RPC_DATA_STREAM_TOPIC, - RPC_REQUEST_ID_ATTR, RPC_RESPONSE_ID_ATTR, RpcError, type RpcInvocationData, From fe4e8875b071e866d24cfb043f89695a672221f4 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Mar 2026 13:47:23 -0400 Subject: [PATCH 12/54] fix: flip around response timeout nan check --- src/room/rpc/RpcServerManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/room/rpc/RpcServerManager.ts b/src/room/rpc/RpcServerManager.ts index 7bef2d8cd3..3f478a7604 100644 --- a/src/room/rpc/RpcServerManager.ts +++ b/src/room/rpc/RpcServerManager.ts @@ -216,7 +216,7 @@ export default class RpcServerManager { const method = dataStreamAttrs[RPC_REQUEST_METHOD_ATTR]; const responseTimeout = parseInt(dataStreamAttrs[RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR], 10); - if (!requestId || !method || !isNaN(responseTimeout)) { + if (!requestId || !method || Number.isNaN(responseTimeout)) { this.log.warn(`RPC data stream malformed: ${RPC_REQUEST_ID_ATTR} / ${RPC_REQUEST_METHOD_ATTR} / ${RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR} not set.`); await this.engine.publishRpcResponse( callerIdentity, From 42eceefe7d936265df58eb299753178e40556f25 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Mar 2026 15:06:09 -0400 Subject: [PATCH 13/54] fix: adjust compression / data stream thresholds to not be inclusive --- src/room/rpc/RpcClientManager.ts | 4 ++-- src/room/rpc/RpcServerManager.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/room/rpc/RpcClientManager.ts b/src/room/rpc/RpcClientManager.ts index 73e1403e5c..7b048e8394 100644 --- a/src/room/rpc/RpcClientManager.ts +++ b/src/room/rpc/RpcClientManager.ts @@ -157,10 +157,10 @@ export default class RpcClientManager { const payloadBytes = byteLength(payload); let mode: 'regular' | 'compressed' | 'compressed-data-stream' = 'regular'; - if (remoteClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && payloadBytes >= COMPRESS_MIN_BYTES) { + if (remoteClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && payloadBytes > COMPRESS_MIN_BYTES) { mode = 'compressed'; } - if (mode === 'compressed' && payloadBytes >= DATA_STREAM_MIN_BYTES) { + if (mode === 'compressed' && payloadBytes > DATA_STREAM_MIN_BYTES) { mode = 'compressed-data-stream'; } diff --git a/src/room/rpc/RpcServerManager.ts b/src/room/rpc/RpcServerManager.ts index 3f478a7604..7971626132 100644 --- a/src/room/rpc/RpcServerManager.ts +++ b/src/room/rpc/RpcServerManager.ts @@ -163,7 +163,7 @@ export default class RpcServerManager { // for lower TTFB if ( callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && - responseBytes >= DATA_STREAM_MIN_BYTES + responseBytes > DATA_STREAM_MIN_BYTES ) { const writer = await this.outgoingDataStreamManager.streamBytes({ topic: RPC_DATA_STREAM_TOPIC, @@ -180,7 +180,7 @@ export default class RpcServerManager { // Medium response: compress inline if ( callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && - responseBytes >= COMPRESS_MIN_BYTES + responseBytes > COMPRESS_MIN_BYTES ) { const compressed = await gzipCompress(response!); await this.engine.publishRpcResponseCompressed(callerIdentity, requestId, compressed); @@ -283,7 +283,7 @@ export default class RpcServerManager { if ( callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && - responseBytes >= DATA_STREAM_MIN_BYTES + responseBytes > DATA_STREAM_MIN_BYTES ) { // Large response: create the data stream tagged with the request ID, // send the RPC response with empty payload, then stream compressed chunks @@ -301,7 +301,7 @@ export default class RpcServerManager { if ( callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && - responseBytes >= COMPRESS_MIN_BYTES + responseBytes > COMPRESS_MIN_BYTES ) { // Medium response: compress inline const compressed = await gzipCompress(response); From 5f8914865a34cf88e9e6934504ed04486b7cc621 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 13 Mar 2026 13:11:11 -0400 Subject: [PATCH 14/54] fix: remove small payload uncompressed path After benchmarking this proved to be not very useful in practice, compression is basically the same. --- src/room/rpc/RpcClientManager.ts | 3 +-- src/room/rpc/RpcServerManager.ts | 13 +++---------- src/room/rpc/utils.ts | 15 +++++---------- 3 files changed, 9 insertions(+), 22 deletions(-) diff --git a/src/room/rpc/RpcClientManager.ts b/src/room/rpc/RpcClientManager.ts index 7b048e8394..874846b547 100644 --- a/src/room/rpc/RpcClientManager.ts +++ b/src/room/rpc/RpcClientManager.ts @@ -13,7 +13,6 @@ import type Participant from '../participant/Participant'; import { Future, compareVersions } from '../utils'; import type { ByteStreamReader } from '../data-stream/incoming/StreamReader'; import { - COMPRESS_MIN_BYTES, DATA_STREAM_MIN_BYTES, MAX_LEGACY_PAYLOAD_BYTES, type PerformRpcParams, @@ -157,7 +156,7 @@ export default class RpcClientManager { const payloadBytes = byteLength(payload); let mode: 'regular' | 'compressed' | 'compressed-data-stream' = 'regular'; - if (remoteClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && payloadBytes > COMPRESS_MIN_BYTES) { + if (remoteClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC) { mode = 'compressed'; } if (mode === 'compressed' && payloadBytes > DATA_STREAM_MIN_BYTES) { diff --git a/src/room/rpc/RpcServerManager.ts b/src/room/rpc/RpcServerManager.ts index 7971626132..3a705ef3fc 100644 --- a/src/room/rpc/RpcServerManager.ts +++ b/src/room/rpc/RpcServerManager.ts @@ -8,7 +8,6 @@ import type { ByteStreamReader } from '../data-stream/incoming/StreamReader'; import type OutgoingDataStreamManager from '../data-stream/outgoing/OutgoingDataStreamManager'; import type Participant from '../participant/Participant'; import { - COMPRESS_MIN_BYTES, DATA_STREAM_MIN_BYTES, MAX_LEGACY_PAYLOAD_BYTES, RPC_DATA_STREAM_TOPIC, @@ -178,11 +177,8 @@ export default class RpcServerManager { } // Medium response: compress inline - if ( - callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && - responseBytes > COMPRESS_MIN_BYTES - ) { - const compressed = await gzipCompress(response!); + if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC) { + const compressed = await gzipCompress(response); await this.engine.publishRpcResponseCompressed(callerIdentity, requestId, compressed); return; } @@ -299,10 +295,7 @@ export default class RpcServerManager { return; } - if ( - callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && - responseBytes > COMPRESS_MIN_BYTES - ) { + if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC) { // Medium response: compress inline const compressed = await gzipCompress(response); await this.engine.publishRpcResponseCompressed(callerIdentity, requestId, compressed); diff --git a/src/room/rpc/utils.ts b/src/room/rpc/utils.ts index 50127c5b0e..e3f7261519 100644 --- a/src/room/rpc/utils.ts +++ b/src/room/rpc/utils.ts @@ -139,20 +139,16 @@ export class RpcError extends Error { } /* - * Maximum payload size for RPC requests and responses when using the legacy (uncompressed / no data - * streams) path. If a payload exceeds this size and the remote client does not support compression, + * Maximum payload size for RPC requests and responses for clients with a clientProtocol of less + * than CLIENT_PROTOCOL_GZIP_RPC. + * + * If a payload exceeds this size and the remote client does not support compression, * the RPC call will fail with a REQUEST_PAYLOAD_TOO_LARGE(1402) or RESPONSE_PAYLOAD_TOO_LARGE(1504) error. */ export const MAX_LEGACY_PAYLOAD_BYTES = 15360; // 15 KB /** - * Payloads smaller than this are sent uncompressed (legacy path). - * @internal - */ -export const COMPRESS_MIN_BYTES = 1024; // 1 KB - -/** - * Payloads at or above this size are sent via a data stream instead of inline. + * Payloads above this size are sent via a data stream instead of inline. * @internal */ export const DATA_STREAM_MIN_BYTES = 15360; // 15 KB @@ -163,7 +159,6 @@ export const DATA_STREAM_MIN_BYTES = 15360; // 15 KB */ export const RPC_REQUEST_ID_ATTR = 'lk.rpc_request_id'; - /** @internal */ export const RPC_REQUEST_METHOD_ATTR = 'lk.rpc_request_method'; From 1f67e47e7f35705389b3b49ec44197138418cb1e Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 13 Mar 2026 13:12:27 -0400 Subject: [PATCH 15/54] refactor: run npm run format --- examples/rpc/rpc-demo.ts | 6 ++++-- src/room/Room.ts | 6 +++--- src/room/participant/RemoteParticipant.ts | 2 +- src/room/rpc/RpcClientManager.ts | 26 ++++++++++------------- src/room/rpc/RpcServerManager.ts | 18 ++++++---------- src/room/rpc/utils.ts | 4 +--- src/version.ts | 2 +- 7 files changed, 28 insertions(+), 36 deletions(-) diff --git a/examples/rpc/rpc-demo.ts b/examples/rpc/rpc-demo.ts index b62ceaa2b1..d2d5d0ea30 100644 --- a/examples/rpc/rpc-demo.ts +++ b/examples/rpc/rpc-demo.ts @@ -96,7 +96,9 @@ const registerReceiverMethods = async (greetersRoom: Room, mathGeniusRoom: Room) 'exchanging-long-info', // eslint-disable-next-line @typescript-eslint/no-unused-vars async (data: RpcInvocationData) => { - console.log(`[Greeter] ${data.callerIdentity} has arrived and said that its long info is "${data.payload}"`); + console.log( + `[Greeter] ${data.callerIdentity} has arrived and said that its long info is "${data.payload}"`, + ); await new Promise((resolve) => setTimeout(resolve, 2000)); return new Array(10_000).fill('Y').join(''); }, @@ -154,7 +156,7 @@ const performGreeting = async (room: Room): Promise => { }; const performSendVeryLongInfo = async (room: Room): Promise => { - console.log("[Caller] Sending the greeter a very long message"); + console.log('[Caller] Sending the greeter a very long message'); try { const response = await room.localParticipant.performRpc({ destinationIdentity: 'greeter', diff --git a/src/room/Room.ts b/src/room/Room.ts index f57372a06d..04a935c2a3 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -45,6 +45,7 @@ import type { } from '../options'; import TypedPromise from '../utils/TypedPromise'; import { getBrowser } from '../utils/browserParser'; +import { CLIENT_PROTOCOL_DEFAULT } from '../version'; import { BackOffStrategy } from './BackOffStrategy'; import DeviceManager from './DeviceManager'; import RTCEngine, { DataChannelKind } from './RTCEngine'; @@ -125,7 +126,6 @@ import { unpackStreamId, unwrapConstraint, } from './utils'; -import { CLIENT_PROTOCOL_DEFAULT } from '../version'; export enum ConnectionState { Disconnected = 'disconnected', @@ -2382,9 +2382,9 @@ class Room extends (EventEmitter as new () => TypedEmitter) } } - private getRemoteParticipantClientProtocol = (identity: Participant["identity"]) => { + private getRemoteParticipantClientProtocol = (identity: Participant['identity']) => { return this.remoteParticipants.get(identity)?.clientProtocol ?? CLIENT_PROTOCOL_DEFAULT; - } + }; private registerRpcDataStreamHandler() { this.incomingDataStreamManager.registerByteStreamHandler( diff --git a/src/room/participant/RemoteParticipant.ts b/src/room/participant/RemoteParticipant.ts index 1a3c1e2213..f58eb7844f 100644 --- a/src/room/participant/RemoteParticipant.ts +++ b/src/room/participant/RemoteParticipant.ts @@ -7,6 +7,7 @@ import type { import type { SignalClient } from '../../api/SignalClient'; import { DeferrableMap } from '../../utils/deferrable-map'; import type RemoteDataTrack from '../data-track/RemoteDataTrack'; +import { CLIENT_PROTOCOL_DEFAULT } from '../../version'; import { ParticipantEvent, TrackEvent } from '../events'; import RemoteAudioTrack from '../track/RemoteAudioTrack'; import type RemoteTrack from '../track/RemoteTrack'; @@ -21,7 +22,6 @@ import type { LoggerOptions } from '../types'; import { isAudioTrack, isRemoteTrack } from '../utils'; import Participant, { ParticipantKind } from './Participant'; import type { ParticipantEventCallbacks } from './Participant'; -import { CLIENT_PROTOCOL_DEFAULT } from '../../version'; export default class RemoteParticipant extends Participant { audioTrackPublications: Map; diff --git a/src/room/rpc/RpcClientManager.ts b/src/room/rpc/RpcClientManager.ts index 874846b547..f1e5adf00b 100644 --- a/src/room/rpc/RpcClientManager.ts +++ b/src/room/rpc/RpcClientManager.ts @@ -1,17 +1,15 @@ // SPDX-FileCopyrightText: 2026 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 -import { - DataPacket, - DataPacket_Kind, - RpcRequest, -} from '@livekit/protocol'; +import { DataPacket, DataPacket_Kind, RpcRequest } from '@livekit/protocol'; import { type StructuredLogger } from '../../logger'; +import { CLIENT_PROTOCOL_GZIP_RPC } from '../../version'; import type RTCEngine from '../RTCEngine'; +import type { ByteStreamReader } from '../data-stream/incoming/StreamReader'; import type OutgoingDataStreamManager from '../data-stream/outgoing/OutgoingDataStreamManager'; +import { EngineEvent } from '../events'; import type Participant from '../participant/Participant'; import { Future, compareVersions } from '../utils'; -import type { ByteStreamReader } from '../data-stream/incoming/StreamReader'; import { DATA_STREAM_MIN_BYTES, MAX_LEGACY_PAYLOAD_BYTES, @@ -27,8 +25,6 @@ import { gzipDecompress, gzipDecompressFromReader, } from './utils'; -import { CLIENT_PROTOCOL_GZIP_RPC } from '../../version'; -import { EngineEvent } from '../events'; /** * Manages the client (caller) side of RPC: sending requests, tracking pending @@ -49,7 +45,7 @@ export default class RpcClientManager { private pendingResponses = new Map< string /* request id */, { - completionFuture: Future, + completionFuture: Future; participantIdentity: string; } >(); @@ -276,7 +272,9 @@ export default class RpcClientManager { } default: { - this.log.warn(`Error handling RPC response data packet: unknown rpcResponse.value.case found (${rpcResponse.value.case})`); + this.log.warn( + `Error handling RPC response data packet: unknown rpcResponse.value.case found (${rpcResponse.value.case})`, + ); return false; } } @@ -303,10 +301,7 @@ export default class RpcClientManager { * Handle an incoming byte stream containing an RPC response payload. * Decompresses the stream and resolves/rejects the pending data stream future. */ - async handleIncomingDataStream( - reader: ByteStreamReader, - responseId: string, - ) { + async handleIncomingDataStream(reader: ByteStreamReader, responseId: string) { let decompressedPayload: string; try { decompressedPayload = await gzipDecompressFromReader(reader); @@ -347,7 +342,8 @@ export default class RpcClientManager { } } - for (const [id, { participantIdentity: pendingIdentity, completionFuture }] of this.pendingResponses) { + for (const [id, { participantIdentity: pendingIdentity, completionFuture }] of this + .pendingResponses) { if (pendingIdentity === participantIdentity) { completionFuture.reject?.(RpcError.builtIn('RECIPIENT_DISCONNECTED')); this.pendingResponses.delete(id); diff --git a/src/room/rpc/RpcServerManager.ts b/src/room/rpc/RpcServerManager.ts index 3a705ef3fc..201a0c9171 100644 --- a/src/room/rpc/RpcServerManager.ts +++ b/src/room/rpc/RpcServerManager.ts @@ -160,10 +160,7 @@ export default class RpcServerManager { // Large response: create the data stream tagged with the request ID, // send the RPC response with empty payload, then stream compressed chunks // for lower TTFB - if ( - callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && - responseBytes > DATA_STREAM_MIN_BYTES - ) { + if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && responseBytes > DATA_STREAM_MIN_BYTES) { const writer = await this.outgoingDataStreamManager.streamBytes({ topic: RPC_DATA_STREAM_TOPIC, destinationIdentities: [callerIdentity], @@ -205,15 +202,17 @@ export default class RpcServerManager { */ async handleIncomingDataStream( reader: ByteStreamReader, - callerIdentity: Participant["identity"], - dataStreamAttrs: Record + callerIdentity: Participant['identity'], + dataStreamAttrs: Record, ) { const requestId = dataStreamAttrs[RPC_REQUEST_ID_ATTR]; const method = dataStreamAttrs[RPC_REQUEST_METHOD_ATTR]; const responseTimeout = parseInt(dataStreamAttrs[RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR], 10); if (!requestId || !method || Number.isNaN(responseTimeout)) { - this.log.warn(`RPC data stream malformed: ${RPC_REQUEST_ID_ATTR} / ${RPC_REQUEST_METHOD_ATTR} / ${RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR} not set.`); + this.log.warn( + `RPC data stream malformed: ${RPC_REQUEST_ID_ATTR} / ${RPC_REQUEST_METHOD_ATTR} / ${RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR} not set.`, + ); await this.engine.publishRpcResponse( callerIdentity, requestId, @@ -277,10 +276,7 @@ export default class RpcServerManager { const responseBytes = byteLength(response ?? ''); - if ( - callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && - responseBytes > DATA_STREAM_MIN_BYTES - ) { + if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && responseBytes > DATA_STREAM_MIN_BYTES) { // Large response: create the data stream tagged with the request ID, // send the RPC response with empty payload, then stream compressed chunks // for lower TTFB diff --git a/src/room/rpc/utils.ts b/src/room/rpc/utils.ts index e3f7261519..b0d3cbd731 100644 --- a/src/room/rpc/utils.ts +++ b/src/room/rpc/utils.ts @@ -261,9 +261,7 @@ export async function gzipDecompress(data: Uint8Array): Promise { * into the decompression stream as it arrives rather than buffering first. * @internal */ -export async function gzipDecompressFromReader( - reader: AsyncIterable, -): Promise { +export async function gzipDecompressFromReader(reader: AsyncIterable): Promise { const ds = new DecompressionStream('gzip'); const dsWriter = ds.writable.getWriter(); diff --git a/src/version.ts b/src/version.ts index a28d7302c2..21fce81612 100644 --- a/src/version.ts +++ b/src/version.ts @@ -7,5 +7,5 @@ export const CLIENT_PROTOCOL_DEFAULT = 0; export const CLIENT_PROTOCOL_GZIP_RPC = 1; /** The client protocol version indicates what level of support that the client has for - * client <-> client api interactions. */ + * client <-> client api interactions. */ export const clientProtocol = CLIENT_PROTOCOL_GZIP_RPC; From 1774046572426e08bc0b79c4a2da71cd8f7c4047 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 13 Mar 2026 13:13:56 -0400 Subject: [PATCH 16/54] feat: commit (updated) rpc benchmark --- examples/rpc-benchmark/README.md | 40 + examples/rpc-benchmark/api.ts | 39 + examples/rpc-benchmark/index.html | 113 ++ examples/rpc-benchmark/package.json | 26 + examples/rpc-benchmark/pnpm-lock.yaml | 2446 +++++++++++++++++++++++ examples/rpc-benchmark/rpc-benchmark.ts | 471 +++++ examples/rpc-benchmark/styles.css | 231 +++ examples/rpc-benchmark/test-data.ts | 93 + examples/rpc-benchmark/tsconfig.json | 20 + examples/rpc-benchmark/vite.config.js | 10 + 10 files changed, 3489 insertions(+) create mode 100644 examples/rpc-benchmark/README.md create mode 100644 examples/rpc-benchmark/api.ts create mode 100644 examples/rpc-benchmark/index.html create mode 100644 examples/rpc-benchmark/package.json create mode 100644 examples/rpc-benchmark/pnpm-lock.yaml create mode 100644 examples/rpc-benchmark/rpc-benchmark.ts create mode 100644 examples/rpc-benchmark/styles.css create mode 100644 examples/rpc-benchmark/test-data.ts create mode 100644 examples/rpc-benchmark/tsconfig.json create mode 100644 examples/rpc-benchmark/vite.config.js diff --git a/examples/rpc-benchmark/README.md b/examples/rpc-benchmark/README.md new file mode 100644 index 0000000000..d41d4a719b --- /dev/null +++ b/examples/rpc-benchmark/README.md @@ -0,0 +1,40 @@ +# RPC Benchmark + +Stress test for LiveKit RPC with configurable payload sizes. Exercises all three RPC transport paths: + +| Path | Payload Size | Description | +|------|-------------|-------------| +| Legacy | < 1 KB | Uncompressed inline payload | +| Compressed | 1 KB – 15 KB | Gzip-compressed inline payload | +| Data Stream | >= 15 KB | Gzip-compressed via one-time data stream | + +## Setup + +1. Create a `.env.local` in this directory: + ``` + LIVEKIT_API_KEY=your-api-key + LIVEKIT_API_SECRET=your-api-secret + LIVEKIT_URL=wss://your-livekit-server.example.com + ``` + +2. Install and run: + ```bash + pnpm install + pnpm dev + ``` + +3. Open the URL shown by Vite (typically `http://localhost:5173`). + +## Usage + +1. Configure benchmark parameters in the UI: + - **Payload Size**: use presets or enter a custom byte count + - **Duration**: how long the benchmark runs (seconds) + - **Concurrent Callers**: number of parallel async caller "threads" + - **Delay Between Calls**: ms to wait between each call per thread + +2. Click **Run Benchmark**. The page connects a caller and receiver to the same room, then the caller fires RPCs and verifies round-trip integrity via checksum. + +3. Live stats update every 500ms. Click **Stop** to end early. + +Everything runs in a single browser tab — no need for multiple tabs. diff --git a/examples/rpc-benchmark/api.ts b/examples/rpc-benchmark/api.ts new file mode 100644 index 0000000000..fabd2d243f --- /dev/null +++ b/examples/rpc-benchmark/api.ts @@ -0,0 +1,39 @@ +import dotenv from 'dotenv'; +import express from 'express'; +import { AccessToken } from 'livekit-server-sdk'; +import type { Express } from 'express'; + +dotenv.config({ path: '.env.local' }); + +const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY; +const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET; +const LIVEKIT_URL = process.env.LIVEKIT_URL; + +const app = express(); +app.use(express.json()); + +app.post('/api/get-token', async (req, res) => { + const { identity, roomName } = req.body; + + if (!LIVEKIT_API_KEY || !LIVEKIT_API_SECRET) { + res.status(500).json({ error: 'Server misconfigured' }); + return; + } + + const token = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { + identity, + }); + token.addGrant({ + room: roomName, + roomJoin: true, + canPublish: true, + canSubscribe: true, + }); + + res.json({ + token: await token.toJwt(), + url: LIVEKIT_URL, + }); +}); + +export const handler: Express = app; diff --git a/examples/rpc-benchmark/index.html b/examples/rpc-benchmark/index.html new file mode 100644 index 0000000000..f0cb2a52f8 --- /dev/null +++ b/examples/rpc-benchmark/index.html @@ -0,0 +1,113 @@ + + + + + + LiveKit RPC Benchmark + + + +
+
+

LiveKit RPC Benchmark

+
+ For informational use only + + +
+
+ +
+
+
+ + +
+ + + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + + +
+

Log

+ +
+
+ + + diff --git a/examples/rpc-benchmark/package.json b/examples/rpc-benchmark/package.json new file mode 100644 index 0000000000..459d0abcb4 --- /dev/null +++ b/examples/rpc-benchmark/package.json @@ -0,0 +1,26 @@ +{ + "name": "livekit-rpc-benchmark", + "version": "1.0.0", + "description": "Benchmark for LiveKit RPC with varying payload sizes", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^5.2.1", + "livekit-server-sdk": "^2.7.0", + "vite": "^5.4.21", + "vite-plugin-mix": "^0.4.0" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "concurrently": "^8.2.0", + "tsx": "^4.7.0", + "typescript": "^5.4.5" + } +} diff --git a/examples/rpc-benchmark/pnpm-lock.yaml b/examples/rpc-benchmark/pnpm-lock.yaml new file mode 100644 index 0000000000..d8acbb612b --- /dev/null +++ b/examples/rpc-benchmark/pnpm-lock.yaml @@ -0,0 +1,2446 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + cors: + specifier: ^2.8.5 + version: 2.8.6 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^5.2.1 + version: 5.2.1 + livekit-server-sdk: + specifier: ^2.7.0 + version: 2.15.0 + vite: + specifier: ^5.4.21 + version: 5.4.21(@types/node@25.4.0) + vite-plugin-mix: + specifier: ^0.4.0 + version: 0.4.0(vite@5.4.21(@types/node@25.4.0)) + devDependencies: + '@types/cors': + specifier: ^2.8.17 + version: 2.8.19 + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 + concurrently: + specifier: ^8.2.0 + version: 8.2.2 + tsx: + specifier: ^4.7.0 + version: 4.21.0 + typescript: + specifier: ^5.4.5 + version: 5.9.3 + +packages: + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@bufbuild/protobuf@1.10.1': + resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@livekit/protocol@1.45.0': + resolution: {integrity: sha512-z22Ej7RRBFm5uVZpU7kBHOdDwZV6Hz+1crCOrse2g7yx8TcHXG0bKnOKwyN/meD233nEDlU2IHNCoT8Vq8lvtg==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/node@25.4.0': + resolution: {integrity: sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==} + + '@types/qs@6.15.0': + resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@vercel/nft@0.10.1': + resolution: {integrity: sha512-xhINCdohfeWg/70QLs3De/rfNFcO2+Sw4tL9oqgFl4zQzhogT3q0MjH6Hda5uM2KuFGndRPs6VkKJphAhWmymg==} + hasBin: true + + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-class-fields@1.0.0: + resolution: {integrity: sha512-l+1FokF34AeCXGBHkrXFmml9nOIRI+2yBnBpO5MaVAaTIJ96irWLtcCxX+7hAp6USHFCe+iyyBB4ZhxV807wmA==} + engines: {node: '>=4.8.2'} + peerDependencies: + acorn: ^6 || ^7 || ^8 + + acorn-private-class-elements@1.0.0: + resolution: {integrity: sha512-zYNcZtxKgVCg1brS39BEou86mIao1EV7eeREG+6WMwKbuYTeivRRs6S2XdWnboRde6G9wKh2w+WBydEyJsJ6mg==} + engines: {node: '>=4.8.2'} + peerDependencies: + acorn: ^6.1.0 || ^7 || ^8 + + acorn-static-class-features@1.0.0: + resolution: {integrity: sha512-XZJECjbmMOKvMHiNzbiPXuXpLAJfN3dAKtfIYbk1eHiWdsutlek+gS7ND4B8yJ3oqvHo1NxfafnezVmq7NXK0A==} + engines: {node: '>=4.8.2'} + peerDependencies: + acorn: ^6.1.0 || ^7 || ^8 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-regex@2.1.1: + resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} + engines: {node: '>=0.10.0'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + aproba@1.2.0: + resolution: {integrity: sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==} + + are-we-there-yet@1.1.7: + resolution: {integrity: sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==} + deprecated: This package is no longer supported. + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase-keys@9.1.3: + resolution: {integrity: sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==} + engines: {node: '>=16'} + + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + code-point-at@1.1.0: + resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==} + engines: {node: '>=0.10.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concurrently@8.2.2: + resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} + engines: {node: ^14.13.0 || >=16.0.0} + hasBin: true + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@0.6.1: + resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-minipass@1.2.7: + resolution: {integrity: sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gauge@2.7.4: + resolution: {integrity: sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==} + deprecated: This package is no longer supported. + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ignore-walk@3.0.4: + resolution: {integrity: sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-fullwidth-code-point@1.0.0: + resolution: {integrity: sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + + livekit-server-sdk@2.15.0: + resolution: {integrity: sha512-HmzjWnwEwwShu8yUf7VGFXdc+BuMJR5pnIY4qsdlhqI9d9wDgq+4cdTEHg0NEBaiGnc6PCOBiaTYgmIyVJ0S9w==} + engines: {node: '>=18'} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + map-obj@5.0.0: + resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@2.9.0: + resolution: {integrity: sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==} + + minizlib@1.3.3: + resolution: {integrity: sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + needle@2.9.1: + resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==} + engines: {node: '>= 4.4.x'} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-pre-gyp@0.13.0: + resolution: {integrity: sha512-Md1D3xnEne8b/HGVQkZZwV27WUi1ZRuZBij24TNaZwUPU3ZAFtvT6xxJGaUVillfmMKnn5oD1HoGsp2Ftik7SQ==} + deprecated: 'Please upgrade to @mapbox/node-pre-gyp: the non-scoped node-pre-gyp package is deprecated and only the @mapbox scoped package will recieve updates in the future' + hasBin: true + + nopt@4.0.3: + resolution: {integrity: sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==} + hasBin: true + + npm-bundled@1.1.2: + resolution: {integrity: sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ==} + + npm-normalize-package-bin@1.0.1: + resolution: {integrity: sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==} + + npm-packlist@1.4.8: + resolution: {integrity: sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==} + + npmlog@4.1.2: + resolution: {integrity: sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==} + deprecated: This package is no longer supported. + + number-is-nan@1.0.1: + resolution: {integrity: sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + os-homedir@1.0.2: + resolution: {integrity: sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==} + engines: {node: '>=0.10.0'} + + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + osenv@0.1.5: + resolution: {integrity: sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==} + deprecated: This package is no longer supported. + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + quick-lru@6.1.2: + resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} + engines: {node: '>=12'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup-pluginutils@2.8.2: + resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.5.0: + resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==} + engines: {node: '>=11.0.0'} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + spawn-command@0.0.2: + resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + string-width@1.0.2: + resolution: {integrity: sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==} + engines: {node: '>=0.10.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-ansi@3.0.1: + resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==} + engines: {node: '>=0.10.0'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + tar@4.4.19: + resolution: {integrity: sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==} + engines: {node: '>=4.5'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite-plugin-mix@0.4.0: + resolution: {integrity: sha512-9X8hiwhl0RbtEXBB0XqnQ5suheAtP3VHn794WcWwjU5ziYYWdlqpMh/2J8APpx/YdpvQ2CZT7dlcGGd/31ya3w==} + peerDependencies: + vite: ^3 + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + +snapshots: + + '@babel/runtime@7.28.6': {} + + '@bufbuild/protobuf@1.10.1': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@livekit/protocol@1.45.0': + dependencies: + '@bufbuild/protobuf': 1.10.1 + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 25.4.0 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.4.0 + + '@types/cors@2.8.19': + dependencies: + '@types/node': 25.4.0 + + '@types/estree@1.0.8': {} + + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 25.4.0 + '@types/qs': 6.15.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/node@25.4.0': + dependencies: + undici-types: 7.18.2 + + '@types/qs@6.15.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 25.4.0 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 25.4.0 + + '@vercel/nft@0.10.1': + dependencies: + acorn: 8.16.0 + acorn-class-fields: 1.0.0(acorn@8.16.0) + acorn-static-class-features: 1.0.0(acorn@8.16.0) + bindings: 1.5.0 + estree-walker: 0.6.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + mkdirp: 0.5.6 + node-gyp-build: 4.8.4 + node-pre-gyp: 0.13.0 + resolve-from: 5.0.0 + rollup-pluginutils: 2.8.2 + transitivePeerDependencies: + - supports-color + + abbrev@1.1.1: {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-class-fields@1.0.0(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-private-class-elements: 1.0.0(acorn@8.16.0) + + acorn-private-class-elements@1.0.0(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn-static-class-features@1.0.0(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-private-class-elements: 1.0.0(acorn@8.16.0) + + acorn@8.16.0: {} + + ansi-regex@2.1.1: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + aproba@1.2.0: {} + + are-we-there-yet@1.1.7: + dependencies: + delegates: 1.0.0 + readable-stream: 2.3.8 + + balanced-match@1.0.2: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase-keys@9.1.3: + dependencies: + camelcase: 8.0.0 + map-obj: 5.0.0 + quick-lru: 6.1.2 + type-fest: 4.41.0 + + camelcase@8.0.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chownr@1.1.4: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + code-point-at@1.1.0: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + concurrently@8.2.2: + dependencies: + chalk: 4.1.2 + date-fns: 2.30.0 + lodash: 4.17.23 + rxjs: 7.8.2 + shell-quote: 1.8.3 + spawn-command: 0.0.2 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + console-control-strings@1.1.0: {} + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + date-fns@2.30.0: + dependencies: + '@babel/runtime': 7.28.6 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-extend@0.6.0: {} + + delegates@1.0.0: {} + + depd@2.0.0: {} + + detect-libc@1.0.3: {} + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + estree-walker@0.6.1: {} + + etag@1.8.1: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + file-uri-to-path@1.0.0: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-minipass@1.2.7: + dependencies: + minipass: 2.9.0 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gauge@2.7.4: + dependencies: + aproba: 1.2.0 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 1.0.2 + strip-ansi: 3.0.1 + wide-align: 1.1.5 + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-unicode@2.0.1: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore-walk@3.0.4: + dependencies: + minimatch: 3.1.5 + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ipaddr.js@1.9.1: {} + + is-fullwidth-code-point@1.0.0: + dependencies: + number-is-nan: 1.0.1 + + is-fullwidth-code-point@3.0.0: {} + + is-number@7.0.0: {} + + is-promise@4.0.0: {} + + isarray@1.0.0: {} + + jose@5.10.0: {} + + livekit-server-sdk@2.15.0: + dependencies: + '@bufbuild/protobuf': 1.10.1 + '@livekit/protocol': 1.45.0 + camelcase-keys: 9.1.3 + jose: 5.10.0 + + lodash@4.17.23: {} + + map-obj@5.0.0: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimist@1.2.8: {} + + minipass@2.9.0: + dependencies: + safe-buffer: 5.2.1 + yallist: 3.1.1 + + minizlib@1.3.3: + dependencies: + minipass: 2.9.0 + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + needle@2.9.1: + dependencies: + debug: 3.2.7 + iconv-lite: 0.4.24 + sax: 1.5.0 + transitivePeerDependencies: + - supports-color + + negotiator@1.0.0: {} + + node-gyp-build@4.8.4: {} + + node-pre-gyp@0.13.0: + dependencies: + detect-libc: 1.0.3 + mkdirp: 0.5.6 + needle: 2.9.1 + nopt: 4.0.3 + npm-packlist: 1.4.8 + npmlog: 4.1.2 + rc: 1.2.8 + rimraf: 2.7.1 + semver: 5.7.2 + tar: 4.4.19 + transitivePeerDependencies: + - supports-color + + nopt@4.0.3: + dependencies: + abbrev: 1.1.1 + osenv: 0.1.5 + + npm-bundled@1.1.2: + dependencies: + npm-normalize-package-bin: 1.0.1 + + npm-normalize-package-bin@1.0.1: {} + + npm-packlist@1.4.8: + dependencies: + ignore-walk: 3.0.4 + npm-bundled: 1.1.2 + npm-normalize-package-bin: 1.0.1 + + npmlog@4.1.2: + dependencies: + are-we-there-yet: 1.1.7 + console-control-strings: 1.1.0 + gauge: 2.7.4 + set-blocking: 2.0.0 + + number-is-nan@1.0.1: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + os-homedir@1.0.2: {} + + os-tmpdir@1.0.2: {} + + osenv@0.1.5: + dependencies: + os-homedir: 1.0.2 + os-tmpdir: 1.0.2 + + parseurl@1.3.3: {} + + path-is-absolute@1.0.1: {} + + path-to-regexp@8.3.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + process-nextick-args@2.0.1: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + quick-lru@6.1.2: {} + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + require-directory@2.1.1: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + rollup-pluginutils@2.8.2: + dependencies: + estree-walker: 0.6.1 + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + sax@1.5.0: {} + + semver@5.7.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + setprototypeof@1.2.0: {} + + shell-quote@1.8.3: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + source-map-js@1.2.1: {} + + spawn-command@0.0.2: {} + + statuses@2.0.2: {} + + string-width@1.0.2: + dependencies: + code-point-at: 1.1.0 + is-fullwidth-code-point: 1.0.0 + strip-ansi: 3.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-ansi@3.0.1: + dependencies: + ansi-regex: 2.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@2.0.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + tar@4.4.19: + dependencies: + chownr: 1.1.4 + fs-minipass: 1.2.7 + minipass: 2.9.0 + minizlib: 1.3.3 + mkdirp: 0.5.6 + safe-buffer: 5.2.1 + yallist: 3.1.1 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tree-kill@1.2.2: {} + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.3 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + + type-fest@4.41.0: {} + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + undici-types@7.18.2: {} + + unpipe@1.0.0: {} + + util-deprecate@1.0.2: {} + + vary@1.1.2: {} + + vite-plugin-mix@0.4.0(vite@5.4.21(@types/node@25.4.0)): + dependencies: + '@vercel/nft': 0.10.1 + vite: 5.4.21(@types/node@25.4.0) + transitivePeerDependencies: + - supports-color + + vite@5.4.21(@types/node@25.4.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.59.0 + optionalDependencies: + '@types/node': 25.4.0 + fsevents: 2.3.3 + + wide-align@1.1.5: + dependencies: + string-width: 1.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 diff --git a/examples/rpc-benchmark/rpc-benchmark.ts b/examples/rpc-benchmark/rpc-benchmark.ts new file mode 100644 index 0000000000..ef3458896b --- /dev/null +++ b/examples/rpc-benchmark/rpc-benchmark.ts @@ -0,0 +1,471 @@ +/** + * RPC Benchmark - stress tests LiveKit RPC with configurable payload sizes. + * + * Ported from client-sdk-cpp/src/tests/stress/test_rpc_stress.cpp + * + * Three RPC paths are exercised depending on payload size: + * 1. Legacy (< COMPRESS_MIN_BYTES = 1KB): uncompressed inline payload + * 2. Compressed (1KB .. < DATA_STREAM_MIN_BYTES = 15KB): gzip-compressed inline + * 3. Data stream (>= 15KB): gzip-compressed via a one-time data stream + */ +import { + Room, + type RoomConnectOptions, + RoomEvent, + RpcError, + type RpcInvocationData, +} from '../../src/index'; +import { generatePayload } from './test-data'; + +// --------------------------------------------------------------------------- +// Stats tracker (mirrors StressTestStats from the C++ test) +// --------------------------------------------------------------------------- + +interface CallRecord { + success: boolean; + latencyMs: number; + payloadBytes: number; +} + +interface ErrorBucket { + [key: string]: number; +} + +class BenchmarkStats { + private calls: CallRecord[] = []; + private errors: ErrorBucket = {}; + + recordCall(success: boolean, latencyMs: number, payloadBytes: number) { + this.calls.push({ success, latencyMs, payloadBytes }); + } + + recordError(kind: string) { + this.errors[kind] = (this.errors[kind] ?? 0) + 1; + } + + get totalCalls() { + return this.calls.length; + } + + get successfulCalls() { + return this.calls.filter((c) => c.success).length; + } + + get failedCalls() { + return this.calls.filter((c) => !c.success).length; + } + + get successRate() { + return this.totalCalls > 0 ? (100 * this.successfulCalls) / this.totalCalls : 0; + } + + get checksumMismatches() { + return this.errors['checksum_mismatch'] ?? 0; + } + + /** Sorted latencies for successful calls */ + private sortedLatencies(): number[] { + return this.calls + .filter((c) => c.success) + .map((c) => c.latencyMs) + .sort((a, b) => a - b); + } + + get avgLatency(): number { + const s = this.sortedLatencies(); + if (s.length === 0) { + return 0; + } + return s.reduce((a, b) => a + b, 0) / s.length; + } + + percentile(p: number): number { + const s = this.sortedLatencies(); + if (s.length === 0) { + return 0; + } + const idx = Math.min(Math.floor((p / 100) * s.length), s.length - 1); + return s[idx]; + } + + get errorSummary(): ErrorBucket { + return { ...this.errors }; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function computeChecksum(str: string): number { + let sum = 0; + for (let i = 0; i < str.length; i += 1) { + sum += str.charCodeAt(i); + } + return sum; +} + +const fetchToken = async ( + identity: string, + roomName: string, +): Promise<{ token: string; url: string }> => { + const response = await fetch('/api/get-token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identity, roomName }), + }); + if (!response.ok) throw new Error('Failed to fetch token'); + const data = await response.json(); + return { token: data.token, url: data.url }; +}; + +const connectParticipant = async (identity: string, roomName: string): Promise => { + const room = new Room(); + const { token, url } = await fetchToken(identity, roomName); + + room.on(RoomEvent.Disconnected, () => { + log(`[${identity}] Disconnected from room`); + }); + + await room.connect(url, token, { autoSubscribe: true } as RoomConnectOptions); + + await new Promise((resolve) => { + if (room.state === 'connected') { + resolve(); + } else { + room.once(RoomEvent.Connected, () => resolve()); + } + }); + + log(`${identity} connected.`); + return room; +}; + +// --------------------------------------------------------------------------- +// UI helpers +// --------------------------------------------------------------------------- + +let startTime = Date.now(); + +function log(message: string) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(3); + const formatted = `[+${elapsed}s] ${message}`; + console.log(formatted); + const logArea = document.getElementById('log') as HTMLTextAreaElement | null; + if (logArea) { + logArea.value += formatted + '\n'; + logArea.scrollTop = logArea.scrollHeight; + } +} + +function updateStat(id: string, value: string | number) { + const el = document.getElementById(id); + if (el) el.textContent = String(value); +} + +function refreshStatsUI(stats: BenchmarkStats, elapsedSec: number) { + updateStat('stat-total', stats.totalCalls); + updateStat('stat-success', stats.successfulCalls); + updateStat('stat-failed', stats.failedCalls); + updateStat('stat-rate', stats.totalCalls > 0 ? stats.successRate.toFixed(1) + '%' : '-'); + updateStat( + 'stat-avg-latency', + stats.successfulCalls > 0 ? stats.avgLatency.toFixed(1) + 'ms' : '-', + ); + updateStat('stat-p50', stats.successfulCalls > 0 ? stats.percentile(50).toFixed(1) + 'ms' : '-'); + updateStat('stat-p95', stats.successfulCalls > 0 ? stats.percentile(95).toFixed(1) + 'ms' : '-'); + updateStat('stat-p99', stats.successfulCalls > 0 ? stats.percentile(99).toFixed(1) + 'ms' : '-'); + updateStat( + 'stat-throughput', + elapsedSec > 0 ? (stats.successfulCalls / elapsedSec).toFixed(2) : '-', + ); + updateStat('stat-checksum', stats.checksumMismatches); + updateStat('stat-elapsed', Math.floor(elapsedSec) + 's'); +} + +// --------------------------------------------------------------------------- +// Benchmark runner +// --------------------------------------------------------------------------- + +let running = false; + +async function runBenchmark() { + const payloadBytes = parseInt( + (document.getElementById('payload-size') as HTMLInputElement).value, + 10, + ); + const durationSec = parseInt((document.getElementById('duration') as HTMLInputElement).value, 10); + const concurrency = parseInt( + (document.getElementById('concurrency') as HTMLInputElement).value, + 10, + ); + const delayMs = parseInt((document.getElementById('delay') as HTMLInputElement).value, 10); + + running = true; + startTime = Date.now(); + + const logArea = document.getElementById('log') as HTMLTextAreaElement; + if (logArea) logArea.value = ''; + + document.getElementById('stats-area')!.style.display = ''; + (document.getElementById('run-benchmark') as HTMLButtonElement).style.display = 'none'; + (document.getElementById('stop-benchmark') as HTMLButtonElement).style.display = ''; + + log(`=== RPC Benchmark ===`); + log(`Payload size: ${payloadBytes} bytes`); + log(`Duration: ${durationSec}s | Concurrency: ${concurrency} | Delay: ${delayMs}ms`); + + const roomName = `rpc-bench-${Math.random().toString(36).substring(2, 8)}`; + log(`Connecting participants to room: ${roomName}`); + + let callerRoom: Room | undefined; + let receiverRoom: Room | undefined; + + try { + [callerRoom, receiverRoom] = await Promise.all([ + connectParticipant('bench-caller', roomName), + connectParticipant('bench-receiver', roomName), + ]); + + // Register echo handler on receiver + let totalReceived = 0; + receiverRoom.registerRpcMethod('benchmark-echo', async (data: RpcInvocationData) => { + totalReceived += 1; + // Echo back the payload for round-trip verification + return data.payload; + }); + + // Wait for participants to see each other + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timed out waiting for receiver to be visible')), + 10_000, + ); + const check = () => { + const participants = callerRoom!.remoteParticipants; + for (const [, p] of participants) { + if (p.identity === 'bench-receiver') { + clearTimeout(timeout); + resolve(); + return; + } + } + }; + check(); + callerRoom!.on(RoomEvent.ParticipantConnected, () => check()); + }); + + log(`Both participants connected. Starting benchmark...`); + log(`Pre-generating payload (${payloadBytes} bytes)...`); + const payload = generatePayload(payloadBytes); + const expectedChecksum = computeChecksum(payload); + log(`Payload generated. Checksum: ${expectedChecksum}`); + + const stats = new BenchmarkStats(); + const benchStartMs = performance.now(); + const benchEndTimeMs = benchStartMs + durationSec * 1000; + + // Stats refresh interval + const statsInterval = setInterval(() => { + const elapsedMs = (performance.now() - benchStartMs) / 1000; + refreshStatsUI(stats, elapsedMs); + }, 500); + + // Caller loop for one "thread" (concurrent async worker) + const callerLoop = async (threadId: number) => { + while (running && performance.now() < benchEndTimeMs) { + const callStartMs = performance.now(); + + try { + const response = await callerRoom!.localParticipant.performRpc({ + destinationIdentity: 'bench-receiver', + method: 'benchmark-echo', + payload, + responseTimeout: 60_000, + }); + + const latencyMs = performance.now() - callStartMs; + const responseChecksum = computeChecksum(response); + + if (response.length === payload.length && responseChecksum === expectedChecksum) { + stats.recordCall(true, latencyMs, payloadBytes); + } else { + stats.recordCall(false, latencyMs, payloadBytes); + stats.recordError('checksum_mismatch'); + log( + `[Thread ${threadId}] CHECKSUM MISMATCH sent=${payload.length}/${expectedChecksum} recv=${response.length}/${responseChecksum}`, + ); + } + } catch (error) { + const latency = performance.now() - callStartMs; + stats.recordCall(false, latency, payloadBytes); + + if (error instanceof RpcError) { + const code = error.code; + if (code === RpcError.ErrorCode.RESPONSE_TIMEOUT) { + stats.recordError('timeout'); + } else if (code === RpcError.ErrorCode.CONNECTION_TIMEOUT) { + stats.recordError('connection_timeout'); + } else if (code === RpcError.ErrorCode.RECIPIENT_DISCONNECTED) { + stats.recordError('recipient_disconnected'); + } else { + stats.recordError(`rpc_error_${code}`); + } + log( + `[Thread ${threadId}] RPC Error code=${code} msg="${error.message}" latency=${latency.toFixed(1)}ms`, + ); + } else { + stats.recordError('exception'); + log(`[Thread ${threadId}] Exception: ${error}`); + } + } + + // Delay between calls + if (delayMs > 0 && running) { + await new Promise((r) => setTimeout(r, delayMs)); + } + } + }; + + // Launch concurrent caller "threads" + const threads = Array.from({ length: concurrency }, (_, i) => callerLoop(i)); + await Promise.all(threads); + + clearInterval(statsInterval); + + // Final stats update + const totalElapsed = (performance.now() - benchStartMs) / 1000; + refreshStatsUI(stats, totalElapsed); + + log(`\n=== Benchmark Complete ===`); + log(`Total calls: ${stats.totalCalls}`); + log(`Successful: ${stats.successfulCalls} | Failed: ${stats.failedCalls}`); + log(`Success rate: ${stats.successRate.toFixed(1)}%`); + log(`Avg latency: ${stats.avgLatency.toFixed(1)}ms`); + log( + `P50: ${stats.percentile(50).toFixed(1)}ms | P95: ${stats.percentile(95).toFixed(1)}ms | P99: ${stats.percentile(99).toFixed(1)}ms`, + ); + log(`Throughput: ${(stats.successfulCalls / totalElapsed).toFixed(2)} calls/sec`); + log(`Receiver total processed: ${totalReceived}`); + + const errors = stats.errorSummary; + if (Object.keys(errors).length > 0) { + log(`Errors: ${JSON.stringify(errors)}`); + } + + receiverRoom.localParticipant.unregisterRpcMethod('benchmark-echo'); + } catch (error) { + log(`Fatal error: ${error}`); + } finally { + running = false; + if (callerRoom) await callerRoom.disconnect(); + if (receiverRoom) await receiverRoom.disconnect(); + (document.getElementById('run-benchmark') as HTMLButtonElement).style.display = ''; + (document.getElementById('run-benchmark') as HTMLButtonElement).disabled = false; + (document.getElementById('stop-benchmark') as HTMLButtonElement).style.display = 'none'; + log('Disconnected.'); + } +} + +// --------------------------------------------------------------------------- +// Query string persistence +// --------------------------------------------------------------------------- + +const PARAM_DEFAULTS: Record = { + 'payload-size': '15360', + duration: '30', + concurrency: '3', + delay: '10', +}; + +// All element IDs managed in the URL (network has no default — omitted when empty) +const ALL_PARAM_IDS = ['network', ...Object.keys(PARAM_DEFAULTS)]; + +function loadParamsFromURL() { + const params = new URLSearchParams(window.location.search); + let needsReplace = false; + + // Network: only set if present in URL, otherwise leave empty + const networkEl = document.getElementById('network') as HTMLSelectElement; + if (params.has('network')) { + networkEl.value = params.get('network')!; + } + + for (const [id, defaultValue] of Object.entries(PARAM_DEFAULTS)) { + const input = document.getElementById(id) as HTMLInputElement; + if (!input) continue; + + if (params.has(id)) { + input.value = params.get(id)!; + } else { + params.set(id, defaultValue); + needsReplace = true; + } + } + + if (needsReplace) { + window.history.replaceState(null, '', '?' + params.toString()); + } +} + +function syncParamsToURL() { + const params = new URLSearchParams(window.location.search); + + // Network: include only when non-empty + const networkEl = document.getElementById('network') as HTMLSelectElement; + if (networkEl.value) { + params.set('network', networkEl.value); + } else { + params.delete('network'); + } + + for (const id of Object.keys(PARAM_DEFAULTS)) { + const input = document.getElementById(id) as HTMLInputElement; + if (input) { + params.set(id, input.value); + } + } + window.history.replaceState(null, '', '?' + params.toString()); +} + +// --------------------------------------------------------------------------- +// DOM wiring +// --------------------------------------------------------------------------- + +document.addEventListener('DOMContentLoaded', () => { + loadParamsFromURL(); + + // Sync to URL on any input change + for (const id of ALL_PARAM_IDS) { + const el = document.getElementById(id); + if (el) { + const event = el.tagName === 'SELECT' ? 'change' : 'input'; + el.addEventListener(event, () => syncParamsToURL()); + } + } + + const runBtn = document.getElementById('run-benchmark') as HTMLButtonElement; + const stopBtn = document.getElementById('stop-benchmark') as HTMLButtonElement; + + runBtn.addEventListener('click', async () => { + runBtn.disabled = true; + await runBenchmark(); + }); + + stopBtn.addEventListener('click', () => { + log('Stopping benchmark...'); + running = false; + }); + + // Preset buttons + document.querySelectorAll('.preset-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const size = (btn as HTMLButtonElement).dataset.size; + if (size) { + const input = document.getElementById('payload-size') as HTMLInputElement; + input.value = size; + syncParamsToURL(); + } + }); + }); +}); diff --git a/examples/rpc-benchmark/styles.css b/examples/rpc-benchmark/styles.css new file mode 100644 index 0000000000..d9ef108571 --- /dev/null +++ b/examples/rpc-benchmark/styles.css @@ -0,0 +1,231 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; + background-color: #f0f2f5; + color: #333; + line-height: 1.6; + margin: 0; + padding: 0; +} + +.container { + max-width: 900px; + margin: 40px auto; + padding: 30px; + background-color: #ffffff; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); + border-radius: 12px; +} + +.title-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 30px; +} + +h1 { + color: #2c3e50; + margin: 0; + font-weight: 600; +} + +h2 { + color: #2c3e50; + margin-top: 30px; + margin-bottom: 15px; + font-weight: 500; + font-size: 1.1em; +} + +.info-box { + border: 2px dotted #bdc3c7; + border-radius: 8px; + padding: 8px 12px; + position: relative; + display: flex; + align-items: center; + gap: 8px; + min-width: 320px; +} + +.info-label { + position: absolute; + top: -10px; + left: 12px; + background: #ffffff; + padding: 0 6px; + font-size: 11px; + color: #95a5a6; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; +} + +.info-box label { + font-weight: 500; + font-size: 14px; + color: #555; + white-space: nowrap; +} + +.info-box select { + padding: 6px 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.controls { + margin-bottom: 20px; +} + +.control-group { + margin-bottom: 12px; +} + +.payload-row { + display: flex; + align-items: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.payload-row .control-group { + margin-bottom: 0; +} + +.preset-btn { + padding: 7px 12px; + background: #ecf0f1; + border: 1px solid #bdc3c7; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + transition: background-color 0.2s; + white-space: nowrap; +} + +.preset-btn:hover { + background: #d5dbdb; +} + +.control-group label { + display: block; + font-weight: 500; + margin-bottom: 4px; + font-size: 14px; + color: #555; +} + +.control-group input { + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + width: 160px; +} + +.control-row { + display: flex; + gap: 20px; + flex-wrap: wrap; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} + +.stat { + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 6px; + padding: 12px; + text-align: center; +} + +.stat-label { + display: block; + font-size: 11px; + color: #6c757d; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.stat-value { + display: block; + font-size: 18px; + font-weight: 600; + color: #2c3e50; + font-variant-numeric: tabular-nums; +} + +.stat-success { + color: #27ae60; +} + +.stat-fail { + color: #e74c3c; +} + +#log-area { + margin-top: 20px; +} + +#log { + box-sizing: border-box; + width: 100%; + height: 250px; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-family: monospace; + font-size: 13px; + resize: vertical; +} + +.btn { + display: inline-block; + padding: 10px 24px; + background-color: #3498db; + color: white; + border: none; + border-radius: 5px; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s, transform 0.1s; + font-weight: 500; + margin-right: 10px; +} + +.btn:hover { + background-color: #2980b9; + transform: translateY(-2px); +} + +.btn:active { + transform: translateY(0); +} + +.btn:disabled { + background-color: #bdc3c7; + color: #7f8c8d; + cursor: not-allowed; + transform: none; +} + +.btn:disabled:hover { + background-color: #bdc3c7; + transform: none; +} + +.btn-danger { + background-color: #e74c3c; +} + +.btn-danger:hover { + background-color: #c0392b; +} diff --git a/examples/rpc-benchmark/test-data.ts b/examples/rpc-benchmark/test-data.ts new file mode 100644 index 0000000000..a3b299d7a4 --- /dev/null +++ b/examples/rpc-benchmark/test-data.ts @@ -0,0 +1,93 @@ +/** + * Generated structured JSON test data for RPC benchmarking. + * + * Each line is a self-contained JSON object representing realistic structured + * data (user profiles, events, metrics, etc.). This data compresses roughly + * as well as typical structured payloads. + */ + +const TEST_DATA_LINES: string[] = [ + '{"id":"usr_a1b2c3","name":"Alice Chen","email":"alice.chen@example.com","role":"engineer","department":"platform","projects":["livekit-core","media-pipeline","signaling"],"metrics":{"commits":342,"reviews":128,"deployments":57},"location":"San Francisco, CA","joined":"2022-03-15T08:30:00Z"}', + '{"event":"room.participant_joined","timestamp":"2025-01-15T14:22:33.456Z","room_sid":"RM_xK9mPq2nR4","participant_sid":"PA_j7hLw3vYm1","identity":"speaker-042","metadata":{"display_name":"Dr. Sarah Mitchell","avatar_url":"https://cdn.example.com/avatars/sm042.jpg","hand_raised":false}}', + '{"sensor_id":"temp-rack-07b","readings":[{"ts":1705312800,"value":23.4,"unit":"celsius"},{"ts":1705312860,"value":23.6,"unit":"celsius"},{"ts":1705312920,"value":24.1,"unit":"celsius"},{"ts":1705312980,"value":23.8,"unit":"celsius"}],"status":"nominal","location":"datacenter-west-3"}', + '{"order_id":"ORD-2025-00847","customer":{"id":"cust_9f8e7d","name":"Bob Williams","tier":"premium"},"items":[{"sku":"WDG-1042","name":"Wireless Adapter Pro","qty":2,"price":49.99},{"sku":"CBL-3001","name":"USB-C Cable 2m","qty":5,"price":12.99}],"total":164.93,"currency":"USD","status":"processing"}', + '{"trace_id":"abc123def456","spans":[{"name":"http.request","duration_ms":245,"status":"ok","attributes":{"http.method":"POST","http.url":"/api/v2/rooms","http.status_code":201}},{"name":"db.query","duration_ms":12,"status":"ok","attributes":{"db.system":"postgresql","db.statement":"INSERT INTO rooms"}}]}', + '{"log_level":"warn","service":"media-router","instance":"mr-us-east-07","message":"Track subscription delayed due to network congestion","context":{"room_sid":"RM_pQ8nL2mK5x","track_sid":"TR_w4jR7vN9y3","participant_sid":"PA_k2mX5bH8r1","delay_ms":1847,"retry_count":3,"bandwidth_estimate_bps":2450000}}', + '{"config":{"video":{"codecs":["VP8","H264","AV1"],"simulcast":{"enabled":true,"layers":[{"rid":"f","maxBitrate":2500000,"maxFramerate":30},{"rid":"h","maxBitrate":800000,"maxFramerate":15},{"rid":"q","maxBitrate":200000,"maxFramerate":7}]},"dynacast":true},"audio":{"codecs":["opus"],"dtx":true,"red":true,"stereo":false}}}', + '{"benchmark":{"test":"rpc-throughput","iteration":1547,"payload_bytes":15360,"compress_ratio":0.42,"latency_ms":23.7,"path":"compressed","timestamp":"2025-06-20T10:15:33.891Z","caller":"bench-caller-01","receiver":"bench-receiver-01","room":"benchmark-room-8f3a"}}', + '{"user_id":"u_7k3m9p","session":{"id":"sess_abc123","started":"2025-01-15T09:00:00Z","duration_minutes":47,"pages_viewed":12,"actions":[{"type":"click","target":"#start-call","ts":1705308120},{"type":"input","target":"#chat-message","ts":1705308245},{"type":"click","target":"#share-screen","ts":1705308390}],"device":{"browser":"Chrome 121","os":"macOS 14.2","screen":"2560x1440"}}}', + '{"pipeline_id":"pipe_rtc_042","stages":[{"name":"capture","codec":"VP8","resolution":"1920x1080","fps":30,"bitrate_kbps":2500},{"name":"encode","profile":"constrained-baseline","hardware_accel":true,"latency_ms":4.2},{"name":"packetize","mtu":1200,"fec_enabled":true,"nack_enabled":true},{"name":"transport","protocol":"UDP","ice_candidates":3,"dtls_setup":"actpass"}]}', + '{"notification":{"id":"notif_x8k2m","type":"room_recording_ready","recipient":"user_j4n7p","channel":"webhook","payload":{"room_name":"team-standup-2025-01-15","recording_url":"https://storage.example.com/recordings/rec_abc123.mp4","duration_seconds":1847,"file_size_bytes":245760000,"format":"mp4","resolution":"1920x1080"},"created_at":"2025-01-15T10:35:00Z"}}', + '{"cluster":{"id":"lk-us-east-1","region":"us-east-1","nodes":[{"id":"node-01","type":"media","status":"healthy","load":0.67,"rooms":42,"participants":318,"cpu_pct":54.2,"mem_pct":71.8},{"id":"node-02","type":"media","status":"healthy","load":0.43,"rooms":31,"participants":201,"cpu_pct":38.1,"mem_pct":55.4}],"total_rooms":73,"total_participants":519}}', + '{"invoice":{"number":"INV-2025-003847","date":"2025-01-15","due_date":"2025-02-14","vendor":{"name":"Cloud Services Inc.","address":"100 Tech Blvd, Austin, TX 78701","tax_id":"US-847291035"},"line_items":[{"description":"Media Server Instances (720 hrs)","quantity":720,"unit_price":0.085,"amount":61.20},{"description":"Bandwidth (2.4 TB)","quantity":2400,"unit_price":0.02,"amount":48.00},{"description":"TURN Relay (180 hrs)","quantity":180,"unit_price":0.04,"amount":7.20}],"subtotal":116.40,"tax":9.31,"total":125.71}}', + '{"experiment":{"id":"exp_codec_comparison_042","hypothesis":"AV1 reduces bandwidth by 30% vs VP8 at equivalent quality","groups":[{"name":"control","codec":"VP8","participants":500,"avg_bitrate_kbps":2100,"avg_psnr":38.2,"avg_latency_ms":45},{"name":"treatment","codec":"AV1","participants":500,"avg_bitrate_kbps":1470,"avg_psnr":38.5,"avg_latency_ms":52}],"p_value":0.003,"significant":true,"start_date":"2025-01-01","end_date":"2025-01-14"}}', + '{"deployment":{"id":"deploy_20250115_003","service":"livekit-server","version":"1.8.2","environment":"production","region":"eu-west-1","status":"completed","started_at":"2025-01-15T03:00:00Z","completed_at":"2025-01-15T03:12:47Z","changes":["fix: ice restart race condition","feat: improved simulcast layer selection","perf: reduce memory allocation in media forwarding"],"rollback_available":true,"health_check":"passing"}}', + '{"analytics":{"room_id":"RM_daily_standup_042","period":"2025-01-15T09:00:00Z/2025-01-15T09:30:00Z","participants":{"total":8,"max_concurrent":7,"avg_duration_minutes":22.4},"media":{"audio":{"total_minutes":156.8,"avg_bitrate_kbps":32,"packet_loss_pct":0.02},"video":{"total_minutes":134.2,"avg_bitrate_kbps":1850,"packet_loss_pct":0.08,"avg_fps":28.7},"screen_share":{"total_minutes":15.3,"avg_bitrate_kbps":3200}},"quality_score":4.7}}', + '{"ticket":{"id":"TICKET-8472","title":"Intermittent audio dropout in large rooms","priority":"high","status":"in_progress","assignee":"eng-media-team","reporter":"support-agent-12","created":"2025-01-14T16:30:00Z","updated":"2025-01-15T11:22:00Z","labels":["audio","production","p1"],"comments_count":7,"related_incidents":["INC-2025-0042","INC-2025-0039"],"description":"Users in rooms with 50+ participants report intermittent audio dropouts lasting 2-5 seconds"}}', + '{"schema":{"table":"participants","columns":[{"name":"id","type":"uuid","primary_key":true},{"name":"room_id","type":"uuid","foreign_key":"rooms.id","index":true},{"name":"identity","type":"varchar(255)","not_null":true},{"name":"name","type":"varchar(500)"},{"name":"metadata","type":"jsonb"},{"name":"joined_at","type":"timestamptz","not_null":true,"default":"now()"},{"name":"left_at","type":"timestamptz"},{"name":"state","type":"enum(active,disconnected,migrating)","not_null":true,"default":"active"}],"indexes":["idx_participants_room_id","idx_participants_identity"]}}', + '{"weather":{"station":"SFO-042","timestamp":"2025-01-15T12:00:00Z","temperature_c":14.2,"humidity_pct":72,"wind_speed_kmh":18.5,"wind_direction":"NW","pressure_hpa":1013.2,"conditions":"partly_cloudy","forecast":[{"hour":13,"temp_c":14.8,"precip_pct":10},{"hour":14,"temp_c":15.1,"precip_pct":5},{"hour":15,"temp_c":14.9,"precip_pct":15},{"hour":16,"temp_c":14.3,"precip_pct":25}]}}', + '{"translation":{"request_id":"tr_9f8e7d6c","source_lang":"en","target_lang":"ja","model":"nmt-v4","segments":[{"source":"The meeting will start in 5 minutes.","target":"\u4f1a\u8b70\u306f5\u5206\u5f8c\u306b\u59cb\u307e\u308a\u307e\u3059\u3002","confidence":0.97},{"source":"Please enable your camera.","target":"\u30ab\u30e1\u30e9\u3092\u6709\u52b9\u306b\u3057\u3066\u304f\u3060\u3055\u3044\u3002","confidence":0.95}],"total_chars":78,"latency_ms":142,"cached":false}}', +]; + +/** + * Generate a payload of the specified byte size by cycling through and + * concatenating random lines from the test data set, then slicing to exact + * size on a valid character boundary. + */ +export function generatePayload(targetBytes: number): string { + if (targetBytes <= 0) return ''; + + const encoder = new TextEncoder(); + let result = ''; + + // Shuffle indices for realistic randomness + const indices = Array.from({ length: TEST_DATA_LINES.length }, (_, i) => i); + for (let i = indices.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [indices[i], indices[j]] = [indices[j], indices[i]]; + } + + let idx = 0; + while (encoder.encode(result).length < targetBytes) { + if (result.length > 0) { + result += '\n'; + } + result += TEST_DATA_LINES[indices[idx % indices.length]]; + idx += 1; + // Re-shuffle when we've gone through all lines + if (idx % indices.length === 0) { + for (let i = indices.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [indices[i], indices[j]] = [indices[j], indices[i]]; + } + } + } + + // Trim to exact byte size on a valid character boundary + const encoded = encoder.encode(result); + if (encoded.length <= targetBytes) { + // Pad with spaces if under target + const padding = targetBytes - encoded.length; + return result + ' '.repeat(padding); + } + + // Binary search for the right character count that fits in targetBytes + let low = 0; + let high = result.length; + while (low < high) { + const mid = Math.floor((low + high + 1) / 2); + if (encoder.encode(result.slice(0, mid)).length <= targetBytes) { + low = mid; + } else { + high = mid - 1; + } + } + + const trimmed = result.slice(0, low); + const trimmedLen = encoder.encode(trimmed).length; + // Pad remainder with spaces to hit exact target + if (trimmedLen < targetBytes) { + return trimmed + ' '.repeat(targetBytes - trimmedLen); + } + return trimmed; +} diff --git a/examples/rpc-benchmark/tsconfig.json b/examples/rpc-benchmark/tsconfig.json new file mode 100644 index 0000000000..299d17f4d5 --- /dev/null +++ b/examples/rpc-benchmark/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "outDir": "build", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true + }, + "include": ["../../src/**/*", "rpc-benchmark.ts", "api.ts", "test-data.ts"], + "exclude": ["**/*.test.ts", "build/**/*"] +} diff --git a/examples/rpc-benchmark/vite.config.js b/examples/rpc-benchmark/vite.config.js new file mode 100644 index 0000000000..8f82d19f31 --- /dev/null +++ b/examples/rpc-benchmark/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import mix from 'vite-plugin-mix'; + +export default defineConfig({ + plugins: [ + mix.default({ + handler: './api.ts', + }), + ], +}); From b1248eb528f8a392c579123eceab80f56eddefd5 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 27 Mar 2026 15:38:50 -0400 Subject: [PATCH 17/54] fix: remove references to "small response", uncompressed payloads are just for legacy cases now --- examples/rpc-benchmark/README.md | 5 ++--- src/room/rpc/RpcServerManager.ts | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/examples/rpc-benchmark/README.md b/examples/rpc-benchmark/README.md index d41d4a719b..18e37ea29b 100644 --- a/examples/rpc-benchmark/README.md +++ b/examples/rpc-benchmark/README.md @@ -1,11 +1,10 @@ # RPC Benchmark -Stress test for LiveKit RPC with configurable payload sizes. Exercises all three RPC transport paths: +Stress test for LiveKit RPC with configurable payload sizes. Exercises both RPC transport paths: | Path | Payload Size | Description | |------|-------------|-------------| -| Legacy | < 1 KB | Uncompressed inline payload | -| Compressed | 1 KB – 15 KB | Gzip-compressed inline payload | +| Compressed | < 15 KB | Gzip-compressed inline payload | | Data Stream | >= 15 KB | Gzip-compressed via one-time data stream | ## Setup diff --git a/src/room/rpc/RpcServerManager.ts b/src/room/rpc/RpcServerManager.ts index 201a0c9171..274ca3e267 100644 --- a/src/room/rpc/RpcServerManager.ts +++ b/src/room/rpc/RpcServerManager.ts @@ -190,7 +190,7 @@ export default class RpcServerManager { return; } - // Small response or legacy client: send uncompressed + // Legacy client: send uncompressed if (isCallerStillConnected()) { await this.engine.publishRpcResponse(callerIdentity, requestId, response, null); } @@ -306,7 +306,7 @@ export default class RpcServerManager { return; } - // Small response or legacy client: send uncompressed + // Legacy client: send uncompressed await this.engine.publishRpcResponse(callerIdentity, requestId, response, null); } } From 8da9c596c49578bb9ab822b0d9f2ace8ab7b65fa Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 27 Mar 2026 17:31:30 -0400 Subject: [PATCH 18/54] refactor: move ack code into handleIncomingRpcAck --- src/room/rpc/RpcClientManager.ts | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/room/rpc/RpcClientManager.ts b/src/room/rpc/RpcClientManager.ts index f1e5adf00b..004b1dbccd 100644 --- a/src/room/rpc/RpcClientManager.ts +++ b/src/room/rpc/RpcClientManager.ts @@ -281,15 +281,7 @@ export default class RpcClientManager { } case 'rpcAck': { const rpcAck = packet.value.value; - - const handler = this.pendingAcks.get(rpcAck.requestId); - if (handler) { - handler.resolve(); - this.pendingAcks.delete(rpcAck.requestId); - } else { - this.log.error(`Ack received for unexpected RPC request: ${rpcAck.requestId}`); - } - + this.handleIncomingRpcAck(rpcAck.requestId); return true; } default: @@ -314,7 +306,8 @@ export default class RpcClientManager { this.handleIncomingRpcResponseSuccess(responseId, decompressedPayload); } - private handleIncomingRpcResponseSuccess(requestId: string, payload: string) { + /** @internal */ + handleIncomingRpcResponseSuccess(requestId: string, payload: string) { const handler = this.pendingResponses.get(requestId); if (handler) { handler.completionFuture.resolve?.(payload); @@ -324,7 +317,8 @@ export default class RpcClientManager { } } - private handleIncomingRpcResponseFailure(requestId: string, error: RpcError) { + /** @internal */ + handleIncomingRpcResponseFailure(requestId: string, error: RpcError) { const handler = this.pendingResponses.get(requestId); if (handler) { handler.completionFuture.reject?.(error); @@ -334,6 +328,17 @@ export default class RpcClientManager { } } + /** @internal */ + handleIncomingRpcAck(requestId: string) { + const handler = this.pendingAcks.get(requestId); + if (handler) { + handler.resolve(); + this.pendingAcks.delete(requestId); + } else { + this.log.error(`Ack received for unexpected RPC request: ${requestId}`); + } + } + /** @internal */ handleParticipantDisconnected(participantIdentity: string) { for (const [id, { participantIdentity: pendingIdentity }] of this.pendingAcks) { From 9b20f07ffc59cdf43a10e89cd521958ecfcc358d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 27 Mar 2026 17:32:35 -0400 Subject: [PATCH 19/54] refactor: extract all rpc related packet sending code out of the engine and back into the server manager --- src/room/RTCEngine.ts | 2 +- src/room/rpc/RpcServerManager.ts | 90 ++++++++++++++++++++++++++------ 2 files changed, 76 insertions(+), 16 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index f8354571d9..2b9c72f50d 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1433,7 +1433,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit }, }); - await this.sendDataPacket(packet, DataPacket_Kind.RELIABLE); + await this.sendDataPacket(packet, DataChannelKind.RELIABLE); } /** @internal */ diff --git a/src/room/rpc/RpcServerManager.ts b/src/room/rpc/RpcServerManager.ts index 274ca3e267..1d5f2ca11c 100644 --- a/src/room/rpc/RpcServerManager.ts +++ b/src/room/rpc/RpcServerManager.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2026 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 +import { DataPacket, DataPacket_Kind, RpcAck, RpcResponse } from '@livekit/protocol'; import { type StructuredLogger } from '../../logger'; import { CLIENT_PROTOCOL_GZIP_RPC } from '../../version'; import type RTCEngine from '../RTCEngine'; @@ -79,11 +80,11 @@ export default class RpcServerManager { version: number, isCallerStillConnected: () => boolean, ) { - await this.engine.publishRpcAck(callerIdentity, requestId); + await this.publishRpcAck(callerIdentity, requestId); if (version !== 1) { if (isCallerStillConnected()) { - await this.engine.publishRpcResponse( + await this.publishRpcResponse( callerIdentity, requestId, null, @@ -101,7 +102,7 @@ export default class RpcServerManager { } catch (e) { this.log.error('Failed to decompress RPC request payload', e); if (isCallerStillConnected()) { - await this.engine.publishRpcResponse( + await this.publishRpcResponse( callerIdentity, requestId, null, @@ -116,7 +117,7 @@ export default class RpcServerManager { if (!handler) { if (isCallerStillConnected()) { - await this.engine.publishRpcResponse( + await this.publishRpcResponse( callerIdentity, requestId, null, @@ -147,7 +148,7 @@ export default class RpcServerManager { } if (isCallerStillConnected()) { - await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + await this.publishRpcResponse(callerIdentity, requestId, null, responseError); } return; } @@ -176,7 +177,7 @@ export default class RpcServerManager { // Medium response: compress inline if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC) { const compressed = await gzipCompress(response); - await this.engine.publishRpcResponseCompressed(callerIdentity, requestId, compressed); + await this.publishRpcResponseCompressed(callerIdentity, requestId, compressed); return; } @@ -185,17 +186,76 @@ export default class RpcServerManager { const responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); this.log.warn(`RPC Response payload too large for ${method}`); if (isCallerStillConnected()) { - await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + await this.publishRpcResponse(callerIdentity, requestId, null, responseError); } return; } // Legacy client: send uncompressed if (isCallerStillConnected()) { - await this.engine.publishRpcResponse(callerIdentity, requestId, response, null); + await this.publishRpcResponse(callerIdentity, requestId, response, null); } } + private async publishRpcAck(destinationIdentity: string, requestId: string) { + const packet = new DataPacket({ + destinationIdentities: [destinationIdentity], + kind: DataPacket_Kind.RELIABLE, + value: { + case: 'rpcAck', + value: new RpcAck({ + requestId, + }), + }, + }); + await this.engine.sendDataPacket(packet, DataPacket_Kind.RELIABLE); + } + + /** @internal */ + private async publishRpcResponse( + destinationIdentity: string, + requestId: string, + payload: string | null, + error: RpcError | null, + ) { + const packet = new DataPacket({ + destinationIdentities: [destinationIdentity], + kind: DataPacket_Kind.RELIABLE, + value: { + case: 'rpcResponse', + value: new RpcResponse({ + requestId, + value: error + ? { case: 'error', value: error.toProto() } + : { case: 'payload', value: payload ?? '' }, + }), + }, + }); + + await this.engine.sendDataPacket(packet, DataPacket_Kind.RELIABLE); + } + + /** @internal */ + private async publishRpcResponseCompressed( + destinationIdentity: string, + requestId: string, + compressedPayload: Uint8Array, + ) { + const packet = new DataPacket({ + destinationIdentities: [destinationIdentity], + kind: DataPacket_Kind.RELIABLE, + value: { + case: 'rpcResponse', + value: new RpcResponse({ + requestId, + value: { case: 'compressedPayload', value: compressedPayload }, + }), + }, + }); + + await this.engine.sendDataPacket(packet, DataPacket_Kind.RELIABLE); + } + /** * Handle an incoming byte stream containing an RPC request payload. * Decompresses the stream and resolves/rejects the pending data stream future. @@ -213,7 +273,7 @@ export default class RpcServerManager { this.log.warn( `RPC data stream malformed: ${RPC_REQUEST_ID_ATTR} / ${RPC_REQUEST_METHOD_ATTR} / ${RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR} not set.`, ); - await this.engine.publishRpcResponse( + await this.publishRpcResponse( callerIdentity, requestId, null, @@ -226,7 +286,7 @@ export default class RpcServerManager { decompressedPayload = await gzipDecompressFromReader(reader); } catch (e) { this.log.warn(`Error decompressing RPC request payload: ${e}`); - await this.engine.publishRpcResponse( + await this.publishRpcResponse( callerIdentity, requestId, null, @@ -238,7 +298,7 @@ export default class RpcServerManager { const handler = this.rpcHandlers.get(method); if (!handler) { - await this.engine.publishRpcResponse( + await this.publishRpcResponse( callerIdentity, requestId, null, @@ -267,7 +327,7 @@ export default class RpcServerManager { responseError = RpcError.builtIn('APPLICATION_ERROR'); } - await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + await this.publishRpcResponse(callerIdentity, requestId, null, responseError); return; } @@ -294,7 +354,7 @@ export default class RpcServerManager { if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC) { // Medium response: compress inline const compressed = await gzipCompress(response); - await this.engine.publishRpcResponseCompressed(callerIdentity, requestId, compressed); + await this.publishRpcResponseCompressed(callerIdentity, requestId, compressed); return; } @@ -302,11 +362,11 @@ export default class RpcServerManager { // Legacy client can't handle large payloads const responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); this.log.warn(`RPC Response payload too large for ${method}`); - await this.engine.publishRpcResponse(callerIdentity, requestId, null, responseError); + await this.publishRpcResponse(callerIdentity, requestId, null, responseError); return; } // Legacy client: send uncompressed - await this.engine.publishRpcResponse(callerIdentity, requestId, response, null); + await this.publishRpcResponse(callerIdentity, requestId, response, null); } } From e24cd15caedd051373f7578fcaa8d18f8d30260e Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 27 Mar 2026 17:42:51 -0400 Subject: [PATCH 20/54] feat: add initial rpc client manager / rpc server manager focused tests --- src/room/participant/LocalParticipant.ts | 4 +- src/room/rpc.test.ts | 181 ++++++++++++++++++++--- src/room/rpc/RpcClientManager.ts | 6 +- 3 files changed, 171 insertions(+), 20 deletions(-) diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index 04b8456f3d..ba7ce2b8bf 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -1797,7 +1797,9 @@ export default class LocalParticipant extends Participant { * @throws Error on failure. Details in `message`. */ performRpc(params: PerformRpcParams): TypedPromise { - return this.rpcClientManager.performRpc(params); + return this.rpcClientManager.performRpc(params).then(([_id, completionPromise]) => { + return completionPromise; + }); } /** diff --git a/src/room/rpc.test.ts b/src/room/rpc.test.ts index bda0a2c712..015b2fe4ac 100644 --- a/src/room/rpc.test.ts +++ b/src/room/rpc.test.ts @@ -1,16 +1,21 @@ import { DataPacket, DataPacket_Kind } from '@livekit/protocol'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import log from '../logger'; import type { InternalRoomOptions } from '../options'; +import { CLIENT_PROTOCOL_DEFAULT } from '../version'; import type RTCEngine from './RTCEngine'; import Room from './Room'; +import OutgoingDataStreamManager from './data-stream/outgoing/OutgoingDataStreamManager'; import LocalParticipant from './participant/LocalParticipant'; import { ParticipantKind } from './participant/Participant'; import RemoteParticipant from './participant/RemoteParticipant'; -import { RpcError } from './rpc'; +import { RpcClientManager, RpcError, RpcServerManager } from './rpc'; -describe('LocalParticipant', () => { +describe.skip('LocalParticipant', () => { describe('registerRpcMethod', () => { let room: Room; + let rpcClientManager: RpcClientManager; + let rpcServerManager: RpcServerManager; let mockSendDataPacket: ReturnType; beforeEach(() => { @@ -25,6 +30,30 @@ describe('LocalParticipant', () => { room.localParticipant.sid = 'test-sid'; room.localParticipant.identity = 'test-identity'; + + const mockEngine = { + client: { + sendUpdateLocalMetadata: vi.fn(), + }, + on: vi.fn().mockReturnThis(), + sendDataPacket: vi.fn(), + publishRpcAck: vi.fn(), + publishRpcResponse: vi.fn(), + } as unknown as RTCEngine; + + const outgoingDataStreamManager = new OutgoingDataStreamManager(mockEngine, log); + rpcClientManager = new RpcClientManager( + mockEngine, + log, + outgoingDataStreamManager, + (_identity) => 1, + ); + rpcServerManager = new RpcServerManager( + mockEngine, + log, + outgoingDataStreamManager, + (_identity) => 1, + ); }); it('should register an RPC method handler', async () => { @@ -44,7 +73,7 @@ describe('LocalParticipant', () => { ParticipantKind.STANDARD, ); - await room.handleIncomingRpcRequest( + await rpcServerManager.handleIncomingRpcRequest( mockCaller.identity, 'test-request-id', methodName, @@ -52,6 +81,7 @@ describe('LocalParticipant', () => { new Uint8Array(), 5000, 1, + () => true, ); expect(handler).toHaveBeenCalledWith({ @@ -91,7 +121,7 @@ describe('LocalParticipant', () => { ParticipantKind.STANDARD, ); - await room.handleIncomingRpcRequest( + await rpcServerManager.handleIncomingRpcRequest( mockCaller.identity, 'test-error-request-id', methodName, @@ -99,6 +129,7 @@ describe('LocalParticipant', () => { new Uint8Array(), 5000, 1, + () => true, ); expect(handler).toHaveBeenCalledWith({ @@ -135,7 +166,7 @@ describe('LocalParticipant', () => { ParticipantKind.STANDARD, ); - await room.handleIncomingRpcRequest( + await rpcServerManager.handleIncomingRpcRequest( mockCaller.identity, 'test-rpc-error-request-id', methodName, @@ -143,6 +174,7 @@ describe('LocalParticipant', () => { new Uint8Array(), 5000, 1, + () => true, ); expect(handler).toHaveBeenCalledWith({ @@ -164,6 +196,8 @@ describe('LocalParticipant', () => { describe('performRpc', () => { let localParticipant: LocalParticipant; + let rpcClientManager: RpcClientManager; + let rpcServerManager: RpcServerManager; let mockRemoteParticipant: RemoteParticipant; let mockEngine: RTCEngine; let mockRoomOptions: InternalRoomOptions; @@ -181,15 +215,28 @@ describe('LocalParticipant', () => { mockRoomOptions = {} as InternalRoomOptions; + const outgoingDataStreamManager = new OutgoingDataStreamManager(mockEngine, log); + rpcClientManager = new RpcClientManager( + mockEngine, + log, + outgoingDataStreamManager, + (_identity) => 1, + ); + rpcServerManager = new RpcServerManager( + mockEngine, + log, + outgoingDataStreamManager, + (_identity) => 1, + ); + localParticipant = new LocalParticipant( 'local-sid', 'local-identity', mockEngine, mockRoomOptions, - new Map(), - {} as any, - () => 0, - () => Promise.resolve(''), + outgoingDataStreamManager, + rpcClientManager, + rpcServerManager, ); mockRemoteParticipant = new RemoteParticipant( @@ -210,11 +257,11 @@ describe('LocalParticipant', () => { const responsePayload = 'responsePayload'; mockSendDataPacket.mockImplementationOnce((packet: DataPacket) => { - const requestId = packet.value.value.id; + const requestId = (packet.value.value as any).id; setTimeout(() => { - localParticipant.handleIncomingRpcAck(requestId); + rpcClientManager.handleIncomingRpcAck(requestId); setTimeout(() => { - localParticipant.handleIncomingRpcResponse(requestId, responsePayload, null); + rpcClientManager.handleIncomingRpcResponseSuccess(requestId, responsePayload); }, 10); }, 10); }); @@ -266,12 +313,11 @@ describe('LocalParticipant', () => { const errorMessage = 'Test error message'; mockSendDataPacket.mockImplementationOnce((packet: DataPacket) => { - const requestId = packet.value.value.id; + const requestId = (packet.value.value as any).id; setTimeout(() => { - localParticipant.handleIncomingRpcAck(requestId); - localParticipant.handleIncomingRpcResponse( + rpcClientManager.handleIncomingRpcAck(requestId); + rpcClientManager.handleIncomingRpcResponseFailure( requestId, - null, new RpcError(errorCode, errorMessage), ); }, 10); @@ -300,9 +346,110 @@ describe('LocalParticipant', () => { // Simulate a small delay before disconnection await new Promise((resolve) => setTimeout(resolve, 200)); - localParticipant.handleParticipantDisconnected(mockRemoteParticipant.identity); + rpcClientManager.handleParticipantDisconnected(mockRemoteParticipant.identity); await expect(resultPromise).rejects.toThrow('Recipient disconnected'); }); }); }); + +describe('RpcClientManager', () => { + it.skip('should send a rpc message to a participant (legacy path)', async () => { + const mockSendDataPacket = vi.fn(); + const mockEngine = { + client: { + sendUpdateLocalMetadata: vi.fn(), + }, + on: vi.fn().mockReturnThis(), + sendDataPacket: mockSendDataPacket, + } as unknown as RTCEngine; + + const outgoingDataStreamManager = new OutgoingDataStreamManager(mockEngine, log); + const rpcClientManager = new RpcClientManager( + mockEngine, + log, + outgoingDataStreamManager, + (_identity) => CLIENT_PROTOCOL_DEFAULT, // NOTE: All other participants are on client protocol 0 + ); + + mockSendDataPacket.mockImplementationOnce(() => Promise.resolve()); + + const [requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'destination-identity', + method: 'test-method', + payload: 'request-payload', + }); + + expect(mockSendDataPacket).toHaveBeenCalledTimes(1); + + // Make sure the request was sent + const packet = mockSendDataPacket.mock.lastCall![0]; + expect(packet.value.case).toStrictEqual('rpcRequest'); + expect(packet.value.value.id).toStrictEqual(requestId); + expect(packet.value.value.method).toStrictEqual('test-method'); + expect(packet.value.value.payload).toStrictEqual('request-payload'); + expect(packet.value.value.compressedPayload).toStrictEqual(new Uint8Array()); + + rpcClientManager.handleIncomingRpcAck(requestId); + + rpcClientManager.handleIncomingRpcResponseSuccess(requestId, 'response-payload'); + + await expect(completionPromise).resolves.toStrictEqual('response-payload'); + }); +}); + +describe('RpcServerManager', () => { + it('should receive a rpc message from a participant', async () => { + const mockSendDataPacket = vi.fn(); + const mockEngine = { + client: { + sendUpdateLocalMetadata: vi.fn(), + }, + on: vi.fn().mockReturnThis(), + sendDataPacket: mockSendDataPacket, + } as unknown as RTCEngine; + + const outgoingDataStreamManager = new OutgoingDataStreamManager(mockEngine, log); + const rpcServerManager = new RpcServerManager( + mockEngine, + log, + outgoingDataStreamManager, + (_identity) => CLIENT_PROTOCOL_DEFAULT, // NOTE: All other participants are on client protocol 0 + ); + + mockSendDataPacket + .mockImplementationOnce(() => Promise.resolve()) + .mockImplementationOnce(() => Promise.resolve()); + + const handler = vi.fn().mockReturnValueOnce('response payload'); + rpcServerManager.registerRpcMethod('test-method', handler); + + const requestId = crypto.randomUUID(); + const responseTimeoutMs = 10_000; + await rpcServerManager.handleIncomingRpcRequest( + 'caller-identity', + requestId, + 'test-method', + 'request payload', + new Uint8Array(), + responseTimeoutMs, + 1, + () => true, + ); + + // Make sure two packets were sent: + expect(mockSendDataPacket).toHaveBeenCalledTimes(2); + + // The first an acknowledgement of the request + const ackPacket = mockSendDataPacket.mock.calls[0][0]; + expect(ackPacket.value.case).toStrictEqual('rpcAck'); + expect(ackPacket.value.value.requestId).toStrictEqual(requestId); + + // And the second being the actual response + const rpcResponsePacket = mockSendDataPacket.mock.calls[1][0]; + expect(rpcResponsePacket.value.case).toStrictEqual('rpcResponse'); + expect(rpcResponsePacket.value.value.requestId).toStrictEqual(requestId); + expect(rpcResponsePacket.value.value.value.case).toStrictEqual('payload'); + expect(rpcResponsePacket.value.value.value.value).toStrictEqual('response payload'); + }); +}); diff --git a/src/room/rpc/RpcClientManager.ts b/src/room/rpc/RpcClientManager.ts index 004b1dbccd..e36fc58658 100644 --- a/src/room/rpc/RpcClientManager.ts +++ b/src/room/rpc/RpcClientManager.ts @@ -73,7 +73,7 @@ export default class RpcClientManager { method, payload, responseTimeout: responseTimeoutMs = 15000, - }: PerformRpcParams): Promise { + }: PerformRpcParams): Promise<[string /* id */, Promise]> { const maxRoundTripLatencyMs = 7000; const minEffectiveTimeoutMs = maxRoundTripLatencyMs + 1000; @@ -130,7 +130,7 @@ export default class RpcClientManager { participantIdentity: destinationIdentity, }); - return completionFuture.promise.finally(() => { + const completionPromise = completionFuture.promise.finally(() => { clearTimeout(responseTimeoutId); if (this.pendingAcks.has(id)) { @@ -139,6 +139,8 @@ export default class RpcClientManager { clearTimeout(ackTimeoutId); } }); + + return [id, completionPromise]; } private async publishRpcRequest( From bf666c2485ce63b58f6829d70e56876f6c125d64 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 27 Mar 2026 17:54:48 -0400 Subject: [PATCH 21/54] feat: port over all existing rpc tests to call RpcClientManager / RpcServerManager directly --- src/room/rpc.test.ts | 583 +++++++++++++++++-------------------------- 1 file changed, 228 insertions(+), 355 deletions(-) diff --git a/src/room/rpc.test.ts b/src/room/rpc.test.ts index 015b2fe4ac..fe97e0d12c 100644 --- a/src/room/rpc.test.ts +++ b/src/room/rpc.test.ts @@ -1,407 +1,168 @@ -import { DataPacket, DataPacket_Kind } from '@livekit/protocol'; +import { DataPacket_Kind } from '@livekit/protocol'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import log from '../logger'; -import type { InternalRoomOptions } from '../options'; import { CLIENT_PROTOCOL_DEFAULT } from '../version'; import type RTCEngine from './RTCEngine'; -import Room from './Room'; import OutgoingDataStreamManager from './data-stream/outgoing/OutgoingDataStreamManager'; -import LocalParticipant from './participant/LocalParticipant'; -import { ParticipantKind } from './participant/Participant'; -import RemoteParticipant from './participant/RemoteParticipant'; import { RpcClientManager, RpcError, RpcServerManager } from './rpc'; +import { sleep } from './utils'; -describe.skip('LocalParticipant', () => { - describe('registerRpcMethod', () => { - let room: Room; - let rpcClientManager: RpcClientManager; - let rpcServerManager: RpcServerManager; - let mockSendDataPacket: ReturnType; - - beforeEach(() => { - mockSendDataPacket = vi.fn(); +describe('RpcClientManager', () => { + let rpcClientManager: RpcClientManager; + let mockSendDataPacket: ReturnType; + let mockEngine: RTCEngine; - room = new Room(); - room.engine.client = { + beforeEach(() => { + mockSendDataPacket = vi.fn(); + mockEngine = { + client: { sendUpdateLocalMetadata: vi.fn(), - }; - room.engine.on = vi.fn().mockReturnThis(); - room.engine.sendDataPacket = mockSendDataPacket; - - room.localParticipant.sid = 'test-sid'; - room.localParticipant.identity = 'test-identity'; - - const mockEngine = { - client: { - sendUpdateLocalMetadata: vi.fn(), - }, - on: vi.fn().mockReturnThis(), - sendDataPacket: vi.fn(), - publishRpcAck: vi.fn(), - publishRpcResponse: vi.fn(), - } as unknown as RTCEngine; - - const outgoingDataStreamManager = new OutgoingDataStreamManager(mockEngine, log); - rpcClientManager = new RpcClientManager( - mockEngine, - log, - outgoingDataStreamManager, - (_identity) => 1, - ); - rpcServerManager = new RpcServerManager( - mockEngine, - log, - outgoingDataStreamManager, - (_identity) => 1, - ); - }); - - it('should register an RPC method handler', async () => { - const methodName = 'testMethod'; - const handler = vi.fn().mockResolvedValue('test response'); - - room.registerRpcMethod(methodName, handler); - - const mockCaller = new RemoteParticipant( - {} as any, - 'remote-sid', - 'remote-identity', - 'Remote Participant', - '', - undefined, - undefined, - ParticipantKind.STANDARD, - ); - - await rpcServerManager.handleIncomingRpcRequest( - mockCaller.identity, - 'test-request-id', - methodName, - 'test payload', - new Uint8Array(), - 5000, - 1, - () => true, - ); - - expect(handler).toHaveBeenCalledWith({ - requestId: 'test-request-id', - callerIdentity: mockCaller.identity, - payload: 'test payload', - responseTimeout: 5000, - }); + }, + on: vi.fn().mockReturnThis(), + sendDataPacket: mockSendDataPacket, + } as unknown as RTCEngine; - // Check if sendDataPacket was called twice (once for ACK and once for response) - expect(mockSendDataPacket).toHaveBeenCalledTimes(2); + const outgoingDataStreamManager = new OutgoingDataStreamManager(mockEngine, log); + rpcClientManager = new RpcClientManager( + mockEngine, + log, + outgoingDataStreamManager, + (_identity) => CLIENT_PROTOCOL_DEFAULT, + ); + }); - // Check if the first call was for ACK - expect(mockSendDataPacket.mock.calls[0][0].value.case).toBe('rpcAck'); - expect(mockSendDataPacket.mock.calls[0][1]).toBe(DataPacket_Kind.RELIABLE); + it.skip('should send a rpc message to a participant (legacy path)', async () => { + mockSendDataPacket.mockImplementationOnce(() => Promise.resolve()); - // Check if the second call was for response - expect(mockSendDataPacket.mock.calls[1][0].value.case).toBe('rpcResponse'); - expect(mockSendDataPacket.mock.calls[1][1]).toBe(DataPacket_Kind.RELIABLE); + const [requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'destination-identity', + method: 'test-method', + payload: 'request-payload', }); - it('should catch and transform unhandled errors in the RPC method handler', async () => { - const methodName = 'errorMethod'; - const errorMessage = 'Test error'; - const handler = vi.fn().mockRejectedValue(new Error(errorMessage)); - - room.registerRpcMethod(methodName, handler); - - const mockCaller = new RemoteParticipant( - {} as any, - 'remote-sid', - 'remote-identity', - 'Remote Participant', - '', - undefined, - undefined, - ParticipantKind.STANDARD, - ); - - await rpcServerManager.handleIncomingRpcRequest( - mockCaller.identity, - 'test-error-request-id', - methodName, - 'test payload', - new Uint8Array(), - 5000, - 1, - () => true, - ); - - expect(handler).toHaveBeenCalledWith({ - requestId: 'test-error-request-id', - callerIdentity: mockCaller.identity, - payload: 'test payload', - responseTimeout: 5000, - }); - - // Check if sendDataPacket was called twice (once for ACK and once for error response) - expect(mockSendDataPacket).toHaveBeenCalledTimes(2); + expect(mockSendDataPacket).toHaveBeenCalledTimes(1); - // Check if the second call was for error response - const errorResponse = mockSendDataPacket.mock.calls[1][0].value.value.value.value; - expect(errorResponse.code).toBe(RpcError.ErrorCode.APPLICATION_ERROR); - }); + // Make sure the request was sent + const packet = mockSendDataPacket.mock.lastCall![0]; + expect(packet.value.case).toStrictEqual('rpcRequest'); + expect(packet.value.value.id).toStrictEqual(requestId); + expect(packet.value.value.method).toStrictEqual('test-method'); + expect(packet.value.value.payload).toStrictEqual('request-payload'); + expect(packet.value.value.compressedPayload).toStrictEqual(new Uint8Array()); - it('should pass through RpcError thrown by the RPC method handler', async () => { - const methodName = 'rpcErrorMethod'; - const errorCode = 101; - const errorMessage = 'some-error-message'; - const handler = vi.fn().mockRejectedValue(new RpcError(errorCode, errorMessage)); - - room.localParticipant.registerRpcMethod(methodName, handler); - - const mockCaller = new RemoteParticipant( - {} as any, - 'remote-sid', - 'remote-identity', - 'Remote Participant', - '', - undefined, - undefined, - ParticipantKind.STANDARD, - ); - - await rpcServerManager.handleIncomingRpcRequest( - mockCaller.identity, - 'test-rpc-error-request-id', - methodName, - 'test payload', - new Uint8Array(), - 5000, - 1, - () => true, - ); - - expect(handler).toHaveBeenCalledWith({ - requestId: 'test-rpc-error-request-id', - callerIdentity: mockCaller.identity, - payload: 'test payload', - responseTimeout: 5000, - }); + rpcClientManager.handleIncomingRpcAck(requestId); - // Check if sendDataPacket was called twice (once for ACK and once for error response) - expect(mockSendDataPacket).toHaveBeenCalledTimes(2); + rpcClientManager.handleIncomingRpcResponseSuccess(requestId, 'response-payload'); - // Check if the second call was for error response - const errorResponse = mockSendDataPacket.mock.calls[1][0].value.value.value.value; - expect(errorResponse.code).toBe(errorCode); - expect(errorResponse.message).toBe(errorMessage); - }); + await expect(completionPromise).resolves.toStrictEqual('response-payload'); }); - describe('performRpc', () => { - let localParticipant: LocalParticipant; - let rpcClientManager: RpcClientManager; - let rpcServerManager: RpcServerManager; - let mockRemoteParticipant: RemoteParticipant; - let mockEngine: RTCEngine; - let mockRoomOptions: InternalRoomOptions; - let mockSendDataPacket: ReturnType; - - beforeEach(() => { - mockSendDataPacket = vi.fn(); - mockEngine = { - client: { - sendUpdateLocalMetadata: vi.fn(), - }, - on: vi.fn().mockReturnThis(), - sendDataPacket: mockSendDataPacket, - } as unknown as RTCEngine; - - mockRoomOptions = {} as InternalRoomOptions; - - const outgoingDataStreamManager = new OutgoingDataStreamManager(mockEngine, log); - rpcClientManager = new RpcClientManager( - mockEngine, - log, - outgoingDataStreamManager, - (_identity) => 1, - ); - rpcServerManager = new RpcServerManager( - mockEngine, - log, - outgoingDataStreamManager, - (_identity) => 1, - ); - - localParticipant = new LocalParticipant( - 'local-sid', - 'local-identity', - mockEngine, - mockRoomOptions, - outgoingDataStreamManager, - rpcClientManager, - rpcServerManager, - ); - - mockRemoteParticipant = new RemoteParticipant( - {} as any, - 'remote-sid', - 'remote-identity', - 'Remote Participant', - '', - undefined, - undefined, - ParticipantKind.STANDARD, - ); + it('should send RPC request and receive successful response', async () => { + const method = 'testMethod'; + const payload = 'testPayload'; + const responsePayload = 'responsePayload'; + + mockSendDataPacket.mockImplementationOnce(() => Promise.resolve()); + + const [requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remote-identity', + method, + payload, }); - it('should send RPC request and receive successful response', async () => { - const method = 'testMethod'; - const payload = 'testPayload'; - const responsePayload = 'responsePayload'; - - mockSendDataPacket.mockImplementationOnce((packet: DataPacket) => { - const requestId = (packet.value.value as any).id; - setTimeout(() => { - rpcClientManager.handleIncomingRpcAck(requestId); - setTimeout(() => { - rpcClientManager.handleIncomingRpcResponseSuccess(requestId, responsePayload); - }, 10); - }, 10); - }); + expect(mockSendDataPacket).toHaveBeenCalledTimes(1); - const result = await localParticipant.performRpc({ - destinationIdentity: mockRemoteParticipant.identity, - method, - payload, - }); + setTimeout(() => { + rpcClientManager.handleIncomingRpcAck(requestId); + setTimeout(() => { + rpcClientManager.handleIncomingRpcResponseSuccess(requestId, responsePayload); + }, 10); + }, 10); - expect(mockSendDataPacket).toHaveBeenCalledTimes(1); - expect(result).toBe(responsePayload); - }); + const result = await completionPromise; + expect(result).toStrictEqual(responsePayload); + }); - it('should handle RPC request timeout', async () => { + it('should handle RPC request timeout', async () => { + vi.useFakeTimers(); + + try { const method = 'timeoutMethod'; const payload = 'timeoutPayload'; - const timeout = 50; - const resultPromise = localParticipant.performRpc({ - destinationIdentity: mockRemoteParticipant.identity, + mockSendDataPacket.mockImplementationOnce(() => Promise.resolve()); + + const [_requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remote-identity', method, payload, responseTimeout: timeout, }); - mockSendDataPacket.mockImplementationOnce(() => { - return new Promise((resolve) => { - setTimeout(resolve, timeout + 10); - }); - }); + // Register the rejection handler before advancing so the rejection is caught + const rejectPromise = expect(completionPromise).rejects.toThrow('Response timeout'); - const startTime = Date.now(); + // Response timeout (50ms) fires before ack timeout (7000ms) + await vi.advanceTimersByTimeAsync(timeout); - await expect(resultPromise).rejects.toThrow('Response timeout'); - - const elapsedTime = Date.now() - startTime; - expect(elapsedTime).toBeGreaterThanOrEqual(timeout); - expect(elapsedTime).toBeLessThan(timeout + 50); // Allow some margin for test execution + await rejectPromise; + } finally { + vi.useRealTimers(); + } + }); - expect(mockSendDataPacket).toHaveBeenCalledTimes(1); - }); + it('should handle RPC error response', async () => { + const method = 'errorMethod'; + const payload = 'errorPayload'; + const errorCode = 101; + const errorMessage = 'Test error message'; - it('should handle RPC error response', async () => { - const method = 'errorMethod'; - const payload = 'errorPayload'; - const errorCode = 101; - const errorMessage = 'Test error message'; - - mockSendDataPacket.mockImplementationOnce((packet: DataPacket) => { - const requestId = (packet.value.value as any).id; - setTimeout(() => { - rpcClientManager.handleIncomingRpcAck(requestId); - rpcClientManager.handleIncomingRpcResponseFailure( - requestId, - new RpcError(errorCode, errorMessage), - ); - }, 10); - }); + mockSendDataPacket.mockImplementationOnce(() => Promise.resolve()); - await expect( - localParticipant.performRpc({ - destinationIdentity: mockRemoteParticipant.identity, - method, - payload, - }), - ).rejects.toThrow(errorMessage); + const [requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remote-identity', + method, + payload, }); - it('should handle participant disconnection during RPC request', async () => { - const method = 'disconnectMethod'; - const payload = 'disconnectPayload'; - - mockSendDataPacket.mockImplementationOnce(() => Promise.resolve()); - - const resultPromise = localParticipant.performRpc({ - destinationIdentity: mockRemoteParticipant.identity, - method, - payload, - }); - - // Simulate a small delay before disconnection - await new Promise((resolve) => setTimeout(resolve, 200)); - rpcClientManager.handleParticipantDisconnected(mockRemoteParticipant.identity); + rpcClientManager.handleIncomingRpcAck(requestId); + rpcClientManager.handleIncomingRpcResponseFailure( + requestId, + new RpcError(errorCode, errorMessage), + ); - await expect(resultPromise).rejects.toThrow('Recipient disconnected'); - }); + await expect(completionPromise).rejects.toThrow(errorMessage); }); -}); -describe('RpcClientManager', () => { - it.skip('should send a rpc message to a participant (legacy path)', async () => { - const mockSendDataPacket = vi.fn(); - const mockEngine = { - client: { - sendUpdateLocalMetadata: vi.fn(), - }, - on: vi.fn().mockReturnThis(), - sendDataPacket: mockSendDataPacket, - } as unknown as RTCEngine; - - const outgoingDataStreamManager = new OutgoingDataStreamManager(mockEngine, log); - const rpcClientManager = new RpcClientManager( - mockEngine, - log, - outgoingDataStreamManager, - (_identity) => CLIENT_PROTOCOL_DEFAULT, // NOTE: All other participants are on client protocol 0 - ); + it('should handle participant disconnection during RPC request', async () => { + const method = 'disconnectMethod'; + const payload = 'disconnectPayload'; mockSendDataPacket.mockImplementationOnce(() => Promise.resolve()); - const [requestId, completionPromise] = await rpcClientManager.performRpc({ - destinationIdentity: 'destination-identity', - method: 'test-method', - payload: 'request-payload', + const [_requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remote-identity', + method, + payload, }); - expect(mockSendDataPacket).toHaveBeenCalledTimes(1); - - // Make sure the request was sent - const packet = mockSendDataPacket.mock.lastCall![0]; - expect(packet.value.case).toStrictEqual('rpcRequest'); - expect(packet.value.value.id).toStrictEqual(requestId); - expect(packet.value.value.method).toStrictEqual('test-method'); - expect(packet.value.value.payload).toStrictEqual('request-payload'); - expect(packet.value.value.compressedPayload).toStrictEqual(new Uint8Array()); - - rpcClientManager.handleIncomingRpcAck(requestId); - - rpcClientManager.handleIncomingRpcResponseSuccess(requestId, 'response-payload'); + // Simulate a small delay before disconnection + await sleep(200); + rpcClientManager.handleParticipantDisconnected('remote-identity'); - await expect(completionPromise).resolves.toStrictEqual('response-payload'); + await expect(completionPromise).rejects.toThrow('Recipient disconnected'); }); }); describe('RpcServerManager', () => { - it('should receive a rpc message from a participant', async () => { - const mockSendDataPacket = vi.fn(); - const mockEngine = { + let rpcServerManager: RpcServerManager; + let mockSendDataPacket: ReturnType; + let mockEngine: RTCEngine; + + beforeEach(() => { + mockSendDataPacket = vi.fn(); + mockEngine = { client: { sendUpdateLocalMetadata: vi.fn(), }, @@ -410,17 +171,17 @@ describe('RpcServerManager', () => { } as unknown as RTCEngine; const outgoingDataStreamManager = new OutgoingDataStreamManager(mockEngine, log); - const rpcServerManager = new RpcServerManager( + rpcServerManager = new RpcServerManager( mockEngine, log, outgoingDataStreamManager, - (_identity) => CLIENT_PROTOCOL_DEFAULT, // NOTE: All other participants are on client protocol 0 + (_identity) => CLIENT_PROTOCOL_DEFAULT, ); - mockSendDataPacket - .mockImplementationOnce(() => Promise.resolve()) - .mockImplementationOnce(() => Promise.resolve()); + mockSendDataPacket.mockImplementation(() => Promise.resolve()); + }); + it('should receive a rpc message from a participant', async () => { const handler = vi.fn().mockReturnValueOnce('response payload'); rpcServerManager.registerRpcMethod('test-method', handler); @@ -452,4 +213,116 @@ describe('RpcServerManager', () => { expect(rpcResponsePacket.value.value.value.case).toStrictEqual('payload'); expect(rpcResponsePacket.value.value.value.value).toStrictEqual('response payload'); }); + + it('should register an RPC method handler', async () => { + const methodName = 'testMethod'; + const handler = vi.fn().mockResolvedValue('test response'); + + rpcServerManager.registerRpcMethod(methodName, handler); + + await rpcServerManager.handleIncomingRpcRequest( + 'remote-identity', + 'test-request-id', + methodName, + 'test payload', + new Uint8Array(), + 5000, + 1, + () => true, + ); + + expect(handler).toHaveBeenCalledWith({ + requestId: 'test-request-id', + callerIdentity: 'remote-identity', + payload: 'test payload', + responseTimeout: 5000, + }); + + // Ensure sendDataPacket was called twice (once for the ack and once for response) + expect(mockSendDataPacket).toHaveBeenCalledTimes(2); + + // Ensure the first call was for the ack + expect(mockSendDataPacket.mock.calls[0][0].value.case).toStrictEqual('rpcAck'); + expect(mockSendDataPacket.mock.calls[0][1]).toStrictEqual(DataPacket_Kind.RELIABLE); + + // and the second call was for the response + expect(mockSendDataPacket.mock.calls[1][0].value.case).toStrictEqual('rpcResponse'); + expect(mockSendDataPacket.mock.calls[1][1]).toStrictEqual(DataPacket_Kind.RELIABLE); + }); + + it('should catch and transform unhandled errors in the RPC method handler', async () => { + const methodName = 'errorMethod'; + const errorMessage = 'Test error'; + const handler = vi.fn().mockRejectedValue(new Error(errorMessage)); + + rpcServerManager.registerRpcMethod(methodName, handler); + + await rpcServerManager.handleIncomingRpcRequest( + 'remote-identity', + 'test-error-request-id', + methodName, + 'test payload', + new Uint8Array(), + 5000, + 1, + () => true, + ); + + expect(handler).toHaveBeenCalledWith({ + requestId: 'test-error-request-id', + callerIdentity: 'remote-identity', + payload: 'test payload', + responseTimeout: 5000, + }); + + // Ensure sendDataPacket was called twice (once for ack and once for error response) + expect(mockSendDataPacket).toHaveBeenCalledTimes(2); + + // Ensure the first call was for the ack + expect(mockSendDataPacket.mock.calls[0][0].value.case).toStrictEqual('rpcAck'); + expect(mockSendDataPacket.mock.calls[0][1]).toStrictEqual(DataPacket_Kind.RELIABLE); + + // And the second call was for the error response + const errorResponse = mockSendDataPacket.mock.calls[1][0].value.value.value.value; + expect(errorResponse.code).toStrictEqual(RpcError.ErrorCode.APPLICATION_ERROR); + }); + + it('should pass through RpcError thrown by the RPC method handler', async () => { + const methodName = 'rpcErrorMethod'; + const errorCode = 101; + const errorMessage = 'some-error-message'; + const handler = vi.fn().mockRejectedValue(new RpcError(errorCode, errorMessage)); + + rpcServerManager.registerRpcMethod(methodName, handler); + + await rpcServerManager.handleIncomingRpcRequest( + 'remote-identity', + 'test-rpc-error-request-id', + methodName, + 'test payload', + new Uint8Array(), + 5000, + 1, + () => true, + ); + + expect(handler).toHaveBeenCalledWith({ + requestId: 'test-rpc-error-request-id', + callerIdentity: 'remote-identity', + payload: 'test payload', + responseTimeout: 5000, + }); + + // Ensure sendDataPacket was called twice (once for ACK and once for error response) + expect(mockSendDataPacket).toHaveBeenCalledTimes(2); + + // Ensure the first call was for the ack + expect(mockSendDataPacket.mock.calls[0][0].value.case).toStrictEqual('rpcAck'); + expect(mockSendDataPacket.mock.calls[0][1]).toStrictEqual(DataPacket_Kind.RELIABLE); + + // And the second call was for the error response + const errorResponse = mockSendDataPacket.mock.calls[1][0].value.value.value.value; + expect(errorResponse.code).toStrictEqual(errorCode); + expect(errorResponse.message).toStrictEqual(errorMessage); + }); }); From 1cfb5c4fbe29637f6f1524cb1187d0bc2fe15d96 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 27 Mar 2026 17:57:51 -0400 Subject: [PATCH 22/54] fix: address tsc warnings --- src/room/RTCEngine.ts | 1 - src/room/rpc.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 2b9c72f50d..5a655202ab 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -74,7 +74,6 @@ import { UnexpectedConnectionState, } from './errors'; import { EngineEvent } from './events'; -import { RpcError } from './rpc'; import CriticalTimers from './timers'; import type LocalTrack from './track/LocalTrack'; import type LocalTrackPublication from './track/LocalTrackPublication'; diff --git a/src/room/rpc.test.ts b/src/room/rpc.test.ts index fe97e0d12c..e99467cdd5 100644 --- a/src/room/rpc.test.ts +++ b/src/room/rpc.test.ts @@ -93,7 +93,7 @@ describe('RpcClientManager', () => { mockSendDataPacket.mockImplementationOnce(() => Promise.resolve()); - const [_requestId, completionPromise] = await rpcClientManager.performRpc({ + const [requestId, completionPromise] = await rpcClientManager.performRpc({ destinationIdentity: 'remote-identity', method, payload, @@ -141,7 +141,7 @@ describe('RpcClientManager', () => { mockSendDataPacket.mockImplementationOnce(() => Promise.resolve()); - const [_requestId, completionPromise] = await rpcClientManager.performRpc({ + const [requestId, completionPromise] = await rpcClientManager.performRpc({ destinationIdentity: 'remote-identity', method, payload, From 671664c2201131d586670392ac86be3ef978ea40 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 27 Mar 2026 18:00:38 -0400 Subject: [PATCH 23/54] fix: remove stale throws import --- src/room/utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/room/utils.ts b/src/room/utils.ts index 79905aff9c..42cb401e8d 100644 --- a/src/room/utils.ts +++ b/src/room/utils.ts @@ -9,7 +9,6 @@ import { type Throws } from '@livekit/throws-transformer/throws'; import TypedPromise from '../utils/TypedPromise'; import { getBrowser } from '../utils/browserParser'; import type { BrowserDetails } from '../utils/browserParser'; -import { type Throws } from '../utils/throws'; import { clientProtocol, protocolVersion, version } from '../version'; import { type ConnectionError, ConnectionErrorReason } from './errors'; import type LocalParticipant from './participant/LocalParticipant'; From cbae0806a4f2a375c529c4d20c244dcfd2db23ef Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 30 Mar 2026 17:02:38 -0400 Subject: [PATCH 24/54] feat: migrate rpc to use data streams for sending rpc requests --- src/room/Room.ts | 1 - src/room/rpc.test.ts | 5 - src/room/rpc/RpcClientManager.ts | 100 +++++------------- src/room/rpc/RpcServerManager.ts | 169 ++++++++----------------------- src/room/rpc/utils.ts | 6 -- 5 files changed, 69 insertions(+), 212 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index 04a935c2a3..ab982f2b50 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -1974,7 +1974,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) rpc.id, rpc.method, rpc.payload, - rpc.compressedPayload, rpc.responseTimeoutMs, rpc.version, () => this.remoteParticipants.has(packet.participantIdentity), diff --git a/src/room/rpc.test.ts b/src/room/rpc.test.ts index e99467cdd5..6689f4b34e 100644 --- a/src/room/rpc.test.ts +++ b/src/room/rpc.test.ts @@ -48,7 +48,6 @@ describe('RpcClientManager', () => { expect(packet.value.value.id).toStrictEqual(requestId); expect(packet.value.value.method).toStrictEqual('test-method'); expect(packet.value.value.payload).toStrictEqual('request-payload'); - expect(packet.value.value.compressedPayload).toStrictEqual(new Uint8Array()); rpcClientManager.handleIncomingRpcAck(requestId); @@ -192,7 +191,6 @@ describe('RpcServerManager', () => { requestId, 'test-method', 'request payload', - new Uint8Array(), responseTimeoutMs, 1, () => true, @@ -225,7 +223,6 @@ describe('RpcServerManager', () => { 'test-request-id', methodName, 'test payload', - new Uint8Array(), 5000, 1, () => true, @@ -262,7 +259,6 @@ describe('RpcServerManager', () => { 'test-error-request-id', methodName, 'test payload', - new Uint8Array(), 5000, 1, () => true, @@ -300,7 +296,6 @@ describe('RpcServerManager', () => { 'test-rpc-error-request-id', methodName, 'test payload', - new Uint8Array(), 5000, 1, () => true, diff --git a/src/room/rpc/RpcClientManager.ts b/src/room/rpc/RpcClientManager.ts index e36fc58658..20835c3baa 100644 --- a/src/room/rpc/RpcClientManager.ts +++ b/src/room/rpc/RpcClientManager.ts @@ -11,7 +11,6 @@ import { EngineEvent } from '../events'; import type Participant from '../participant/Participant'; import { Future, compareVersions } from '../utils'; import { - DATA_STREAM_MIN_BYTES, MAX_LEGACY_PAYLOAD_BYTES, type PerformRpcParams, RPC_DATA_STREAM_TOPIC, @@ -20,9 +19,7 @@ import { RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR, RpcError, byteLength, - gzipCompress, gzipCompressToWriter, - gzipDecompress, gzipDecompressFromReader, } from './utils'; @@ -151,70 +148,38 @@ export default class RpcClientManager { responseTimeout: number, remoteClientProtocol: number, ) { - const payloadBytes = byteLength(payload); - - let mode: 'regular' | 'compressed' | 'compressed-data-stream' = 'regular'; if (remoteClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC) { - mode = 'compressed'; - } - if (mode === 'compressed' && payloadBytes > DATA_STREAM_MIN_BYTES) { - mode = 'compressed-data-stream'; + // Send payload as a compressed data stream + const writer = await this.outgoingDataStreamManager.streamBytes({ + topic: RPC_DATA_STREAM_TOPIC, + destinationIdentities: [destinationIdentity], + mimeType: 'application/octet-stream', + attributes: { + [RPC_REQUEST_ID_ATTR]: requestId, + [RPC_REQUEST_METHOD_ATTR]: method, + [RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR]: `${responseTimeout}`, + }, + }); + await gzipCompressToWriter(payload, writer); + await writer.close(); + return; } - switch (mode) { - case 'compressed-data-stream': { - // Large payload: create the data stream tagged with the request ID, - // send the RPC request with empty payload/compressedPayload, then - // stream compressed chunks for lower TTFB - const writer = await this.outgoingDataStreamManager.streamBytes({ - topic: RPC_DATA_STREAM_TOPIC, - destinationIdentities: [destinationIdentity], - mimeType: 'application/octet-stream', - attributes: { - [RPC_REQUEST_ID_ATTR]: requestId, - [RPC_REQUEST_METHOD_ATTR]: method, - [RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR]: `${responseTimeout}`, - }, - }); - await gzipCompressToWriter(payload, writer); - await writer.close(); - return; - } - - case 'compressed': - // Medium payload: compress inline - const compressedPayload = await gzipCompress(payload); - await this.sendRpcRequestPacket( - destinationIdentity, - requestId, - method, - '', - compressedPayload, - responseTimeout, - ); - break; - - case 'regular': - default: - // Small payload: just include the payload directly, uncompressed - await this.sendRpcRequestPacket( - destinationIdentity, - requestId, - method, - payload, - undefined, - responseTimeout, - ); - break; - } + // Legacy client: send uncompressed payload inline + await this.sendRpcRequestPacket( + destinationIdentity, + requestId, + method, + payload, + responseTimeout, + ); } private async sendRpcRequestPacket( destinationIdentity: string, requestId: string, method: string, - payload: string | undefined, - compressedPayload: Uint8Array | undefined, + payload: string, responseTimeout: number, ) { const packet = new DataPacket({ @@ -225,8 +190,7 @@ export default class RpcClientManager { value: new RpcRequest({ id: requestId, method, - payload: payload ?? '', - compressedPayload: compressedPayload ?? new Uint8Array(), + payload, responseTimeoutMs: responseTimeout, version: 1, }), @@ -251,22 +215,6 @@ export default class RpcClientManager { return true; } - case 'compressedPayload': { - let payload; - try { - payload = await gzipDecompress(rpcResponse.value.value); - } catch (e) { - this.log.error('Failed to decompress RPC response', e); - this.handleIncomingRpcResponseFailure( - rpcResponse.requestId, - RpcError.builtIn('APPLICATION_ERROR'), - ); - return true; - } - this.handleIncomingRpcResponseSuccess(rpcResponse.requestId, payload); - return true; - } - case 'error': { const error = RpcError.fromProto(rpcResponse.value.value); this.handleIncomingRpcResponseFailure(rpcResponse.requestId, error); diff --git a/src/room/rpc/RpcServerManager.ts b/src/room/rpc/RpcServerManager.ts index 1d5f2ca11c..96ac03dd00 100644 --- a/src/room/rpc/RpcServerManager.ts +++ b/src/room/rpc/RpcServerManager.ts @@ -9,7 +9,6 @@ import type { ByteStreamReader } from '../data-stream/incoming/StreamReader'; import type OutgoingDataStreamManager from '../data-stream/outgoing/OutgoingDataStreamManager'; import type Participant from '../participant/Participant'; import { - DATA_STREAM_MIN_BYTES, MAX_LEGACY_PAYLOAD_BYTES, RPC_DATA_STREAM_TOPIC, RPC_REQUEST_ID_ATTR, @@ -19,9 +18,7 @@ import { RpcError, type RpcInvocationData, byteLength, - gzipCompress, gzipCompressToWriter, - gzipDecompress, gzipDecompressFromReader, } from './utils'; @@ -75,7 +72,6 @@ export default class RpcServerManager { requestId: string, method: string, payload: string, - compressedPayload: Uint8Array, responseTimeout: number, version: number, isCallerStillConnected: () => boolean, @@ -84,7 +80,7 @@ export default class RpcServerManager { if (version !== 1) { if (isCallerStillConnected()) { - await this.publishRpcResponse( + await this.publishRpcResponsePacket( callerIdentity, requestId, null, @@ -94,30 +90,11 @@ export default class RpcServerManager { return; } - // Resolve the actual payload from compressed or data stream sources - let resolvedPayload = payload; - if (compressedPayload && compressedPayload.length > 0) { - try { - resolvedPayload = await gzipDecompress(compressedPayload); - } catch (e) { - this.log.error('Failed to decompress RPC request payload', e); - if (isCallerStillConnected()) { - await this.publishRpcResponse( - callerIdentity, - requestId, - null, - RpcError.builtIn('APPLICATION_ERROR'), - ); - } - return; - } - } - const handler = this.rpcHandlers.get(method); if (!handler) { if (isCallerStillConnected()) { - await this.publishRpcResponse( + await this.publishRpcResponsePacket( callerIdentity, requestId, null, @@ -132,7 +109,7 @@ export default class RpcServerManager { response = await handler({ requestId, callerIdentity, - payload: resolvedPayload, + payload, responseTimeout, }); } catch (error) { @@ -148,52 +125,13 @@ export default class RpcServerManager { } if (isCallerStillConnected()) { - await this.publishRpcResponse(callerIdentity, requestId, null, responseError); + await this.publishRpcResponsePacket(callerIdentity, requestId, null, responseError); } return; } - // Determine how to send the response based on the caller's client protocol - const callerClientProtocol = this.getRemoteParticipantClientProtocol(callerIdentity); - - const responseBytes = byteLength(response ?? ''); - - // Large response: create the data stream tagged with the request ID, - // send the RPC response with empty payload, then stream compressed chunks - // for lower TTFB - if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && responseBytes > DATA_STREAM_MIN_BYTES) { - const writer = await this.outgoingDataStreamManager.streamBytes({ - topic: RPC_DATA_STREAM_TOPIC, - destinationIdentities: [callerIdentity], - mimeType: 'application/octet-stream', - attributes: { [RPC_RESPONSE_ID_ATTR]: requestId }, - }); - - await gzipCompressToWriter(response, writer); - await writer.close(); - return; - } - - // Medium response: compress inline - if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC) { - const compressed = await gzipCompress(response); - await this.publishRpcResponseCompressed(callerIdentity, requestId, compressed); - return; - } - - // Legacy client can't handle large payloads - if (responseBytes > MAX_LEGACY_PAYLOAD_BYTES) { - const responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); - this.log.warn(`RPC Response payload too large for ${method}`); - if (isCallerStillConnected()) { - await this.publishRpcResponse(callerIdentity, requestId, null, responseError); - } - return; - } - - // Legacy client: send uncompressed if (isCallerStillConnected()) { - await this.publishRpcResponse(callerIdentity, requestId, response, null); + await this.publishRpcResponse(callerIdentity, requestId, response ?? ''); } } @@ -212,7 +150,7 @@ export default class RpcServerManager { } /** @internal */ - private async publishRpcResponse( + private async publishRpcResponsePacket( destinationIdentity: string, requestId: string, payload: string | null, @@ -235,25 +173,44 @@ export default class RpcServerManager { await this.engine.sendDataPacket(packet, DataPacket_Kind.RELIABLE); } - /** @internal */ - private async publishRpcResponseCompressed( + /** + * Send a successful RPC response payload, choosing the transport based on + * the caller's client protocol version. + */ + private async publishRpcResponse( destinationIdentity: string, requestId: string, - compressedPayload: Uint8Array, + payload: string, ) { - const packet = new DataPacket({ - destinationIdentities: [destinationIdentity], - kind: DataPacket_Kind.RELIABLE, - value: { - case: 'rpcResponse', - value: new RpcResponse({ - requestId, - value: { case: 'compressedPayload', value: compressedPayload }, - }), - }, - }); + const callerClientProtocol = this.getRemoteParticipantClientProtocol(destinationIdentity); - await this.engine.sendDataPacket(packet, DataPacket_Kind.RELIABLE); + if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC) { + // Send response as a compressed data stream + const writer = await this.outgoingDataStreamManager.streamBytes({ + topic: RPC_DATA_STREAM_TOPIC, + destinationIdentities: [destinationIdentity], + mimeType: 'application/octet-stream', + attributes: { [RPC_RESPONSE_ID_ATTR]: requestId }, + }); + await gzipCompressToWriter(payload, writer); + await writer.close(); + return; + } + + // Legacy client: enforce size limit and send uncompressed payload inline + const responseBytes = byteLength(payload); + if (responseBytes > MAX_LEGACY_PAYLOAD_BYTES) { + this.log.warn(`RPC Response payload too large for request ${requestId}`); + await this.publishRpcResponsePacket( + destinationIdentity, + requestId, + null, + RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'), + ); + return; + } + + await this.publishRpcResponsePacket(destinationIdentity, requestId, payload, null); } /** @@ -273,7 +230,7 @@ export default class RpcServerManager { this.log.warn( `RPC data stream malformed: ${RPC_REQUEST_ID_ATTR} / ${RPC_REQUEST_METHOD_ATTR} / ${RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR} not set.`, ); - await this.publishRpcResponse( + await this.publishRpcResponsePacket( callerIdentity, requestId, null, @@ -286,7 +243,7 @@ export default class RpcServerManager { decompressedPayload = await gzipDecompressFromReader(reader); } catch (e) { this.log.warn(`Error decompressing RPC request payload: ${e}`); - await this.publishRpcResponse( + await this.publishRpcResponsePacket( callerIdentity, requestId, null, @@ -298,7 +255,7 @@ export default class RpcServerManager { const handler = this.rpcHandlers.get(method); if (!handler) { - await this.publishRpcResponse( + await this.publishRpcResponsePacket( callerIdentity, requestId, null, @@ -327,46 +284,10 @@ export default class RpcServerManager { responseError = RpcError.builtIn('APPLICATION_ERROR'); } - await this.publishRpcResponse(callerIdentity, requestId, null, responseError); - return; - } - - // Determine how to send the response based on the caller's client protocol - const callerClientProtocol = this.getRemoteParticipantClientProtocol(callerIdentity); - - const responseBytes = byteLength(response ?? ''); - - if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC && responseBytes > DATA_STREAM_MIN_BYTES) { - // Large response: create the data stream tagged with the request ID, - // send the RPC response with empty payload, then stream compressed chunks - // for lower TTFB - const writer = await this.outgoingDataStreamManager.streamBytes({ - topic: RPC_DATA_STREAM_TOPIC, - destinationIdentities: [callerIdentity], - mimeType: 'application/octet-stream', - attributes: { [RPC_RESPONSE_ID_ATTR]: requestId }, - }); - await gzipCompressToWriter(response!, writer); - await writer.close(); - return; - } - - if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC) { - // Medium response: compress inline - const compressed = await gzipCompress(response); - await this.publishRpcResponseCompressed(callerIdentity, requestId, compressed); - return; - } - - if (responseBytes > MAX_LEGACY_PAYLOAD_BYTES) { - // Legacy client can't handle large payloads - const responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE'); - this.log.warn(`RPC Response payload too large for ${method}`); - await this.publishRpcResponse(callerIdentity, requestId, null, responseError); + await this.publishRpcResponsePacket(callerIdentity, requestId, null, responseError); return; } - // Legacy client: send uncompressed - await this.publishRpcResponse(callerIdentity, requestId, response, null); + await this.publishRpcResponse(callerIdentity, requestId, response ?? ''); } } diff --git a/src/room/rpc/utils.ts b/src/room/rpc/utils.ts index b0d3cbd731..1f8b07e899 100644 --- a/src/room/rpc/utils.ts +++ b/src/room/rpc/utils.ts @@ -147,12 +147,6 @@ export class RpcError extends Error { */ export const MAX_LEGACY_PAYLOAD_BYTES = 15360; // 15 KB -/** - * Payloads above this size are sent via a data stream instead of inline. - * @internal - */ -export const DATA_STREAM_MIN_BYTES = 15360; // 15 KB - /** * Attribute key set on a data stream to associate it with an RPC request. * @internal From 18bac37a1201cb6c2fb27f2e9b95cd943dc5612b Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 31 Mar 2026 15:28:03 -0400 Subject: [PATCH 25/54] feat: migrate RpcClientManager / RpcServerManager to not be tightly coupled to RTCEngine --- src/room/Room.ts | 40 ++- src/room/rpc.test.ts | 323 ------------------ src/room/rpc/client/RpcClientManager.test.ts | 160 +++++++++ src/room/rpc/{ => client}/RpcClientManager.ts | 126 ++----- src/room/rpc/client/events.ts | 13 + src/room/rpc/index.ts | 6 +- src/room/rpc/server/RpcServerManager.test.ts | 176 ++++++++++ src/room/rpc/{ => server}/RpcServerManager.ts | 99 +++--- src/room/rpc/server/events.ts | 13 + 9 files changed, 479 insertions(+), 477 deletions(-) delete mode 100644 src/room/rpc.test.ts create mode 100644 src/room/rpc/client/RpcClientManager.test.ts rename src/room/rpc/{ => client}/RpcClientManager.ts (70%) create mode 100644 src/room/rpc/client/events.ts create mode 100644 src/room/rpc/server/RpcServerManager.test.ts rename src/room/rpc/{ => server}/RpcServerManager.ts (76%) create mode 100644 src/room/rpc/server/events.ts diff --git a/src/room/Room.ts b/src/room/Room.ts index ab982f2b50..4dc70b7b78 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -83,6 +83,7 @@ import { RPC_DATA_STREAM_TOPIC, RPC_RESPONSE_ID_ATTR, RpcClientManager, + RpcError, type RpcInvocationData, RpcServerManager, } from './rpc'; @@ -306,17 +307,22 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.registerRpcDataStreamHandler(); this.rpcClientManager = new RpcClientManager( - this.engine, this.log, this.outgoingDataStreamManager, this.getRemoteParticipantClientProtocol, + () => this.engine.latestJoinResponse?.serverInfo?.version, ); + this.rpcClientManager.on('sendDataPacket', ({ packet, kind }) => { + this.engine.sendDataPacket(packet, kind); + }); this.rpcServerManager = new RpcServerManager( - this.engine, this.log, this.outgoingDataStreamManager, this.getRemoteParticipantClientProtocol, ); + this.rpcServerManager.on('sendDataPacket', ({ packet, kind }) => { + this.engine.sendDataPacket(packet, kind); + }); this.disconnectLock = new Mutex(); @@ -708,12 +714,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) if (this.outgoingDataStreamManager) { this.outgoingDataStreamManager.setupEngine(this.engine); } - if (this.rpcClientManager) { - this.rpcClientManager.setupEngine(this.engine); - } - if (this.rpcServerManager) { - this.rpcServerManager.setupEngine(this.engine); - } } /** @@ -1978,6 +1978,30 @@ class Room extends (EventEmitter as new () => TypedEmitter) rpc.version, () => this.remoteParticipants.has(packet.participantIdentity), ); + } else if (packet.value.case === 'rpcResponse') { + const rpcResponse = packet.value.value; + switch (rpcResponse.value.case) { + case 'payload': + this.rpcClientManager.handleIncomingRpcResponseSuccess( + rpcResponse.requestId, + rpcResponse.value.value, + ); + break; + case 'error': + this.rpcClientManager.handleIncomingRpcResponseFailure( + rpcResponse.requestId, + RpcError.fromProto(rpcResponse.value.value), + ); + break; + default: + this.log.warn( + `Unknown rpcResponse.value.case: ${rpcResponse.value.case}`, + this.logContext, + ); + break; + } + } else if (packet.value.case === 'rpcAck') { + this.rpcClientManager.handleIncomingRpcAck(packet.value.value.requestId); } }; diff --git a/src/room/rpc.test.ts b/src/room/rpc.test.ts deleted file mode 100644 index 6689f4b34e..0000000000 --- a/src/room/rpc.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { DataPacket_Kind } from '@livekit/protocol'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import log from '../logger'; -import { CLIENT_PROTOCOL_DEFAULT } from '../version'; -import type RTCEngine from './RTCEngine'; -import OutgoingDataStreamManager from './data-stream/outgoing/OutgoingDataStreamManager'; -import { RpcClientManager, RpcError, RpcServerManager } from './rpc'; -import { sleep } from './utils'; - -describe('RpcClientManager', () => { - let rpcClientManager: RpcClientManager; - let mockSendDataPacket: ReturnType; - let mockEngine: RTCEngine; - - beforeEach(() => { - mockSendDataPacket = vi.fn(); - mockEngine = { - client: { - sendUpdateLocalMetadata: vi.fn(), - }, - on: vi.fn().mockReturnThis(), - sendDataPacket: mockSendDataPacket, - } as unknown as RTCEngine; - - const outgoingDataStreamManager = new OutgoingDataStreamManager(mockEngine, log); - rpcClientManager = new RpcClientManager( - mockEngine, - log, - outgoingDataStreamManager, - (_identity) => CLIENT_PROTOCOL_DEFAULT, - ); - }); - - it.skip('should send a rpc message to a participant (legacy path)', async () => { - mockSendDataPacket.mockImplementationOnce(() => Promise.resolve()); - - const [requestId, completionPromise] = await rpcClientManager.performRpc({ - destinationIdentity: 'destination-identity', - method: 'test-method', - payload: 'request-payload', - }); - - expect(mockSendDataPacket).toHaveBeenCalledTimes(1); - - // Make sure the request was sent - const packet = mockSendDataPacket.mock.lastCall![0]; - expect(packet.value.case).toStrictEqual('rpcRequest'); - expect(packet.value.value.id).toStrictEqual(requestId); - expect(packet.value.value.method).toStrictEqual('test-method'); - expect(packet.value.value.payload).toStrictEqual('request-payload'); - - rpcClientManager.handleIncomingRpcAck(requestId); - - rpcClientManager.handleIncomingRpcResponseSuccess(requestId, 'response-payload'); - - await expect(completionPromise).resolves.toStrictEqual('response-payload'); - }); - - it('should send RPC request and receive successful response', async () => { - const method = 'testMethod'; - const payload = 'testPayload'; - const responsePayload = 'responsePayload'; - - mockSendDataPacket.mockImplementationOnce(() => Promise.resolve()); - - const [requestId, completionPromise] = await rpcClientManager.performRpc({ - destinationIdentity: 'remote-identity', - method, - payload, - }); - - expect(mockSendDataPacket).toHaveBeenCalledTimes(1); - - setTimeout(() => { - rpcClientManager.handleIncomingRpcAck(requestId); - setTimeout(() => { - rpcClientManager.handleIncomingRpcResponseSuccess(requestId, responsePayload); - }, 10); - }, 10); - - const result = await completionPromise; - expect(result).toStrictEqual(responsePayload); - }); - - it('should handle RPC request timeout', async () => { - vi.useFakeTimers(); - - try { - const method = 'timeoutMethod'; - const payload = 'timeoutPayload'; - const timeout = 50; - - mockSendDataPacket.mockImplementationOnce(() => Promise.resolve()); - - const [requestId, completionPromise] = await rpcClientManager.performRpc({ - destinationIdentity: 'remote-identity', - method, - payload, - responseTimeout: timeout, - }); - - // Register the rejection handler before advancing so the rejection is caught - const rejectPromise = expect(completionPromise).rejects.toThrow('Response timeout'); - - // Response timeout (50ms) fires before ack timeout (7000ms) - await vi.advanceTimersByTimeAsync(timeout); - - await rejectPromise; - } finally { - vi.useRealTimers(); - } - }); - - it('should handle RPC error response', async () => { - const method = 'errorMethod'; - const payload = 'errorPayload'; - const errorCode = 101; - const errorMessage = 'Test error message'; - - mockSendDataPacket.mockImplementationOnce(() => Promise.resolve()); - - const [requestId, completionPromise] = await rpcClientManager.performRpc({ - destinationIdentity: 'remote-identity', - method, - payload, - }); - - rpcClientManager.handleIncomingRpcAck(requestId); - rpcClientManager.handleIncomingRpcResponseFailure( - requestId, - new RpcError(errorCode, errorMessage), - ); - - await expect(completionPromise).rejects.toThrow(errorMessage); - }); - - it('should handle participant disconnection during RPC request', async () => { - const method = 'disconnectMethod'; - const payload = 'disconnectPayload'; - - mockSendDataPacket.mockImplementationOnce(() => Promise.resolve()); - - const [requestId, completionPromise] = await rpcClientManager.performRpc({ - destinationIdentity: 'remote-identity', - method, - payload, - }); - - // Simulate a small delay before disconnection - await sleep(200); - rpcClientManager.handleParticipantDisconnected('remote-identity'); - - await expect(completionPromise).rejects.toThrow('Recipient disconnected'); - }); -}); - -describe('RpcServerManager', () => { - let rpcServerManager: RpcServerManager; - let mockSendDataPacket: ReturnType; - let mockEngine: RTCEngine; - - beforeEach(() => { - mockSendDataPacket = vi.fn(); - mockEngine = { - client: { - sendUpdateLocalMetadata: vi.fn(), - }, - on: vi.fn().mockReturnThis(), - sendDataPacket: mockSendDataPacket, - } as unknown as RTCEngine; - - const outgoingDataStreamManager = new OutgoingDataStreamManager(mockEngine, log); - rpcServerManager = new RpcServerManager( - mockEngine, - log, - outgoingDataStreamManager, - (_identity) => CLIENT_PROTOCOL_DEFAULT, - ); - - mockSendDataPacket.mockImplementation(() => Promise.resolve()); - }); - - it('should receive a rpc message from a participant', async () => { - const handler = vi.fn().mockReturnValueOnce('response payload'); - rpcServerManager.registerRpcMethod('test-method', handler); - - const requestId = crypto.randomUUID(); - const responseTimeoutMs = 10_000; - await rpcServerManager.handleIncomingRpcRequest( - 'caller-identity', - requestId, - 'test-method', - 'request payload', - responseTimeoutMs, - 1, - () => true, - ); - - // Make sure two packets were sent: - expect(mockSendDataPacket).toHaveBeenCalledTimes(2); - - // The first an acknowledgement of the request - const ackPacket = mockSendDataPacket.mock.calls[0][0]; - expect(ackPacket.value.case).toStrictEqual('rpcAck'); - expect(ackPacket.value.value.requestId).toStrictEqual(requestId); - - // And the second being the actual response - const rpcResponsePacket = mockSendDataPacket.mock.calls[1][0]; - expect(rpcResponsePacket.value.case).toStrictEqual('rpcResponse'); - expect(rpcResponsePacket.value.value.requestId).toStrictEqual(requestId); - expect(rpcResponsePacket.value.value.value.case).toStrictEqual('payload'); - expect(rpcResponsePacket.value.value.value.value).toStrictEqual('response payload'); - }); - - it('should register an RPC method handler', async () => { - const methodName = 'testMethod'; - const handler = vi.fn().mockResolvedValue('test response'); - - rpcServerManager.registerRpcMethod(methodName, handler); - - await rpcServerManager.handleIncomingRpcRequest( - 'remote-identity', - 'test-request-id', - methodName, - 'test payload', - 5000, - 1, - () => true, - ); - - expect(handler).toHaveBeenCalledWith({ - requestId: 'test-request-id', - callerIdentity: 'remote-identity', - payload: 'test payload', - responseTimeout: 5000, - }); - - // Ensure sendDataPacket was called twice (once for the ack and once for response) - expect(mockSendDataPacket).toHaveBeenCalledTimes(2); - - // Ensure the first call was for the ack - expect(mockSendDataPacket.mock.calls[0][0].value.case).toStrictEqual('rpcAck'); - expect(mockSendDataPacket.mock.calls[0][1]).toStrictEqual(DataPacket_Kind.RELIABLE); - - // and the second call was for the response - expect(mockSendDataPacket.mock.calls[1][0].value.case).toStrictEqual('rpcResponse'); - expect(mockSendDataPacket.mock.calls[1][1]).toStrictEqual(DataPacket_Kind.RELIABLE); - }); - - it('should catch and transform unhandled errors in the RPC method handler', async () => { - const methodName = 'errorMethod'; - const errorMessage = 'Test error'; - const handler = vi.fn().mockRejectedValue(new Error(errorMessage)); - - rpcServerManager.registerRpcMethod(methodName, handler); - - await rpcServerManager.handleIncomingRpcRequest( - 'remote-identity', - 'test-error-request-id', - methodName, - 'test payload', - 5000, - 1, - () => true, - ); - - expect(handler).toHaveBeenCalledWith({ - requestId: 'test-error-request-id', - callerIdentity: 'remote-identity', - payload: 'test payload', - responseTimeout: 5000, - }); - - // Ensure sendDataPacket was called twice (once for ack and once for error response) - expect(mockSendDataPacket).toHaveBeenCalledTimes(2); - - // Ensure the first call was for the ack - expect(mockSendDataPacket.mock.calls[0][0].value.case).toStrictEqual('rpcAck'); - expect(mockSendDataPacket.mock.calls[0][1]).toStrictEqual(DataPacket_Kind.RELIABLE); - - // And the second call was for the error response - const errorResponse = mockSendDataPacket.mock.calls[1][0].value.value.value.value; - expect(errorResponse.code).toStrictEqual(RpcError.ErrorCode.APPLICATION_ERROR); - }); - - it('should pass through RpcError thrown by the RPC method handler', async () => { - const methodName = 'rpcErrorMethod'; - const errorCode = 101; - const errorMessage = 'some-error-message'; - const handler = vi.fn().mockRejectedValue(new RpcError(errorCode, errorMessage)); - - rpcServerManager.registerRpcMethod(methodName, handler); - - await rpcServerManager.handleIncomingRpcRequest( - 'remote-identity', - 'test-rpc-error-request-id', - methodName, - 'test payload', - 5000, - 1, - () => true, - ); - - expect(handler).toHaveBeenCalledWith({ - requestId: 'test-rpc-error-request-id', - callerIdentity: 'remote-identity', - payload: 'test payload', - responseTimeout: 5000, - }); - - // Ensure sendDataPacket was called twice (once for ACK and once for error response) - expect(mockSendDataPacket).toHaveBeenCalledTimes(2); - - // Ensure the first call was for the ack - expect(mockSendDataPacket.mock.calls[0][0].value.case).toStrictEqual('rpcAck'); - expect(mockSendDataPacket.mock.calls[0][1]).toStrictEqual(DataPacket_Kind.RELIABLE); - - // And the second call was for the error response - const errorResponse = mockSendDataPacket.mock.calls[1][0].value.value.value.value; - expect(errorResponse.code).toStrictEqual(errorCode); - expect(errorResponse.message).toStrictEqual(errorMessage); - }); -}); diff --git a/src/room/rpc/client/RpcClientManager.test.ts b/src/room/rpc/client/RpcClientManager.test.ts new file mode 100644 index 0000000000..e65c311888 --- /dev/null +++ b/src/room/rpc/client/RpcClientManager.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import log from '../../../logger'; +import { subscribeToEvents } from '../../../utils/subscribeToEvents'; +import { CLIENT_PROTOCOL_DEFAULT } from '../../../version'; +import type RTCEngine from '../../RTCEngine'; +import OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; +import { sleep } from '../../utils'; +import { RpcError } from '../utils'; +import type { RpcClientManagerCallbacks } from './events'; +import RpcClientManager from './RpcClientManager'; + +describe('RpcClientManager', () => { + let rpcClientManager: RpcClientManager; + + beforeEach(() => { + const outgoingDataStreamManager = new OutgoingDataStreamManager( + {} as unknown as RTCEngine, + log, + ); + + rpcClientManager = new RpcClientManager( + log, + outgoingDataStreamManager, + (_identity) => CLIENT_PROTOCOL_DEFAULT, + () => undefined, + ); + }); + + it.skip('should send a rpc message to a participant (legacy path)', async () => { + const managerEvents = subscribeToEvents(rpcClientManager, [ + 'sendDataPacket', + ]); + + const [requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'destination-identity', + method: 'test-method', + payload: 'request-payload', + }); + + const { packet } = await managerEvents.waitFor('sendDataPacket'); + expect(packet.value.case).toStrictEqual('rpcRequest'); + expect(packet.value.value.id).toStrictEqual(requestId); + expect(packet.value.value.method).toStrictEqual('test-method'); + expect(packet.value.value.payload).toStrictEqual('request-payload'); + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + + rpcClientManager.handleIncomingRpcAck(requestId); + + rpcClientManager.handleIncomingRpcResponseSuccess(requestId, 'response-payload'); + + await expect(completionPromise).resolves.toStrictEqual('response-payload'); + }); + + it('should send RPC request and receive successful response', async () => { + const managerEvents = subscribeToEvents(rpcClientManager, [ + 'sendDataPacket', + ]); + + const method = 'testMethod'; + const payload = 'testPayload'; + const responsePayload = 'responsePayload'; + + const [requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remote-identity', + method, + payload, + }); + + // Verify exactly one packet was emitted + await managerEvents.waitFor('sendDataPacket'); + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + + setTimeout(() => { + rpcClientManager.handleIncomingRpcAck(requestId); + setTimeout(() => { + rpcClientManager.handleIncomingRpcResponseSuccess(requestId, responsePayload); + }, 10); + }, 10); + + const result = await completionPromise; + expect(result).toStrictEqual(responsePayload); + }); + + it('should handle RPC request timeout', async () => { + const managerEvents = subscribeToEvents(rpcClientManager, [ + 'sendDataPacket', + ]); + + vi.useFakeTimers(); + + try { + const method = 'timeoutMethod'; + const payload = 'timeoutPayload'; + const timeout = 50; + + const [requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remote-identity', + method, + payload, + responseTimeout: timeout, + }); + + // Register the rejection handler before advancing so the rejection is caught + const rejectPromise = expect(completionPromise).rejects.toThrow('Response timeout'); + + // Response timeout (50ms) fires before ack timeout (7000ms) + await vi.advanceTimersByTimeAsync(timeout); + + await rejectPromise; + } finally { + vi.useRealTimers(); + } + }); + + it('should handle RPC error response', async () => { + const managerEvents = subscribeToEvents(rpcClientManager, [ + 'sendDataPacket', + ]); + + const method = 'errorMethod'; + const payload = 'errorPayload'; + const errorCode = 101; + const errorMessage = 'Test error message'; + + const [requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remote-identity', + method, + payload, + }); + + rpcClientManager.handleIncomingRpcAck(requestId); + rpcClientManager.handleIncomingRpcResponseFailure( + requestId, + new RpcError(errorCode, errorMessage), + ); + + await expect(completionPromise).rejects.toThrow(errorMessage); + }); + + it('should handle participant disconnection during RPC request', async () => { + const managerEvents = subscribeToEvents(rpcClientManager, [ + 'sendDataPacket', + ]); + + const method = 'disconnectMethod'; + const payload = 'disconnectPayload'; + + const [requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remote-identity', + method, + payload, + }); + + // Simulate a small delay before disconnection + await sleep(200); + rpcClientManager.handleParticipantDisconnected('remote-identity'); + + await expect(completionPromise).rejects.toThrow('Recipient disconnected'); + }); +}); diff --git a/src/room/rpc/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts similarity index 70% rename from src/room/rpc/RpcClientManager.ts rename to src/room/rpc/client/RpcClientManager.ts index 20835c3baa..4fd72c034f 100644 --- a/src/room/rpc/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -2,14 +2,14 @@ // // SPDX-License-Identifier: Apache-2.0 import { DataPacket, DataPacket_Kind, RpcRequest } from '@livekit/protocol'; -import { type StructuredLogger } from '../../logger'; -import { CLIENT_PROTOCOL_GZIP_RPC } from '../../version'; -import type RTCEngine from '../RTCEngine'; -import type { ByteStreamReader } from '../data-stream/incoming/StreamReader'; -import type OutgoingDataStreamManager from '../data-stream/outgoing/OutgoingDataStreamManager'; -import { EngineEvent } from '../events'; -import type Participant from '../participant/Participant'; -import { Future, compareVersions } from '../utils'; +import EventEmitter from 'events'; +import type TypedEmitter from 'typed-emitter'; +import { type StructuredLogger } from '../../../logger'; +import { CLIENT_PROTOCOL_GZIP_RPC } from '../../../version'; +import type { ByteStreamReader } from '../../data-stream/incoming/StreamReader'; +import type OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; +import type Participant from '../../participant/Participant'; +import { Future, compareVersions } from '../../utils'; import { MAX_LEGACY_PAYLOAD_BYTES, type PerformRpcParams, @@ -21,22 +21,23 @@ import { byteLength, gzipCompressToWriter, gzipDecompressFromReader, -} from './utils'; +} from '../utils'; +import type { RpcClientManagerCallbacks } from './events'; /** * Manages the client (caller) side of RPC: sending requests, tracking pending * ack/response state, and handling incoming ack/response packets. * @internal */ -export default class RpcClientManager { - private engine: RTCEngine; - +export default class RpcClientManager extends (EventEmitter as new () => TypedEmitter) { private log: StructuredLogger; private outgoingDataStreamManager: OutgoingDataStreamManager; private getRemoteParticipantClientProtocol: (identity: Participant['identity']) => number; + private getServerVersion: () => string | undefined; + private pendingAcks = new Map void; participantIdentity: string }>(); private pendingResponses = new Map< @@ -48,21 +49,16 @@ export default class RpcClientManager { >(); constructor( - engine: RTCEngine, log: StructuredLogger, outgoingDataStreamManager: OutgoingDataStreamManager, getRemoteParticipantClientProtocol: (identity: Participant['identity']) => number, + getServerVersion: () => string | undefined, ) { - this.engine = engine; + super(); this.log = log; this.outgoingDataStreamManager = outgoingDataStreamManager; this.getRemoteParticipantClientProtocol = getRemoteParticipantClientProtocol; - } - - setupEngine(engine: RTCEngine) { - this.engine = engine; - - this.engine.on(EngineEvent.DataPacketReceived, this.handleDataPacket); + this.getServerVersion = getServerVersion; } async performRpc({ @@ -82,10 +78,8 @@ export default class RpcClientManager { throw RpcError.builtIn('REQUEST_PAYLOAD_TOO_LARGE'); } - if ( - this.engine.latestJoinResponse?.serverInfo?.version && - compareVersions(this.engine.latestJoinResponse?.serverInfo?.version, '1.8.0') < 0 - ) { + const serverVersion = this.getServerVersion(); + if (serverVersion && compareVersions(serverVersion, '1.8.0') < 0) { throw RpcError.builtIn('UNSUPPORTED_SERVER'); } @@ -166,79 +160,25 @@ export default class RpcClientManager { } // Legacy client: send uncompressed payload inline - await this.sendRpcRequestPacket( - destinationIdentity, - requestId, - method, - payload, - responseTimeout, - ); - } - - private async sendRpcRequestPacket( - destinationIdentity: string, - requestId: string, - method: string, - payload: string, - responseTimeout: number, - ) { - const packet = new DataPacket({ - destinationIdentities: [destinationIdentity], + this.emit('sendDataPacket', { + packet: new DataPacket({ + destinationIdentities: [destinationIdentity], + kind: DataPacket_Kind.RELIABLE, + value: { + case: 'rpcRequest', + value: new RpcRequest({ + id: requestId, + method, + payload, + responseTimeoutMs: responseTimeout, + version: 1, + }), + }, + }), kind: DataPacket_Kind.RELIABLE, - value: { - case: 'rpcRequest', - value: new RpcRequest({ - id: requestId, - method, - payload, - responseTimeoutMs: responseTimeout, - version: 1, - }), - }, }); - - await this.engine.sendDataPacket(packet, DataPacket_Kind.RELIABLE); } - /** - * Handle an incoming data packet that may contain an RPC ack or response. - * Returns true if the packet was handled. - */ - private handleDataPacket = async (packet: DataPacket): Promise => { - switch (packet.value.case) { - case 'rpcResponse': { - const rpcResponse = packet.value.value; - - switch (rpcResponse.value.case) { - case 'payload': { - this.handleIncomingRpcResponseSuccess(rpcResponse.requestId, rpcResponse.value.value); - return true; - } - - case 'error': { - const error = RpcError.fromProto(rpcResponse.value.value); - this.handleIncomingRpcResponseFailure(rpcResponse.requestId, error); - return true; - } - - default: { - this.log.warn( - `Error handling RPC response data packet: unknown rpcResponse.value.case found (${rpcResponse.value.case})`, - ); - return false; - } - } - } - case 'rpcAck': { - const rpcAck = packet.value.value; - this.handleIncomingRpcAck(rpcAck.requestId); - return true; - } - default: - return false; - } - }; - /** * Handle an incoming byte stream containing an RPC response payload. * Decompresses the stream and resolves/rejects the pending data stream future. diff --git a/src/room/rpc/client/events.ts b/src/room/rpc/client/events.ts new file mode 100644 index 0000000000..8db45fc674 --- /dev/null +++ b/src/room/rpc/client/events.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +import type { DataPacket, DataPacket_Kind } from '@livekit/protocol'; + +export type EventSendDataPacket = { + packet: DataPacket; + kind: DataPacket_Kind; +}; + +export type RpcClientManagerCallbacks = { + sendDataPacket: (event: EventSendDataPacket) => void; +}; diff --git a/src/room/rpc/index.ts b/src/room/rpc/index.ts index 338c46dd51..d0ce278e9d 100644 --- a/src/room/rpc/index.ts +++ b/src/room/rpc/index.ts @@ -1,8 +1,10 @@ // SPDX-FileCopyrightText: 2026 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 -export { default as RpcClientManager } from './RpcClientManager'; -export { default as RpcServerManager } from './RpcServerManager'; +export { default as RpcClientManager } from './client/RpcClientManager'; +export type { RpcClientManagerCallbacks } from './client/events'; +export { default as RpcServerManager } from './server/RpcServerManager'; +export type { RpcServerManagerCallbacks } from './server/events'; export { type PerformRpcParams, RPC_DATA_STREAM_TOPIC, diff --git a/src/room/rpc/server/RpcServerManager.test.ts b/src/room/rpc/server/RpcServerManager.test.ts new file mode 100644 index 0000000000..59bc9cffc5 --- /dev/null +++ b/src/room/rpc/server/RpcServerManager.test.ts @@ -0,0 +1,176 @@ +import { DataPacket_Kind } from '@livekit/protocol'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import log from '../../../logger'; +import { subscribeToEvents } from '../../../utils/subscribeToEvents'; +import { CLIENT_PROTOCOL_DEFAULT } from '../../../version'; +import type RTCEngine from '../../RTCEngine'; +import OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; +import { RpcError } from '../utils'; +import type { RpcServerManagerCallbacks } from './events'; +import RpcServerManager from './RpcServerManager'; + +describe('RpcServerManager', () => { + let rpcServerManager: RpcServerManager; + + beforeEach(() => { + const outgoingDataStreamManager = new OutgoingDataStreamManager( + {} as unknown as RTCEngine, + log, + ); + + rpcServerManager = new RpcServerManager( + log, + outgoingDataStreamManager, + (_identity) => CLIENT_PROTOCOL_DEFAULT, + ); + }); + + it('should receive a rpc message from a participant', async () => { + const managerEvents = subscribeToEvents(rpcServerManager, [ + 'sendDataPacket', + ]); + + const handler = async () => 'response payload'; + rpcServerManager.registerRpcMethod('test-method', handler); + + const requestId = crypto.randomUUID(); + const responseTimeoutMs = 10_000; + await rpcServerManager.handleIncomingRpcRequest( + 'caller-identity', + requestId, + 'test-method', + 'request payload', + responseTimeoutMs, + 1, + () => true, + ); + + // The first event is an acknowledgement of the request + const ackEvent = await managerEvents.waitFor('sendDataPacket'); + expect(ackEvent.packet.value.case).toStrictEqual('rpcAck'); + expect(ackEvent.packet.value.value.requestId).toStrictEqual(requestId); + + // And the second being the actual response + const responseEvent = await managerEvents.waitFor('sendDataPacket'); + expect(responseEvent.packet.value.case).toStrictEqual('rpcResponse'); + expect(responseEvent.packet.value.value.requestId).toStrictEqual(requestId); + expect(responseEvent.packet.value.value.value.case).toStrictEqual('payload'); + expect(responseEvent.packet.value.value.value.value).toStrictEqual('response payload'); + + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + }); + + it('should register an RPC method handler', async () => { + const managerEvents = subscribeToEvents(rpcServerManager, [ + 'sendDataPacket', + ]); + + const methodName = 'testMethod'; + const handler = vi.fn().mockResolvedValue('test response'); + + rpcServerManager.registerRpcMethod(methodName, handler); + + await rpcServerManager.handleIncomingRpcRequest( + 'remote-identity', + 'test-request-id', + methodName, + 'test payload', + 5000, + 1, + () => true, + ); + + expect(handler).toHaveBeenCalledWith({ + requestId: 'test-request-id', + callerIdentity: 'remote-identity', + payload: 'test payload', + responseTimeout: 5000, + }); + + // Ensure the first event was for the ack + const ackEvent = await managerEvents.waitFor('sendDataPacket'); + expect(ackEvent.packet.value.case).toStrictEqual('rpcAck'); + expect(ackEvent.kind).toStrictEqual(DataPacket_Kind.RELIABLE); + + // And the second event was for the response + const responseEvent = await managerEvents.waitFor('sendDataPacket'); + expect(responseEvent.packet.value.case).toStrictEqual('rpcResponse'); + expect(responseEvent.kind).toStrictEqual(DataPacket_Kind.RELIABLE); + + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + }); + + it('should catch and transform unhandled errors in the RPC method handler', async () => { + const managerEvents = subscribeToEvents(rpcServerManager, [ + 'sendDataPacket', + ]); + + const methodName = 'errorMethod'; + const errorMessage = 'Test error'; + const handler = async () => { + throw new Error(errorMessage); + }; + + rpcServerManager.registerRpcMethod(methodName, handler); + + await rpcServerManager.handleIncomingRpcRequest( + 'remote-identity', + 'test-error-request-id', + methodName, + 'test payload', + 5000, + 1, + () => true, + ); + + // Ensure the first event was for the ack + const ackEvent = await managerEvents.waitFor('sendDataPacket'); + expect(ackEvent.packet.value.case).toStrictEqual('rpcAck'); + expect(ackEvent.kind).toStrictEqual(DataPacket_Kind.RELIABLE); + + // And the second event was for the error response + const errorEvent = await managerEvents.waitFor('sendDataPacket'); + const errorResponse = errorEvent.packet.value.value.value.value; + expect(errorResponse.code).toStrictEqual(RpcError.ErrorCode.APPLICATION_ERROR); + + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + }); + + it('should pass through RpcError thrown by the RPC method handler', async () => { + const managerEvents = subscribeToEvents(rpcServerManager, [ + 'sendDataPacket', + ]); + + const methodName = 'rpcErrorMethod'; + const errorCode = 101; + const errorMessage = 'some-error-message'; + const handler = async () => { + throw new RpcError(errorCode, errorMessage); + }; + + rpcServerManager.registerRpcMethod(methodName, handler); + + await rpcServerManager.handleIncomingRpcRequest( + 'remote-identity', + 'test-rpc-error-request-id', + methodName, + 'test payload', + 5000, + 1, + () => true, + ); + + // Ensure the first event was for the ack + const ackEvent = await managerEvents.waitFor('sendDataPacket'); + expect(ackEvent.packet.value.case).toStrictEqual('rpcAck'); + expect(ackEvent.kind).toStrictEqual(DataPacket_Kind.RELIABLE); + + // And the second event was for the error response + const errorEvent = await managerEvents.waitFor('sendDataPacket'); + const errorResponse = errorEvent.packet.value.value.value.value; + expect(errorResponse.code).toStrictEqual(errorCode); + expect(errorResponse.message).toStrictEqual(errorMessage); + + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + }); +}); diff --git a/src/room/rpc/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts similarity index 76% rename from src/room/rpc/RpcServerManager.ts rename to src/room/rpc/server/RpcServerManager.ts index 96ac03dd00..41dc0abb22 100644 --- a/src/room/rpc/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -2,12 +2,13 @@ // // SPDX-License-Identifier: Apache-2.0 import { DataPacket, DataPacket_Kind, RpcAck, RpcResponse } from '@livekit/protocol'; -import { type StructuredLogger } from '../../logger'; -import { CLIENT_PROTOCOL_GZIP_RPC } from '../../version'; -import type RTCEngine from '../RTCEngine'; -import type { ByteStreamReader } from '../data-stream/incoming/StreamReader'; -import type OutgoingDataStreamManager from '../data-stream/outgoing/OutgoingDataStreamManager'; -import type Participant from '../participant/Participant'; +import EventEmitter from 'events'; +import type TypedEmitter from 'typed-emitter'; +import { type StructuredLogger } from '../../../logger'; +import { CLIENT_PROTOCOL_GZIP_RPC } from '../../../version'; +import type { ByteStreamReader } from '../../data-stream/incoming/StreamReader'; +import type OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; +import type Participant from '../../participant/Participant'; import { MAX_LEGACY_PAYLOAD_BYTES, RPC_DATA_STREAM_TOPIC, @@ -20,16 +21,15 @@ import { byteLength, gzipCompressToWriter, gzipDecompressFromReader, -} from './utils'; +} from '../utils'; +import type { RpcServerManagerCallbacks } from './events'; /** * Manages the server (handler) side of RPC: processing incoming requests, * managing registered method handlers, and sending responses. * @internal */ -export default class RpcServerManager { - private engine: RTCEngine; - +export default class RpcServerManager extends (EventEmitter as new () => TypedEmitter) { private log: StructuredLogger; private outgoingDataStreamManager: OutgoingDataStreamManager; @@ -39,21 +39,16 @@ export default class RpcServerManager { private rpcHandlers: Map Promise> = new Map(); constructor( - engine: RTCEngine, log: StructuredLogger, outgoingDataStreamManager: OutgoingDataStreamManager, getRemoteParticipantClientProtocol: (identity: Participant['identity']) => number, ) { - this.engine = engine; + super(); this.log = log; this.outgoingDataStreamManager = outgoingDataStreamManager; this.getRemoteParticipantClientProtocol = getRemoteParticipantClientProtocol; } - setupEngine(engine: RTCEngine) { - this.engine = engine; - } - registerRpcMethod(method: string, handler: (data: RpcInvocationData) => Promise) { if (this.rpcHandlers.has(method)) { throw Error( @@ -76,11 +71,11 @@ export default class RpcServerManager { version: number, isCallerStillConnected: () => boolean, ) { - await this.publishRpcAck(callerIdentity, requestId); + this.publishRpcAck(callerIdentity, requestId); if (version !== 1) { if (isCallerStillConnected()) { - await this.publishRpcResponsePacket( + this.publishRpcResponsePacket( callerIdentity, requestId, null, @@ -94,7 +89,7 @@ export default class RpcServerManager { if (!handler) { if (isCallerStillConnected()) { - await this.publishRpcResponsePacket( + this.publishRpcResponsePacket( callerIdentity, requestId, null, @@ -125,7 +120,7 @@ export default class RpcServerManager { } if (isCallerStillConnected()) { - await this.publishRpcResponsePacket(callerIdentity, requestId, null, responseError); + this.publishRpcResponsePacket(callerIdentity, requestId, null, responseError); } return; } @@ -135,42 +130,44 @@ export default class RpcServerManager { } } - private async publishRpcAck(destinationIdentity: string, requestId: string) { - const packet = new DataPacket({ - destinationIdentities: [destinationIdentity], + private publishRpcAck(destinationIdentity: string, requestId: string) { + this.emit('sendDataPacket', { + packet: new DataPacket({ + destinationIdentities: [destinationIdentity], + kind: DataPacket_Kind.RELIABLE, + value: { + case: 'rpcAck', + value: new RpcAck({ + requestId, + }), + }, + }), kind: DataPacket_Kind.RELIABLE, - value: { - case: 'rpcAck', - value: new RpcAck({ - requestId, - }), - }, }); - await this.engine.sendDataPacket(packet, DataPacket_Kind.RELIABLE); } - /** @internal */ - private async publishRpcResponsePacket( + private publishRpcResponsePacket( destinationIdentity: string, requestId: string, payload: string | null, error: RpcError | null, ) { - const packet = new DataPacket({ - destinationIdentities: [destinationIdentity], + this.emit('sendDataPacket', { + packet: new DataPacket({ + destinationIdentities: [destinationIdentity], + kind: DataPacket_Kind.RELIABLE, + value: { + case: 'rpcResponse', + value: new RpcResponse({ + requestId, + value: error + ? { case: 'error', value: error.toProto() } + : { case: 'payload', value: payload ?? '' }, + }), + }, + }), kind: DataPacket_Kind.RELIABLE, - value: { - case: 'rpcResponse', - value: new RpcResponse({ - requestId, - value: error - ? { case: 'error', value: error.toProto() } - : { case: 'payload', value: payload ?? '' }, - }), - }, }); - - await this.engine.sendDataPacket(packet, DataPacket_Kind.RELIABLE); } /** @@ -201,7 +198,7 @@ export default class RpcServerManager { const responseBytes = byteLength(payload); if (responseBytes > MAX_LEGACY_PAYLOAD_BYTES) { this.log.warn(`RPC Response payload too large for request ${requestId}`); - await this.publishRpcResponsePacket( + this.publishRpcResponsePacket( destinationIdentity, requestId, null, @@ -210,7 +207,7 @@ export default class RpcServerManager { return; } - await this.publishRpcResponsePacket(destinationIdentity, requestId, payload, null); + this.publishRpcResponsePacket(destinationIdentity, requestId, payload, null); } /** @@ -230,7 +227,7 @@ export default class RpcServerManager { this.log.warn( `RPC data stream malformed: ${RPC_REQUEST_ID_ATTR} / ${RPC_REQUEST_METHOD_ATTR} / ${RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR} not set.`, ); - await this.publishRpcResponsePacket( + this.publishRpcResponsePacket( callerIdentity, requestId, null, @@ -243,7 +240,7 @@ export default class RpcServerManager { decompressedPayload = await gzipDecompressFromReader(reader); } catch (e) { this.log.warn(`Error decompressing RPC request payload: ${e}`); - await this.publishRpcResponsePacket( + this.publishRpcResponsePacket( callerIdentity, requestId, null, @@ -255,7 +252,7 @@ export default class RpcServerManager { const handler = this.rpcHandlers.get(method); if (!handler) { - await this.publishRpcResponsePacket( + this.publishRpcResponsePacket( callerIdentity, requestId, null, @@ -284,7 +281,7 @@ export default class RpcServerManager { responseError = RpcError.builtIn('APPLICATION_ERROR'); } - await this.publishRpcResponsePacket(callerIdentity, requestId, null, responseError); + this.publishRpcResponsePacket(callerIdentity, requestId, null, responseError); return; } diff --git a/src/room/rpc/server/events.ts b/src/room/rpc/server/events.ts new file mode 100644 index 0000000000..87e4939e16 --- /dev/null +++ b/src/room/rpc/server/events.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +import type { DataPacket, DataPacket_Kind } from '@livekit/protocol'; + +export type EventSendDataPacket = { + packet: DataPacket; + kind: DataPacket_Kind; +}; + +export type RpcServerManagerCallbacks = { + sendDataPacket: (event: EventSendDataPacket) => void; +}; From bc788e55d4c5d976ce4a874d781b83e0778e35b5 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Apr 2026 15:43:12 -0400 Subject: [PATCH 26/54] fix: address fallout from bad rebase --- src/room/RTCEngine.ts | 46 -------------------- src/room/Room.ts | 4 -- src/room/participant/LocalParticipant.ts | 4 -- src/room/participant/RemoteParticipant.ts | 2 +- src/room/rpc/client/RpcClientManager.test.ts | 2 +- src/room/rpc/server/RpcServerManager.test.ts | 2 +- 6 files changed, 3 insertions(+), 57 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 5a655202ab..c6943c9e00 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -25,7 +25,6 @@ import { Room as RoomModel, RoomMovedResponse, RpcAck, - RpcResponse, ServerInfo, SessionDescription, SignalTarget, @@ -1390,51 +1389,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit }); }; - /** @internal */ - async publishRpcResponse( - destinationIdentity: string, - requestId: string, - payload: string | null, - error: RpcError | null, - ) { - const packet = new DataPacket({ - destinationIdentities: [destinationIdentity], - kind: DataPacket_Kind.RELIABLE, - value: { - case: 'rpcResponse', - value: new RpcResponse({ - requestId, - value: error - ? { case: 'error', value: error.toProto() } - : { case: 'payload', value: payload ?? '' }, - }), - }, - }); - - await this.sendDataPacket(packet, DataChannelKind.RELIABLE); - } - - /** @internal */ - async publishRpcResponseCompressed( - destinationIdentity: string, - requestId: string, - compressedPayload: Uint8Array, - ) { - const packet = new DataPacket({ - destinationIdentities: [destinationIdentity], - kind: DataPacket_Kind.RELIABLE, - value: { - case: 'rpcResponse', - value: new RpcResponse({ - requestId, - value: { case: 'compressedPayload', value: compressedPayload }, - }), - }, - }); - - await this.sendDataPacket(packet, DataChannelKind.RELIABLE); - } - /** @internal */ async publishRpcAck(destinationIdentity: string, requestId: string) { const packet = new DataPacket({ diff --git a/src/room/Room.ts b/src/room/Room.ts index 4dc70b7b78..4a77031750 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -222,8 +222,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) private outgoingDataTrackManager: OutgoingDataTrackManager; - private rpcHandlers: Map Promise> = new Map(); - private rpcClientManager: RpcClientManager; private rpcServerManager: RpcServerManager; @@ -333,8 +331,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.options, this.outgoingDataStreamManager, this.outgoingDataTrackManager, - this.getRemoteParticipantClientProtocol.bind(this), - this.waitForRpcDataStream, this.rpcClientManager, this.rpcServerManager, ); diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index ba7ce2b8bf..d13876c06e 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -174,8 +174,6 @@ export default class LocalParticipant extends Participant { options: InternalRoomOptions, roomOutgoingDataStreamManager: OutgoingDataStreamManager, roomOutgoingDataTrackManager: OutgoingDataTrackManager, - getRemoteParticipantClientProtocol: (identity: Participant["identity"]) => number, - waitForRpcDataStream: (streamId: string) => Promise, rpcClientManager: RpcClientManager, rpcServerManager: RpcServerManager, ) { @@ -197,8 +195,6 @@ export default class LocalParticipant extends Participant { this.pendingSignalRequests = new Map(); this.roomOutgoingDataStreamManager = roomOutgoingDataStreamManager; this.roomOutgoingDataTrackManager = roomOutgoingDataTrackManager; - this.getRemoteParticipantClientProtocol = getRemoteParticipantClientProtocol; - this.waitForRpcDataStream = waitForRpcDataStream; this.rpcClientManager = rpcClientManager; this.rpcServerManager = rpcServerManager; } diff --git a/src/room/participant/RemoteParticipant.ts b/src/room/participant/RemoteParticipant.ts index f58eb7844f..012697af81 100644 --- a/src/room/participant/RemoteParticipant.ts +++ b/src/room/participant/RemoteParticipant.ts @@ -6,8 +6,8 @@ import type { } from '@livekit/protocol'; import type { SignalClient } from '../../api/SignalClient'; import { DeferrableMap } from '../../utils/deferrable-map'; -import type RemoteDataTrack from '../data-track/RemoteDataTrack'; import { CLIENT_PROTOCOL_DEFAULT } from '../../version'; +import type RemoteDataTrack from '../data-track/RemoteDataTrack'; import { ParticipantEvent, TrackEvent } from '../events'; import RemoteAudioTrack from '../track/RemoteAudioTrack'; import type RemoteTrack from '../track/RemoteTrack'; diff --git a/src/room/rpc/client/RpcClientManager.test.ts b/src/room/rpc/client/RpcClientManager.test.ts index e65c311888..012e352a80 100644 --- a/src/room/rpc/client/RpcClientManager.test.ts +++ b/src/room/rpc/client/RpcClientManager.test.ts @@ -6,8 +6,8 @@ import type RTCEngine from '../../RTCEngine'; import OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; import { sleep } from '../../utils'; import { RpcError } from '../utils'; -import type { RpcClientManagerCallbacks } from './events'; import RpcClientManager from './RpcClientManager'; +import type { RpcClientManagerCallbacks } from './events'; describe('RpcClientManager', () => { let rpcClientManager: RpcClientManager; diff --git a/src/room/rpc/server/RpcServerManager.test.ts b/src/room/rpc/server/RpcServerManager.test.ts index 59bc9cffc5..dff08fc6a3 100644 --- a/src/room/rpc/server/RpcServerManager.test.ts +++ b/src/room/rpc/server/RpcServerManager.test.ts @@ -6,8 +6,8 @@ import { CLIENT_PROTOCOL_DEFAULT } from '../../../version'; import type RTCEngine from '../../RTCEngine'; import OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; import { RpcError } from '../utils'; -import type { RpcServerManagerCallbacks } from './events'; import RpcServerManager from './RpcServerManager'; +import type { RpcServerManagerCallbacks } from './events'; describe('RpcServerManager', () => { let rpcServerManager: RpcServerManager; From 8a75a8a539882a246a26b15b13b12308cda99534 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Apr 2026 15:43:25 -0400 Subject: [PATCH 27/54] fix: remove DataPacket_Kind from RpcClientManager / RpcServerManager --- src/room/Room.ts | 8 ++++---- src/room/rpc/client/RpcClientManager.ts | 2 +- src/room/rpc/client/events.ts | 3 +-- src/room/rpc/server/RpcServerManager.ts | 2 -- src/room/rpc/server/events.ts | 3 +-- 5 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index 4a77031750..b5fe440353 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -310,16 +310,16 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.getRemoteParticipantClientProtocol, () => this.engine.latestJoinResponse?.serverInfo?.version, ); - this.rpcClientManager.on('sendDataPacket', ({ packet, kind }) => { - this.engine.sendDataPacket(packet, kind); + this.rpcClientManager.on('sendDataPacket', ({ packet }) => { + this.engine.sendDataPacket(packet, DataChannelKind.RELIABLE); }); this.rpcServerManager = new RpcServerManager( this.log, this.outgoingDataStreamManager, this.getRemoteParticipantClientProtocol, ); - this.rpcServerManager.on('sendDataPacket', ({ packet, kind }) => { - this.engine.sendDataPacket(packet, kind); + this.rpcServerManager.on('sendDataPacket', ({ packet }) => { + this.engine.sendDataPacket(packet, DataChannelKind.RELIABLE); }); this.disconnectLock = new Mutex(); diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index 4fd72c034f..a87ed7ad83 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -6,6 +6,7 @@ import EventEmitter from 'events'; import type TypedEmitter from 'typed-emitter'; import { type StructuredLogger } from '../../../logger'; import { CLIENT_PROTOCOL_GZIP_RPC } from '../../../version'; +import { DataChannelKind } from '../../RTCEngine'; import type { ByteStreamReader } from '../../data-stream/incoming/StreamReader'; import type OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; import type Participant from '../../participant/Participant'; @@ -175,7 +176,6 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm }), }, }), - kind: DataPacket_Kind.RELIABLE, }); } diff --git a/src/room/rpc/client/events.ts b/src/room/rpc/client/events.ts index 8db45fc674..3f2a60fd4d 100644 --- a/src/room/rpc/client/events.ts +++ b/src/room/rpc/client/events.ts @@ -1,11 +1,10 @@ // SPDX-FileCopyrightText: 2026 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 -import type { DataPacket, DataPacket_Kind } from '@livekit/protocol'; +import type { DataPacket } from '@livekit/protocol'; export type EventSendDataPacket = { packet: DataPacket; - kind: DataPacket_Kind; }; export type RpcClientManagerCallbacks = { diff --git a/src/room/rpc/server/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts index 41dc0abb22..a3588ec6d1 100644 --- a/src/room/rpc/server/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -142,7 +142,6 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm }), }, }), - kind: DataPacket_Kind.RELIABLE, }); } @@ -166,7 +165,6 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm }), }, }), - kind: DataPacket_Kind.RELIABLE, }); } diff --git a/src/room/rpc/server/events.ts b/src/room/rpc/server/events.ts index 87e4939e16..0e685c8b03 100644 --- a/src/room/rpc/server/events.ts +++ b/src/room/rpc/server/events.ts @@ -1,11 +1,10 @@ // SPDX-FileCopyrightText: 2026 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 -import type { DataPacket, DataPacket_Kind } from '@livekit/protocol'; +import type { DataPacket } from '@livekit/protocol'; export type EventSendDataPacket = { packet: DataPacket; - kind: DataPacket_Kind; }; export type RpcServerManagerCallbacks = { From d97becbeae3d4ec0d4a8816fbaf29da348436930 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Apr 2026 16:21:53 -0400 Subject: [PATCH 28/54] fix: adjust rpc client / server manager tests to fix type errors --- src/room/rpc/client/RpcClientManager.ts | 1 - src/room/rpc/server/RpcServerManager.test.ts | 26 ++++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index a87ed7ad83..bdfe9fdc3d 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -6,7 +6,6 @@ import EventEmitter from 'events'; import type TypedEmitter from 'typed-emitter'; import { type StructuredLogger } from '../../../logger'; import { CLIENT_PROTOCOL_GZIP_RPC } from '../../../version'; -import { DataChannelKind } from '../../RTCEngine'; import type { ByteStreamReader } from '../../data-stream/incoming/StreamReader'; import type OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; import type Participant from '../../participant/Participant'; diff --git a/src/room/rpc/server/RpcServerManager.test.ts b/src/room/rpc/server/RpcServerManager.test.ts index dff08fc6a3..1797348ba9 100644 --- a/src/room/rpc/server/RpcServerManager.test.ts +++ b/src/room/rpc/server/RpcServerManager.test.ts @@ -1,5 +1,4 @@ -import { DataPacket_Kind } from '@livekit/protocol'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi, assert } from 'vitest'; import log from '../../../logger'; import { subscribeToEvents } from '../../../utils/subscribeToEvents'; import { CLIENT_PROTOCOL_DEFAULT } from '../../../version'; @@ -47,15 +46,16 @@ describe('RpcServerManager', () => { // The first event is an acknowledgement of the request const ackEvent = await managerEvents.waitFor('sendDataPacket'); - expect(ackEvent.packet.value.case).toStrictEqual('rpcAck'); + assert(ackEvent.packet.value.case === 'rpcAck'); expect(ackEvent.packet.value.value.requestId).toStrictEqual(requestId); // And the second being the actual response const responseEvent = await managerEvents.waitFor('sendDataPacket'); - expect(responseEvent.packet.value.case).toStrictEqual('rpcResponse'); - expect(responseEvent.packet.value.value.requestId).toStrictEqual(requestId); - expect(responseEvent.packet.value.value.value.case).toStrictEqual('payload'); - expect(responseEvent.packet.value.value.value.value).toStrictEqual('response payload'); + assert(responseEvent.packet.value.case === 'rpcResponse'); + const rpcResponse = responseEvent.packet.value.value; + expect(rpcResponse.requestId).toStrictEqual(requestId); + assert(rpcResponse.value.case === 'payload'); + expect(rpcResponse.value.value).toStrictEqual('response payload'); expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); }); @@ -90,12 +90,10 @@ describe('RpcServerManager', () => { // Ensure the first event was for the ack const ackEvent = await managerEvents.waitFor('sendDataPacket'); expect(ackEvent.packet.value.case).toStrictEqual('rpcAck'); - expect(ackEvent.kind).toStrictEqual(DataPacket_Kind.RELIABLE); // And the second event was for the response const responseEvent = await managerEvents.waitFor('sendDataPacket'); expect(responseEvent.packet.value.case).toStrictEqual('rpcResponse'); - expect(responseEvent.kind).toStrictEqual(DataPacket_Kind.RELIABLE); expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); }); @@ -125,11 +123,12 @@ describe('RpcServerManager', () => { // Ensure the first event was for the ack const ackEvent = await managerEvents.waitFor('sendDataPacket'); - expect(ackEvent.packet.value.case).toStrictEqual('rpcAck'); - expect(ackEvent.kind).toStrictEqual(DataPacket_Kind.RELIABLE); + assert(ackEvent.packet.value.case === 'rpcAck'); // And the second event was for the error response const errorEvent = await managerEvents.waitFor('sendDataPacket'); + assert(errorEvent.packet.value.case === 'rpcResponse'); + assert(errorEvent.packet.value.value.value.case === 'error'); const errorResponse = errorEvent.packet.value.value.value.value; expect(errorResponse.code).toStrictEqual(RpcError.ErrorCode.APPLICATION_ERROR); @@ -162,11 +161,12 @@ describe('RpcServerManager', () => { // Ensure the first event was for the ack const ackEvent = await managerEvents.waitFor('sendDataPacket'); - expect(ackEvent.packet.value.case).toStrictEqual('rpcAck'); - expect(ackEvent.kind).toStrictEqual(DataPacket_Kind.RELIABLE); + assert(ackEvent.packet.value.case === 'rpcAck'); // And the second event was for the error response const errorEvent = await managerEvents.waitFor('sendDataPacket'); + assert(errorEvent.packet.value.case === 'rpcResponse'); + assert(errorEvent.packet.value.value.value.case === 'error'); const errorResponse = errorEvent.packet.value.value.value.value; expect(errorResponse.code).toStrictEqual(errorCode); expect(errorResponse.message).toStrictEqual(errorMessage); From 5597802a3832377685e5b09b8da6c86c2e8bdf00 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Apr 2026 16:45:40 -0400 Subject: [PATCH 29/54] feat: remove compression from this for now Instead think about how to introduce compression into data streams --- src/room/rpc/client/RpcClientManager.ts | 17 ++-- src/room/rpc/index.ts | 4 - src/room/rpc/server/RpcServerManager.ts | 17 ++-- src/room/rpc/utils.ts | 113 ------------------------ 4 files changed, 14 insertions(+), 137 deletions(-) diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index bdfe9fdc3d..bc2e5b27ae 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -19,8 +19,6 @@ import { RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR, RpcError, byteLength, - gzipCompressToWriter, - gzipDecompressFromReader, } from '../utils'; import type { RpcClientManagerCallbacks } from './events'; @@ -143,7 +141,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm remoteClientProtocol: number, ) { if (remoteClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC) { - // Send payload as a compressed data stream + // Send payload as a data stream const writer = await this.outgoingDataStreamManager.streamBytes({ topic: RPC_DATA_STREAM_TOPIC, destinationIdentities: [destinationIdentity], @@ -154,7 +152,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm [RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR]: `${responseTimeout}`, }, }); - await gzipCompressToWriter(payload, writer); + await writer.write(payload); await writer.close(); return; } @@ -179,20 +177,19 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm } /** - * Handle an incoming byte stream containing an RPC response payload. - * Decompresses the stream and resolves/rejects the pending data stream future. + * Handle an incoming data stream containing an RPC response payload. */ async handleIncomingDataStream(reader: ByteStreamReader, responseId: string) { - let decompressedPayload: string; + let payload: string; try { - decompressedPayload = await gzipDecompressFromReader(reader); + payload = await reader.readAll(); } catch (e) { - this.log.warn(`Error decompressing RPC response payload: ${e}`); + this.log.warn(`Error reading RPC response payload: ${e}`); this.handleIncomingRpcResponseFailure(responseId, RpcError.builtIn('APPLICATION_ERROR')); return; } - this.handleIncomingRpcResponseSuccess(responseId, decompressedPayload); + this.handleIncomingRpcResponseSuccess(responseId, payload); } /** @internal */ diff --git a/src/room/rpc/index.ts b/src/room/rpc/index.ts index d0ce278e9d..c30f833455 100644 --- a/src/room/rpc/index.ts +++ b/src/room/rpc/index.ts @@ -12,9 +12,5 @@ export { RpcError, type RpcInvocationData, byteLength, - gzipCompress, - gzipCompressToWriter, - gzipDecompress, - gzipDecompressFromReader, truncateBytes, } from './utils'; diff --git a/src/room/rpc/server/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts index a3588ec6d1..0648a8127a 100644 --- a/src/room/rpc/server/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -19,8 +19,6 @@ import { RpcError, type RpcInvocationData, byteLength, - gzipCompressToWriter, - gzipDecompressFromReader, } from '../utils'; import type { RpcServerManagerCallbacks } from './events'; @@ -180,14 +178,14 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm const callerClientProtocol = this.getRemoteParticipantClientProtocol(destinationIdentity); if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC) { - // Send response as a compressed data stream + // Send response as a data stream const writer = await this.outgoingDataStreamManager.streamBytes({ topic: RPC_DATA_STREAM_TOPIC, destinationIdentities: [destinationIdentity], mimeType: 'application/octet-stream', attributes: { [RPC_RESPONSE_ID_ATTR]: requestId }, }); - await gzipCompressToWriter(payload, writer); + await writer.write(payload); await writer.close(); return; } @@ -209,8 +207,7 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm } /** - * Handle an incoming byte stream containing an RPC request payload. - * Decompresses the stream and resolves/rejects the pending data stream future. + * Handle an incoming data stream containing an RPC request payload. */ async handleIncomingDataStream( reader: ByteStreamReader, @@ -233,11 +230,11 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm ); } - let decompressedPayload: string; + let payload: string; try { - decompressedPayload = await gzipDecompressFromReader(reader); + payload = await reader.readAll(); } catch (e) { - this.log.warn(`Error decompressing RPC request payload: ${e}`); + this.log.warn(`Error reading RPC request payload: ${e}`); this.publishRpcResponsePacket( callerIdentity, requestId, @@ -264,7 +261,7 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm response = await handler({ requestId, callerIdentity, - payload: decompressedPayload, + payload, responseTimeout, }); } catch (error) { diff --git a/src/room/rpc/utils.ts b/src/room/rpc/utils.ts index 1f8b07e899..543278a6e5 100644 --- a/src/room/rpc/utils.ts +++ b/src/room/rpc/utils.ts @@ -171,119 +171,6 @@ export const RPC_RESPONSE_ID_ATTR = 'lk.rpc_response_id'; */ export const RPC_DATA_STREAM_TOPIC = 'lk.rpc_payload'; -/** - * Compress a string payload using gzip. - * @internal - */ -export async function gzipCompress(data: string): Promise { - const input = new TextEncoder().encode(data); - const cs = new CompressionStream('gzip'); - const writer = cs.writable.getWriter(); - writer.write(input); - writer.close(); - - const reader = cs.readable.getReader(); - const chunks: Uint8Array[] = []; - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - chunks.push(value); - } - - const totalLength = chunks.reduce((sum, c) => sum + c.length, 0); - const result = new Uint8Array(totalLength); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.length; - } - return result; -} - -/** - * Compress a string payload using gzip, streaming each compressed chunk to the provided writer. - * @internal - */ -export async function gzipCompressToWriter( - data: string, - writer: { write(chunk: Uint8Array): Promise }, -): Promise { - const input = new TextEncoder().encode(data); - const cs = new CompressionStream('gzip'); - const csWriter = cs.writable.getWriter(); - csWriter.write(input); - csWriter.close(); - - const reader = cs.readable.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - await writer.write(value); - } -} - -/** - * Decompress a gzip-compressed payload back to a string. - * @internal - */ -export async function gzipDecompress(data: Uint8Array): Promise { - const ds = new DecompressionStream('gzip'); - const writer = ds.writable.getWriter(); - writer.write(data); - writer.close(); - - const reader = ds.readable.getReader(); - const decoder = new TextDecoder(); - let result = ''; - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - result += decoder.decode(value, { stream: true }); - } - result += decoder.decode(); - return result; -} - -/** - * Decompress a gzip-compressed stream of chunks back to a string, feeding each chunk - * into the decompression stream as it arrives rather than buffering first. - * @internal - */ -export async function gzipDecompressFromReader(reader: AsyncIterable): Promise { - const ds = new DecompressionStream('gzip'); - const dsWriter = ds.writable.getWriter(); - - // Feed compressed chunks into the decompression stream as they arrive - const pipePromise = (async () => { - for await (const chunk of reader) { - await dsWriter.write(chunk); - } - await dsWriter.close(); - })(); - - // Read decompressed output concurrently - const dsReader = ds.readable.getReader(); - const decoder = new TextDecoder(); - let result = ''; - while (true) { - const { done, value } = await dsReader.read(); - if (done) { - break; - } - result += decoder.decode(value, { stream: true }); - } - result += decoder.decode(); - - await pipePromise; - return result; -} - /** * @internal */ From bc44f20ed25ae77f9ecdb13af1de5ce50b674e2f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 09:36:26 -0400 Subject: [PATCH 30/54] feat: consistency use text streams everywhere for rpc, not byte streams I think this is the right call, not 100% sure though. --- src/room/Room.ts | 2 +- src/room/rpc/client/RpcClientManager.ts | 8 ++++---- src/room/rpc/server/RpcServerManager.ts | 7 +++---- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index b5fe440353..6c26699178 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -2406,7 +2406,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) }; private registerRpcDataStreamHandler() { - this.incomingDataStreamManager.registerByteStreamHandler( + this.incomingDataStreamManager.registerTextStreamHandler( RPC_DATA_STREAM_TOPIC, async (reader, { identity }) => { const attributes = reader.info.attributes ?? {}; diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index bc2e5b27ae..4911f1bbb9 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -6,7 +6,7 @@ import EventEmitter from 'events'; import type TypedEmitter from 'typed-emitter'; import { type StructuredLogger } from '../../../logger'; import { CLIENT_PROTOCOL_GZIP_RPC } from '../../../version'; -import type { ByteStreamReader } from '../../data-stream/incoming/StreamReader'; +import { type TextStreamReader } from '../../data-stream/incoming/StreamReader'; import type OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; import type Participant from '../../participant/Participant'; import { Future, compareVersions } from '../../utils'; @@ -142,16 +142,16 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm ) { if (remoteClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC) { // Send payload as a data stream - const writer = await this.outgoingDataStreamManager.streamBytes({ + const writer = await this.outgoingDataStreamManager.streamText({ topic: RPC_DATA_STREAM_TOPIC, destinationIdentities: [destinationIdentity], - mimeType: 'application/octet-stream', attributes: { [RPC_REQUEST_ID_ATTR]: requestId, [RPC_REQUEST_METHOD_ATTR]: method, [RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR]: `${responseTimeout}`, }, }); + await writer.write(payload); await writer.close(); return; @@ -179,7 +179,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm /** * Handle an incoming data stream containing an RPC response payload. */ - async handleIncomingDataStream(reader: ByteStreamReader, responseId: string) { + async handleIncomingDataStream(reader: TextStreamReader, responseId: string) { let payload: string; try { payload = await reader.readAll(); diff --git a/src/room/rpc/server/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts index 0648a8127a..d8d6de7c52 100644 --- a/src/room/rpc/server/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -6,7 +6,7 @@ import EventEmitter from 'events'; import type TypedEmitter from 'typed-emitter'; import { type StructuredLogger } from '../../../logger'; import { CLIENT_PROTOCOL_GZIP_RPC } from '../../../version'; -import type { ByteStreamReader } from '../../data-stream/incoming/StreamReader'; +import { type TextStreamReader } from '../../data-stream/incoming/StreamReader'; import type OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; import type Participant from '../../participant/Participant'; import { @@ -179,10 +179,9 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC) { // Send response as a data stream - const writer = await this.outgoingDataStreamManager.streamBytes({ + const writer = await this.outgoingDataStreamManager.streamText({ topic: RPC_DATA_STREAM_TOPIC, destinationIdentities: [destinationIdentity], - mimeType: 'application/octet-stream', attributes: { [RPC_RESPONSE_ID_ATTR]: requestId }, }); await writer.write(payload); @@ -210,7 +209,7 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm * Handle an incoming data stream containing an RPC request payload. */ async handleIncomingDataStream( - reader: ByteStreamReader, + reader: TextStreamReader, callerIdentity: Participant['identity'], dataStreamAttrs: Record, ) { From 4e40eb6566a796f6ec1371eddd1bcca46e942e8c Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 09:57:16 -0400 Subject: [PATCH 31/54] fix: add ack to rpc messages also in the data stream case --- src/room/rpc/server/RpcServerManager.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/room/rpc/server/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts index d8d6de7c52..26814b5ad9 100644 --- a/src/room/rpc/server/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -227,8 +227,11 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm null, RpcError.builtIn('APPLICATION_ERROR'), ); + return; } + this.publishRpcAck(callerIdentity, requestId); + let payload: string; try { payload = await reader.readAll(); From 5ed8c39fa77d15a64afdc30fe1a8a3acf6ec64ff Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 10:19:33 -0400 Subject: [PATCH 32/54] fix: make rpc resilient to engine teardowns This could exist if the room is disconnected halfway through responding to a rpc request. --- src/room/Room.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index 6c26699178..af525ca92b 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -311,7 +311,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) () => this.engine.latestJoinResponse?.serverInfo?.version, ); this.rpcClientManager.on('sendDataPacket', ({ packet }) => { - this.engine.sendDataPacket(packet, DataChannelKind.RELIABLE); + this.engine?.sendDataPacket(packet, DataChannelKind.RELIABLE); }); this.rpcServerManager = new RpcServerManager( this.log, @@ -319,7 +319,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.getRemoteParticipantClientProtocol, ); this.rpcServerManager.on('sendDataPacket', ({ packet }) => { - this.engine.sendDataPacket(packet, DataChannelKind.RELIABLE); + this.engine?.sendDataPacket(packet, DataChannelKind.RELIABLE); }); this.disconnectLock = new Mutex(); From d7a8a1e907c3c73378cf1b3e514c45064133eb67 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 10:24:25 -0400 Subject: [PATCH 33/54] fix: remove LLM generated file headers --- src/room/rpc/client/RpcClientManager.ts | 3 --- src/room/rpc/server/RpcServerManager.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index 4911f1bbb9..c469ca36c8 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -1,6 +1,3 @@ -// SPDX-FileCopyrightText: 2026 LiveKit, Inc. -// -// SPDX-License-Identifier: Apache-2.0 import { DataPacket, DataPacket_Kind, RpcRequest } from '@livekit/protocol'; import EventEmitter from 'events'; import type TypedEmitter from 'typed-emitter'; diff --git a/src/room/rpc/server/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts index 26814b5ad9..ecc34cb4ec 100644 --- a/src/room/rpc/server/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -1,6 +1,3 @@ -// SPDX-FileCopyrightText: 2026 LiveKit, Inc. -// -// SPDX-License-Identifier: Apache-2.0 import { DataPacket, DataPacket_Kind, RpcAck, RpcResponse } from '@livekit/protocol'; import EventEmitter from 'events'; import type TypedEmitter from 'typed-emitter'; From f2bc189c002b7fb11fc0350ba49a5b8c62e77b2a Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 10:25:12 -0400 Subject: [PATCH 34/54] feat: make handleIncomingRpcRequest take a RpcRequest --- src/room/Room.ts | 10 +- src/room/rpc/server/RpcServerManager.ts | 208 +++++++++++------------- 2 files changed, 97 insertions(+), 121 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index af525ca92b..c7e3f6ff17 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -1965,15 +1965,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.handleDataStream(packet, encryptionType); } else if (packet.value.case === 'rpcRequest') { const rpc = packet.value.value; - this.rpcServerManager.handleIncomingRpcRequest( - packet.participantIdentity, - rpc.id, - rpc.method, - rpc.payload, - rpc.responseTimeoutMs, - rpc.version, - () => this.remoteParticipants.has(packet.participantIdentity), - ); + this.rpcServerManager.handleIncomingRpcRequest(packet.participantIdentity, rpc); } else if (packet.value.case === 'rpcResponse') { const rpcResponse = packet.value.value; switch (rpcResponse.value.case) { diff --git a/src/room/rpc/server/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts index ecc34cb4ec..d8a491f322 100644 --- a/src/room/rpc/server/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -1,4 +1,4 @@ -import { DataPacket, DataPacket_Kind, RpcAck, RpcResponse } from '@livekit/protocol'; +import { DataPacket, DataPacket_Kind, RpcAck, RpcRequest, RpcResponse } from '@livekit/protocol'; import EventEmitter from 'events'; import type TypedEmitter from 'typed-emitter'; import { type StructuredLogger } from '../../../logger'; @@ -57,40 +57,108 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm this.rpcHandlers.delete(method); } - async handleIncomingRpcRequest( - callerIdentity: string, - requestId: string, - method: string, - payload: string, - responseTimeout: number, - version: number, - isCallerStillConnected: () => boolean, - ) { - this.publishRpcAck(callerIdentity, requestId); + async handleIncomingRpcRequest(callerIdentity: string, rpcRequest: RpcRequest) { + this.publishRpcAck(callerIdentity, rpcRequest.id); + + if (rpcRequest.version !== 1) { + this.publishRpcResponsePacket( + callerIdentity, + rpcRequest.id, + null, + RpcError.builtIn('UNSUPPORTED_VERSION'), + ); + return; + } - if (version !== 1) { - if (isCallerStillConnected()) { - this.publishRpcResponsePacket( - callerIdentity, - requestId, - null, - RpcError.builtIn('UNSUPPORTED_VERSION'), + const handler = this.rpcHandlers.get(rpcRequest.method); + + if (!handler) { + this.publishRpcResponsePacket( + callerIdentity, + rpcRequest.id, + null, + RpcError.builtIn('UNSUPPORTED_METHOD'), + ); + return; + } + + let response: string | null = null; + try { + response = await handler({ + requestId: rpcRequest.id, + callerIdentity, + payload: rpcRequest.payload, + responseTimeout: rpcRequest.responseTimeoutMs, + }); + } catch (error) { + let responseError; + if (error instanceof RpcError) { + responseError = error; + } else { + this.log.warn( + `Uncaught error returned by RPC handler for ${rpcRequest.method}. Returning APPLICATION_ERROR instead.`, + error, ); + responseError = RpcError.builtIn('APPLICATION_ERROR'); } + + this.publishRpcResponsePacket(callerIdentity, rpcRequest.id, null, responseError); + return; + } + + await this.publishRpcResponse(callerIdentity, rpcRequest.id, response ?? ''); + } + + /** + * Handle an incoming data stream containing an RPC request payload. + */ + async handleIncomingDataStream( + reader: TextStreamReader, + callerIdentity: Participant['identity'], + dataStreamAttrs: Record, + ) { + const requestId = dataStreamAttrs[RPC_REQUEST_ID_ATTR]; + const method = dataStreamAttrs[RPC_REQUEST_METHOD_ATTR]; + const responseTimeout = parseInt(dataStreamAttrs[RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR], 10); + + if (!requestId || !method || Number.isNaN(responseTimeout)) { + this.log.warn( + `RPC data stream malformed: ${RPC_REQUEST_ID_ATTR} / ${RPC_REQUEST_METHOD_ATTR} / ${RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR} not set.`, + ); + this.publishRpcResponsePacket( + callerIdentity, + requestId, + null, + RpcError.builtIn('APPLICATION_ERROR'), + ); + return; + } + + this.publishRpcAck(callerIdentity, requestId); + + let payload: string; + try { + payload = await reader.readAll(); + } catch (e) { + this.log.warn(`Error reading RPC request payload: ${e}`); + this.publishRpcResponsePacket( + callerIdentity, + requestId, + null, + RpcError.builtIn('APPLICATION_ERROR'), + ); return; } const handler = this.rpcHandlers.get(method); if (!handler) { - if (isCallerStillConnected()) { - this.publishRpcResponsePacket( - callerIdentity, - requestId, - null, - RpcError.builtIn('UNSUPPORTED_METHOD'), - ); - } + this.publishRpcResponsePacket( + callerIdentity, + requestId, + null, + RpcError.builtIn('UNSUPPORTED_METHOD'), + ); return; } @@ -114,15 +182,11 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm responseError = RpcError.builtIn('APPLICATION_ERROR'); } - if (isCallerStillConnected()) { - this.publishRpcResponsePacket(callerIdentity, requestId, null, responseError); - } + this.publishRpcResponsePacket(callerIdentity, requestId, null, responseError); return; } - if (isCallerStillConnected()) { - await this.publishRpcResponse(callerIdentity, requestId, response ?? ''); - } + await this.publishRpcResponse(callerIdentity, requestId, response ?? ''); } private publishRpcAck(destinationIdentity: string, requestId: string) { @@ -201,84 +265,4 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm this.publishRpcResponsePacket(destinationIdentity, requestId, payload, null); } - - /** - * Handle an incoming data stream containing an RPC request payload. - */ - async handleIncomingDataStream( - reader: TextStreamReader, - callerIdentity: Participant['identity'], - dataStreamAttrs: Record, - ) { - const requestId = dataStreamAttrs[RPC_REQUEST_ID_ATTR]; - const method = dataStreamAttrs[RPC_REQUEST_METHOD_ATTR]; - const responseTimeout = parseInt(dataStreamAttrs[RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR], 10); - - if (!requestId || !method || Number.isNaN(responseTimeout)) { - this.log.warn( - `RPC data stream malformed: ${RPC_REQUEST_ID_ATTR} / ${RPC_REQUEST_METHOD_ATTR} / ${RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR} not set.`, - ); - this.publishRpcResponsePacket( - callerIdentity, - requestId, - null, - RpcError.builtIn('APPLICATION_ERROR'), - ); - return; - } - - this.publishRpcAck(callerIdentity, requestId); - - let payload: string; - try { - payload = await reader.readAll(); - } catch (e) { - this.log.warn(`Error reading RPC request payload: ${e}`); - this.publishRpcResponsePacket( - callerIdentity, - requestId, - null, - RpcError.builtIn('APPLICATION_ERROR'), - ); - return; - } - - const handler = this.rpcHandlers.get(method); - - if (!handler) { - this.publishRpcResponsePacket( - callerIdentity, - requestId, - null, - RpcError.builtIn('UNSUPPORTED_METHOD'), - ); - return; - } - - let response: string | null = null; - try { - response = await handler({ - requestId, - callerIdentity, - payload, - responseTimeout, - }); - } catch (error) { - let responseError; - if (error instanceof RpcError) { - responseError = error; - } else { - this.log.warn( - `Uncaught error returned by RPC handler for ${method}. Returning APPLICATION_ERROR instead.`, - error, - ); - responseError = RpcError.builtIn('APPLICATION_ERROR'); - } - - this.publishRpcResponsePacket(callerIdentity, requestId, null, responseError); - return; - } - - await this.publishRpcResponse(callerIdentity, requestId, response ?? ''); - } } From ca28e0d0ce91558de700448a48d5a4938fe45d66 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 10:41:20 -0400 Subject: [PATCH 35/54] feat: add explicit version field to data streams rpc (this is now explicitly "version 2") --- src/room/rpc/client/RpcClientManager.ts | 2 ++ src/room/rpc/server/RpcServerManager.ts | 23 ++++++++++++++++++++--- src/room/rpc/utils.ts | 3 +++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index c469ca36c8..ad99492b1c 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -14,6 +14,7 @@ import { RPC_REQUEST_ID_ATTR, RPC_REQUEST_METHOD_ATTR, RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR, + RPC_REQUEST_VERSION_ATTR, RpcError, byteLength, } from '../utils'; @@ -146,6 +147,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm [RPC_REQUEST_ID_ATTR]: requestId, [RPC_REQUEST_METHOD_ATTR]: method, [RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR]: `${responseTimeout}`, + [RPC_REQUEST_VERSION_ATTR]: "2", // Latest rpc request version }, }); diff --git a/src/room/rpc/server/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts index d8a491f322..28d7c46cf4 100644 --- a/src/room/rpc/server/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -12,6 +12,7 @@ import { RPC_REQUEST_ID_ATTR, RPC_REQUEST_METHOD_ATTR, RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR, + RPC_REQUEST_VERSION_ATTR, RPC_RESPONSE_ID_ATTR, RpcError, type RpcInvocationData, @@ -57,6 +58,10 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm this.rpcHandlers.delete(method); } + /** + * Handle an incoming RPCRequest message containing a payload. + * This handles "version 1" of rpc requests. + */ async handleIncomingRpcRequest(callerIdentity: string, rpcRequest: RpcRequest) { this.publishRpcAck(callerIdentity, rpcRequest.id); @@ -110,7 +115,8 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm } /** - * Handle an incoming data stream containing an RPC request payload. + * Handle an incoming data stream containing a RPC request payload. + * This handles "version 2" of rpc requests. */ async handleIncomingDataStream( reader: TextStreamReader, @@ -120,10 +126,11 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm const requestId = dataStreamAttrs[RPC_REQUEST_ID_ATTR]; const method = dataStreamAttrs[RPC_REQUEST_METHOD_ATTR]; const responseTimeout = parseInt(dataStreamAttrs[RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR], 10); + const version = parseInt(dataStreamAttrs[RPC_REQUEST_VERSION_ATTR], 10); - if (!requestId || !method || Number.isNaN(responseTimeout)) { + if (!requestId || !method || Number.isNaN(responseTimeout) || Number.isNaN(version)) { this.log.warn( - `RPC data stream malformed: ${RPC_REQUEST_ID_ATTR} / ${RPC_REQUEST_METHOD_ATTR} / ${RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR} not set.`, + `RPC data stream malformed: ${RPC_REQUEST_ID_ATTR} / ${RPC_REQUEST_METHOD_ATTR} / ${RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR} / ${RPC_REQUEST_VERSION_ATTR} not set.`, ); this.publishRpcResponsePacket( callerIdentity, @@ -136,6 +143,16 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm this.publishRpcAck(callerIdentity, requestId); + if (version !== 2) { + this.publishRpcResponsePacket( + callerIdentity, + requestId, + null, + RpcError.builtIn('UNSUPPORTED_VERSION'), + ); + return; + } + let payload: string; try { payload = await reader.readAll(); diff --git a/src/room/rpc/utils.ts b/src/room/rpc/utils.ts index 543278a6e5..8d5f481b33 100644 --- a/src/room/rpc/utils.ts +++ b/src/room/rpc/utils.ts @@ -159,6 +159,9 @@ export const RPC_REQUEST_METHOD_ATTR = 'lk.rpc_request_method'; /** @internal */ export const RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR = 'lk.rpc_request_response_timeout_ms'; +/** @internal */ +export const RPC_REQUEST_VERSION_ATTR = 'lk.rpc_request_version'; + /** * Attribute key set on a data stream to associate it with an RPC response. * @internal From e4e985f9286b706bc891fb2a5d5b75b8bc895420 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 10:41:52 -0400 Subject: [PATCH 36/54] fix: rename client protocol version var to match new implementation properties --- src/room/rpc/client/RpcClientManager.ts | 4 ++-- src/room/rpc/server/RpcServerManager.ts | 4 ++-- src/room/rpc/utils.ts | 2 +- src/version.ts | 7 +++++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index ad99492b1c..0d6c64b123 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -2,7 +2,7 @@ import { DataPacket, DataPacket_Kind, RpcRequest } from '@livekit/protocol'; import EventEmitter from 'events'; import type TypedEmitter from 'typed-emitter'; import { type StructuredLogger } from '../../../logger'; -import { CLIENT_PROTOCOL_GZIP_RPC } from '../../../version'; +import { CLIENT_PROTOCOL_DATA_STREAM_RPC } from '../../../version'; import { type TextStreamReader } from '../../data-stream/incoming/StreamReader'; import type OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; import type Participant from '../../participant/Participant'; @@ -138,7 +138,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm responseTimeout: number, remoteClientProtocol: number, ) { - if (remoteClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC) { + if (remoteClientProtocol >= CLIENT_PROTOCOL_DATA_STREAM_RPC) { // Send payload as a data stream const writer = await this.outgoingDataStreamManager.streamText({ topic: RPC_DATA_STREAM_TOPIC, diff --git a/src/room/rpc/server/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts index 28d7c46cf4..2d274437df 100644 --- a/src/room/rpc/server/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -2,7 +2,7 @@ import { DataPacket, DataPacket_Kind, RpcAck, RpcRequest, RpcResponse } from '@l import EventEmitter from 'events'; import type TypedEmitter from 'typed-emitter'; import { type StructuredLogger } from '../../../logger'; -import { CLIENT_PROTOCOL_GZIP_RPC } from '../../../version'; +import { CLIENT_PROTOCOL_DATA_STREAM_RPC } from '../../../version'; import { type TextStreamReader } from '../../data-stream/incoming/StreamReader'; import type OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; import type Participant from '../../participant/Participant'; @@ -255,7 +255,7 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm ) { const callerClientProtocol = this.getRemoteParticipantClientProtocol(destinationIdentity); - if (callerClientProtocol >= CLIENT_PROTOCOL_GZIP_RPC) { + if (callerClientProtocol >= CLIENT_PROTOCOL_DATA_STREAM_RPC) { // Send response as a data stream const writer = await this.outgoingDataStreamManager.streamText({ topic: RPC_DATA_STREAM_TOPIC, diff --git a/src/room/rpc/utils.ts b/src/room/rpc/utils.ts index 8d5f481b33..bacf429587 100644 --- a/src/room/rpc/utils.ts +++ b/src/room/rpc/utils.ts @@ -140,7 +140,7 @@ export class RpcError extends Error { /* * Maximum payload size for RPC requests and responses for clients with a clientProtocol of less - * than CLIENT_PROTOCOL_GZIP_RPC. + * than CLIENT_PROTOCOL_DATA_STREAM_RPC. * * If a payload exceeds this size and the remote client does not support compression, * the RPC call will fail with a REQUEST_PAYLOAD_TOO_LARGE(1402) or RESPONSE_PAYLOAD_TOO_LARGE(1504) error. diff --git a/src/version.ts b/src/version.ts index 21fce81612..096e8faab7 100644 --- a/src/version.ts +++ b/src/version.ts @@ -3,9 +3,12 @@ import { version as v } from '../package.json'; export const version = v; export const protocolVersion = 16; +/** Initial client protocol. */ export const CLIENT_PROTOCOL_DEFAULT = 0; -export const CLIENT_PROTOCOL_GZIP_RPC = 1; +/** Replaces RPC v1 protocol with a data streams based one to support unlimited request / response + * payload length. */ +export const CLIENT_PROTOCOL_DATA_STREAM_RPC = 1; /** The client protocol version indicates what level of support that the client has for * client <-> client api interactions. */ -export const clientProtocol = CLIENT_PROTOCOL_GZIP_RPC; +export const clientProtocol = CLIENT_PROTOCOL_DATA_STREAM_RPC; From d048d8ba09125e08e06952e7694bc73b16582dca Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 10:49:46 -0400 Subject: [PATCH 37/54] fix: convert console.error -> log.error --- src/room/rpc/client/RpcClientManager.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index 0d6c64b123..2f9a216b14 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -177,6 +177,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm /** * Handle an incoming data stream containing an RPC response payload. + * @internal */ async handleIncomingDataStream(reader: TextStreamReader, responseId: string) { let payload: string; @@ -198,7 +199,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm handler.completionFuture.resolve?.(payload); this.pendingResponses.delete(requestId); } else { - console.error('Response received for unexpected RPC request', requestId); + this.log.error('Response received for unexpected RPC request', requestId); } } @@ -209,7 +210,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm handler.completionFuture.reject?.(error); this.pendingResponses.delete(requestId); } else { - console.error('Response received for unexpected RPC request', requestId); + this.log.error('Response received for unexpected RPC request', requestId); } } From 73e300d761bfc36e5d79ce5d28792282a6ce3d7c Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 11:15:52 -0400 Subject: [PATCH 38/54] refactor: rename MAX_LEGACY_PAYLOAD_BYTES => MAX_V1_PAYLOAD_BYTES --- src/room/rpc/client/RpcClientManager.ts | 4 ++-- src/room/rpc/server/RpcServerManager.ts | 6 +++--- src/room/rpc/utils.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index 2f9a216b14..ec30fa309e 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -8,7 +8,7 @@ import type OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingD import type Participant from '../../participant/Participant'; import { Future, compareVersions } from '../../utils'; import { - MAX_LEGACY_PAYLOAD_BYTES, + MAX_V1_PAYLOAD_BYTES, type PerformRpcParams, RPC_DATA_STREAM_TOPIC, RPC_REQUEST_ID_ATTR, @@ -70,7 +70,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm const payloadBytes = byteLength(payload); // Only enforce the legacy size limit when compression is not available - if (payloadBytes > MAX_LEGACY_PAYLOAD_BYTES && remoteClientProtocol < 1) { + if (payloadBytes > MAX_V1_PAYLOAD_BYTES && remoteClientProtocol < 1) { throw RpcError.builtIn('REQUEST_PAYLOAD_TOO_LARGE'); } diff --git a/src/room/rpc/server/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts index 2d274437df..d67ee872d0 100644 --- a/src/room/rpc/server/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -7,7 +7,7 @@ import { type TextStreamReader } from '../../data-stream/incoming/StreamReader'; import type OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; import type Participant from '../../participant/Participant'; import { - MAX_LEGACY_PAYLOAD_BYTES, + MAX_V1_PAYLOAD_BYTES, RPC_DATA_STREAM_TOPIC, RPC_REQUEST_ID_ATTR, RPC_REQUEST_METHOD_ATTR, @@ -269,8 +269,8 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm // Legacy client: enforce size limit and send uncompressed payload inline const responseBytes = byteLength(payload); - if (responseBytes > MAX_LEGACY_PAYLOAD_BYTES) { - this.log.warn(`RPC Response payload too large for request ${requestId}`); + if (responseBytes > MAX_V1_PAYLOAD_BYTES) { + this.log.warn(`RPC Response payload too large for request ${requestId}. To send larger responses, consider updating the sending client.`); this.publishRpcResponsePacket( destinationIdentity, requestId, diff --git a/src/room/rpc/utils.ts b/src/room/rpc/utils.ts index bacf429587..3b83337574 100644 --- a/src/room/rpc/utils.ts +++ b/src/room/rpc/utils.ts @@ -145,7 +145,7 @@ export class RpcError extends Error { * If a payload exceeds this size and the remote client does not support compression, * the RPC call will fail with a REQUEST_PAYLOAD_TOO_LARGE(1402) or RESPONSE_PAYLOAD_TOO_LARGE(1504) error. */ -export const MAX_LEGACY_PAYLOAD_BYTES = 15360; // 15 KB +export const MAX_V1_PAYLOAD_BYTES = 15360; // 15 KB /** * Attribute key set on a data stream to associate it with an RPC request. From 0fb650fe9f76ee7304c323bb21088fd29bf5f35f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 11:16:07 -0400 Subject: [PATCH 39/54] fix: add docs comments for v1 / v2 rpcs --- src/room/rpc/client/RpcClientManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index ec30fa309e..efaac51eaf 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -139,7 +139,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm remoteClientProtocol: number, ) { if (remoteClientProtocol >= CLIENT_PROTOCOL_DATA_STREAM_RPC) { - // Send payload as a data stream + // Send payload as a data stream - a "version 2" rpc request. const writer = await this.outgoingDataStreamManager.streamText({ topic: RPC_DATA_STREAM_TOPIC, destinationIdentities: [destinationIdentity], @@ -156,7 +156,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm return; } - // Legacy client: send uncompressed payload inline + // Fallback to sending a literal RpcRequest - a "version 1" rpc request. this.emit('sendDataPacket', { packet: new DataPacket({ destinationIdentities: [destinationIdentity], From bf2a4842cd93a3bd2025d93b18a27a59f5d48d4d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 11:16:19 -0400 Subject: [PATCH 40/54] fix: adjust tests over to use new handleIncomingRpcRequest signature --- src/room/rpc/server/RpcServerManager.test.ts | 55 +++++++++++--------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/src/room/rpc/server/RpcServerManager.test.ts b/src/room/rpc/server/RpcServerManager.test.ts index 1797348ba9..b9a36b1ab1 100644 --- a/src/room/rpc/server/RpcServerManager.test.ts +++ b/src/room/rpc/server/RpcServerManager.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi, assert } from 'vitest'; +import { RpcRequest } from '@livekit/protocol'; +import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; import log from '../../../logger'; import { subscribeToEvents } from '../../../utils/subscribeToEvents'; import { CLIENT_PROTOCOL_DEFAULT } from '../../../version'; @@ -36,12 +37,13 @@ describe('RpcServerManager', () => { const responseTimeoutMs = 10_000; await rpcServerManager.handleIncomingRpcRequest( 'caller-identity', - requestId, - 'test-method', - 'request payload', - responseTimeoutMs, - 1, - () => true, + new RpcRequest({ + id: requestId, + method: 'test-method', + payload: 'request payload', + responseTimeoutMs, + version: 1, + }), ); // The first event is an acknowledgement of the request @@ -72,12 +74,13 @@ describe('RpcServerManager', () => { await rpcServerManager.handleIncomingRpcRequest( 'remote-identity', - 'test-request-id', - methodName, - 'test payload', - 5000, - 1, - () => true, + new RpcRequest({ + id: 'test-request-id', + method: methodName, + payload: 'test payload', + responseTimeoutMs: 5000, + version: 1, + }), ); expect(handler).toHaveBeenCalledWith({ @@ -113,12 +116,13 @@ describe('RpcServerManager', () => { await rpcServerManager.handleIncomingRpcRequest( 'remote-identity', - 'test-error-request-id', - methodName, - 'test payload', - 5000, - 1, - () => true, + new RpcRequest({ + id: 'test-error-request-id', + method: methodName, + payload: 'test payload', + responseTimeoutMs: 5000, + version: 1, + }), ); // Ensure the first event was for the ack @@ -151,12 +155,13 @@ describe('RpcServerManager', () => { await rpcServerManager.handleIncomingRpcRequest( 'remote-identity', - 'test-rpc-error-request-id', - methodName, - 'test payload', - 5000, - 1, - () => true, + new RpcRequest({ + id: 'test-rpc-error-request-id', + method: methodName, + payload: 'test payload', + responseTimeoutMs: 5000, + version: 1, + }), ); // Ensure the first event was for the ack From c2c42d9aa71f56a56dc010a512f0d4a370c73bfb Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 11:17:09 -0400 Subject: [PATCH 41/54] fix: run npm run format --- src/room/rpc/client/RpcClientManager.ts | 2 +- src/room/rpc/server/RpcServerManager.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index efaac51eaf..8cd8c7730b 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -147,7 +147,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm [RPC_REQUEST_ID_ATTR]: requestId, [RPC_REQUEST_METHOD_ATTR]: method, [RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR]: `${responseTimeout}`, - [RPC_REQUEST_VERSION_ATTR]: "2", // Latest rpc request version + [RPC_REQUEST_VERSION_ATTR]: '2', // Latest rpc request version }, }); diff --git a/src/room/rpc/server/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts index d67ee872d0..4d21547057 100644 --- a/src/room/rpc/server/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -270,7 +270,9 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm // Legacy client: enforce size limit and send uncompressed payload inline const responseBytes = byteLength(payload); if (responseBytes > MAX_V1_PAYLOAD_BYTES) { - this.log.warn(`RPC Response payload too large for request ${requestId}. To send larger responses, consider updating the sending client.`); + this.log.warn( + `RPC Response payload too large for request ${requestId}. To send larger responses, consider updating the sending client.`, + ); this.publishRpcResponsePacket( destinationIdentity, requestId, From 4b6b23dcd12d95ed89a69034d219f767df2f637e Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 11:24:25 -0400 Subject: [PATCH 42/54] fix: remove dead code in rpc client manager test --- src/room/rpc/client/RpcClientManager.test.ts | 22 +++++--------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/room/rpc/client/RpcClientManager.test.ts b/src/room/rpc/client/RpcClientManager.test.ts index 012e352a80..84d838d790 100644 --- a/src/room/rpc/client/RpcClientManager.test.ts +++ b/src/room/rpc/client/RpcClientManager.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; import log from '../../../logger'; import { subscribeToEvents } from '../../../utils/subscribeToEvents'; import { CLIENT_PROTOCOL_DEFAULT } from '../../../version'; @@ -26,7 +26,7 @@ describe('RpcClientManager', () => { ); }); - it.skip('should send a rpc message to a participant (legacy path)', async () => { + it('should send a rpc message to a participant (legacy path)', async () => { const managerEvents = subscribeToEvents(rpcClientManager, [ 'sendDataPacket', ]); @@ -38,7 +38,7 @@ describe('RpcClientManager', () => { }); const { packet } = await managerEvents.waitFor('sendDataPacket'); - expect(packet.value.case).toStrictEqual('rpcRequest'); + assert(packet.value.case === 'rpcRequest'); expect(packet.value.value.id).toStrictEqual(requestId); expect(packet.value.value.method).toStrictEqual('test-method'); expect(packet.value.value.payload).toStrictEqual('request-payload'); @@ -82,10 +82,6 @@ describe('RpcClientManager', () => { }); it('should handle RPC request timeout', async () => { - const managerEvents = subscribeToEvents(rpcClientManager, [ - 'sendDataPacket', - ]); - vi.useFakeTimers(); try { @@ -93,7 +89,7 @@ describe('RpcClientManager', () => { const payload = 'timeoutPayload'; const timeout = 50; - const [requestId, completionPromise] = await rpcClientManager.performRpc({ + const [, completionPromise] = await rpcClientManager.performRpc({ destinationIdentity: 'remote-identity', method, payload, @@ -113,10 +109,6 @@ describe('RpcClientManager', () => { }); it('should handle RPC error response', async () => { - const managerEvents = subscribeToEvents(rpcClientManager, [ - 'sendDataPacket', - ]); - const method = 'errorMethod'; const payload = 'errorPayload'; const errorCode = 101; @@ -138,14 +130,10 @@ describe('RpcClientManager', () => { }); it('should handle participant disconnection during RPC request', async () => { - const managerEvents = subscribeToEvents(rpcClientManager, [ - 'sendDataPacket', - ]); - const method = 'disconnectMethod'; const payload = 'disconnectPayload'; - const [requestId, completionPromise] = await rpcClientManager.performRpc({ + const [, completionPromise] = await rpcClientManager.performRpc({ destinationIdentity: 'remote-identity', method, payload, From 63a4e0c53856ed01da5bceea6ace48a6d869c9bc Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 12:07:47 -0400 Subject: [PATCH 43/54] feat: add tests for v2 -> v2 rpc messages --- src/room/rpc/client/RpcClientManager.test.ts | 336 ++++++++---- src/room/rpc/server/RpcServerManager.test.ts | 533 ++++++++++++++----- 2 files changed, 621 insertions(+), 248 deletions(-) diff --git a/src/room/rpc/client/RpcClientManager.test.ts b/src/room/rpc/client/RpcClientManager.test.ts index 84d838d790..19d6220c30 100644 --- a/src/room/rpc/client/RpcClientManager.test.ts +++ b/src/room/rpc/client/RpcClientManager.test.ts @@ -1,148 +1,284 @@ import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; import log from '../../../logger'; import { subscribeToEvents } from '../../../utils/subscribeToEvents'; -import { CLIENT_PROTOCOL_DEFAULT } from '../../../version'; +import { CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT } from '../../../version'; import type RTCEngine from '../../RTCEngine'; import OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; import { sleep } from '../../utils'; -import { RpcError } from '../utils'; +import { + RPC_DATA_STREAM_TOPIC, + RPC_REQUEST_ID_ATTR, + RPC_REQUEST_METHOD_ATTR, + RPC_REQUEST_VERSION_ATTR, + RpcError, +} from '../utils'; import RpcClientManager from './RpcClientManager'; import type { RpcClientManagerCallbacks } from './events'; describe('RpcClientManager', () => { - let rpcClientManager: RpcClientManager; - - beforeEach(() => { - const outgoingDataStreamManager = new OutgoingDataStreamManager( - {} as unknown as RTCEngine, - log, - ); - - rpcClientManager = new RpcClientManager( - log, - outgoingDataStreamManager, - (_identity) => CLIENT_PROTOCOL_DEFAULT, - () => undefined, - ); - }); + describe('v2 -> v1', () => { + let rpcClientManager: RpcClientManager; - it('should send a rpc message to a participant (legacy path)', async () => { - const managerEvents = subscribeToEvents(rpcClientManager, [ - 'sendDataPacket', - ]); + beforeEach(() => { + const outgoingDataStreamManager = new OutgoingDataStreamManager( + {} as unknown as RTCEngine, + log, + ); - const [requestId, completionPromise] = await rpcClientManager.performRpc({ - destinationIdentity: 'destination-identity', - method: 'test-method', - payload: 'request-payload', + rpcClientManager = new RpcClientManager( + log, + outgoingDataStreamManager, + (_identity) => CLIENT_PROTOCOL_DEFAULT, // (other participant is "v1") + () => undefined, + ); }); - const { packet } = await managerEvents.waitFor('sendDataPacket'); - assert(packet.value.case === 'rpcRequest'); - expect(packet.value.value.id).toStrictEqual(requestId); - expect(packet.value.value.method).toStrictEqual('test-method'); - expect(packet.value.value.payload).toStrictEqual('request-payload'); - expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + it('should send v1 RPC request to a "legacy" client and receive successful response', async () => { + const managerEvents = subscribeToEvents(rpcClientManager, [ + 'sendDataPacket', + ]); - rpcClientManager.handleIncomingRpcAck(requestId); + const [requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remoteIdentity', + method: 'testMethod', + payload: 'testPayload', + }); - rpcClientManager.handleIncomingRpcResponseSuccess(requestId, 'response-payload'); + // Verify exactly one packet was emitted + const { packet } = await managerEvents.waitFor('sendDataPacket'); + assert(packet.value.case === 'rpcRequest'); + expect(packet.value.value.id).toStrictEqual(requestId); + expect(packet.value.value.method).toStrictEqual('testMethod'); + expect(packet.value.value.payload).toStrictEqual('testPayload'); + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); - await expect(completionPromise).resolves.toStrictEqual('response-payload'); - }); + // Asynchronously send a response back + await sleep(10); + rpcClientManager.handleIncomingRpcAck(requestId); + await sleep(10); + rpcClientManager.handleIncomingRpcResponseSuccess(requestId, 'response payload'); + + // Make sure the response came out the other end + const result = await completionPromise; + expect(result).toStrictEqual('response payload'); + }); + + it('should handle v1 RPC request timeout', async () => { + vi.useFakeTimers(); + + try { + const method = 'timeoutMethod'; + const payload = 'timeoutPayload'; + const timeout = 50; - it('should send RPC request and receive successful response', async () => { - const managerEvents = subscribeToEvents(rpcClientManager, [ - 'sendDataPacket', - ]); + const [, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remote-identity', + method, + payload, + responseTimeout: timeout, + }); - const method = 'testMethod'; - const payload = 'testPayload'; - const responsePayload = 'responsePayload'; + // Register the rejection handler before advancing so the rejection is caught + const rejectPromise = expect(completionPromise).rejects.toThrow('Response timeout'); - const [requestId, completionPromise] = await rpcClientManager.performRpc({ - destinationIdentity: 'remote-identity', - method, - payload, + // Response timeout (50ms) fires before ack timeout (7000ms) + await vi.advanceTimersByTimeAsync(timeout); + + await rejectPromise; + } finally { + vi.useRealTimers(); + } }); - // Verify exactly one packet was emitted - await managerEvents.waitFor('sendDataPacket'); - expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + it('should handle v1 RPC error response', async () => { + const method = 'errorMethod'; + const payload = 'errorPayload'; + const errorCode = 101; + const errorMessage = 'Test error message'; + + const [requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remote-identity', + method, + payload, + }); - setTimeout(() => { rpcClientManager.handleIncomingRpcAck(requestId); - setTimeout(() => { - rpcClientManager.handleIncomingRpcResponseSuccess(requestId, responsePayload); - }, 10); - }, 10); + rpcClientManager.handleIncomingRpcResponseFailure( + requestId, + new RpcError(errorCode, errorMessage), + ); - const result = await completionPromise; - expect(result).toStrictEqual(responsePayload); + await expect(completionPromise).rejects.toThrow(errorMessage); + }); + + it('should handle participant disconnection during v1 RPC request', async () => { + const method = 'disconnectMethod'; + const payload = 'disconnectPayload'; + + const [, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remote-identity', + method, + payload, + }); + + // Simulate a small delay before disconnection + await sleep(200); + rpcClientManager.handleParticipantDisconnected('remote-identity'); + + await expect(completionPromise).rejects.toThrow('Recipient disconnected'); + }); }); - it('should handle RPC request timeout', async () => { - vi.useFakeTimers(); + describe('v2 -> v2', () => { + let rpcClientManager: RpcClientManager; + let mockStreamTextWriter: { + write: ReturnType; + close: ReturnType; + }; + let mockOutgoingDataStreamManager: OutgoingDataStreamManager; - try { - const method = 'timeoutMethod'; - const payload = 'timeoutPayload'; - const timeout = 50; + beforeEach(() => { + mockStreamTextWriter = { + write: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; + mockOutgoingDataStreamManager = { + streamText: vi.fn().mockResolvedValue(mockStreamTextWriter), + } as unknown as OutgoingDataStreamManager; - const [, completionPromise] = await rpcClientManager.performRpc({ + rpcClientManager = new RpcClientManager( + log, + mockOutgoingDataStreamManager, + (_identity) => CLIENT_PROTOCOL_DATA_STREAM_RPC, + () => undefined, + ); + }); + + it('should send v2 RPC request via data stream and receive successful response', async () => { + const managerEvents = subscribeToEvents(rpcClientManager, [ + 'sendDataPacket', + ]); + + const [requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'destination-identity', + method: 'test-method', + payload: 'request-payload', + }); + + // Verify the data stream was used with correct attributes + expect(mockOutgoingDataStreamManager.streamText).toHaveBeenCalledWith( + expect.objectContaining({ + topic: RPC_DATA_STREAM_TOPIC, + destinationIdentities: ['destination-identity'], + attributes: expect.objectContaining({ + [RPC_REQUEST_ID_ATTR]: requestId, + [RPC_REQUEST_METHOD_ATTR]: 'test-method', + [RPC_REQUEST_VERSION_ATTR]: '2', + }), + }), + ); + expect(mockStreamTextWriter.write).toHaveBeenCalledWith('request-payload'); + expect(mockStreamTextWriter.close).toHaveBeenCalled(); + + // No packet should have been emitted + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + + rpcClientManager.handleIncomingRpcAck(requestId); + rpcClientManager.handleIncomingRpcResponseSuccess(requestId, 'response-payload'); + + await expect(completionPromise).resolves.toStrictEqual('response-payload'); + }); + + it('should send RPC request via data stream with delayed ack and response', async () => { + const method = 'testMethod'; + const payload = 'testPayload'; + const responsePayload = 'responsePayload'; + + const [requestId, completionPromise] = await rpcClientManager.performRpc({ destinationIdentity: 'remote-identity', method, payload, - responseTimeout: timeout, }); - // Register the rejection handler before advancing so the rejection is caught - const rejectPromise = expect(completionPromise).rejects.toThrow('Response timeout'); + setTimeout(() => { + rpcClientManager.handleIncomingRpcAck(requestId); + setTimeout(() => { + rpcClientManager.handleIncomingRpcResponseSuccess(requestId, responsePayload); + }, 10); + }, 10); + + const result = await completionPromise; + expect(result).toStrictEqual(responsePayload); + }); - // Response timeout (50ms) fires before ack timeout (7000ms) - await vi.advanceTimersByTimeAsync(timeout); + it('should handle RPC request timeout', async () => { + vi.useFakeTimers(); - await rejectPromise; - } finally { - vi.useRealTimers(); - } - }); + try { + const timeout = 50; - it('should handle RPC error response', async () => { - const method = 'errorMethod'; - const payload = 'errorPayload'; - const errorCode = 101; - const errorMessage = 'Test error message'; + const [, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remote-identity', + method: 'timeoutMethod', + payload: 'timeoutPayload', + responseTimeout: timeout, + }); - const [requestId, completionPromise] = await rpcClientManager.performRpc({ - destinationIdentity: 'remote-identity', - method, - payload, + const rejectPromise = expect(completionPromise).rejects.toThrow('Response timeout'); + await vi.advanceTimersByTimeAsync(timeout); + await rejectPromise; + } finally { + vi.useRealTimers(); + } }); - rpcClientManager.handleIncomingRpcAck(requestId); - rpcClientManager.handleIncomingRpcResponseFailure( - requestId, - new RpcError(errorCode, errorMessage), - ); + it('should handle RPC error response', async () => { + const errorCode = 101; + const errorMessage = 'Test error message'; - await expect(completionPromise).rejects.toThrow(errorMessage); - }); + const [requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remote-identity', + method: 'errorMethod', + payload: 'errorPayload', + }); - it('should handle participant disconnection during RPC request', async () => { - const method = 'disconnectMethod'; - const payload = 'disconnectPayload'; + rpcClientManager.handleIncomingRpcAck(requestId); + rpcClientManager.handleIncomingRpcResponseFailure( + requestId, + new RpcError(errorCode, errorMessage), + ); - const [, completionPromise] = await rpcClientManager.performRpc({ - destinationIdentity: 'remote-identity', - method, - payload, + await expect(completionPromise).rejects.toThrow(errorMessage); }); - // Simulate a small delay before disconnection - await sleep(200); - rpcClientManager.handleParticipantDisconnected('remote-identity'); + it('should handle participant disconnection during RPC request', async () => { + const [, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remote-identity', + method: 'disconnectMethod', + payload: 'disconnectPayload', + }); + + await sleep(200); + rpcClientManager.handleParticipantDisconnected('remote-identity'); + + await expect(completionPromise).rejects.toThrow('Recipient disconnected'); + }); - await expect(completionPromise).rejects.toThrow('Recipient disconnected'); + it('should receive response via data stream', async () => { + const [requestId, completionPromise] = await rpcClientManager.performRpc({ + destinationIdentity: 'remote-identity', + method: 'test-method', + payload: 'request-payload', + }); + + rpcClientManager.handleIncomingRpcAck(requestId); + + const mockReader = { + readAll: vi.fn().mockResolvedValue('data-stream-response'), + }; + await rpcClientManager.handleIncomingDataStream(mockReader as any, requestId); + + await expect(completionPromise).resolves.toStrictEqual('data-stream-response'); + }); }); }); diff --git a/src/room/rpc/server/RpcServerManager.test.ts b/src/room/rpc/server/RpcServerManager.test.ts index b9a36b1ab1..2931026aab 100644 --- a/src/room/rpc/server/RpcServerManager.test.ts +++ b/src/room/rpc/server/RpcServerManager.test.ts @@ -2,180 +2,417 @@ import { RpcRequest } from '@livekit/protocol'; import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; import log from '../../../logger'; import { subscribeToEvents } from '../../../utils/subscribeToEvents'; -import { CLIENT_PROTOCOL_DEFAULT } from '../../../version'; +import { CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT } from '../../../version'; import type RTCEngine from '../../RTCEngine'; import OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; -import { RpcError } from '../utils'; +import { + RPC_DATA_STREAM_TOPIC, + RPC_REQUEST_ID_ATTR, + RPC_REQUEST_METHOD_ATTR, + RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR, + RPC_REQUEST_VERSION_ATTR, + RPC_RESPONSE_ID_ATTR, + RpcError, +} from '../utils'; import RpcServerManager from './RpcServerManager'; import type { RpcServerManagerCallbacks } from './events'; describe('RpcServerManager', () => { - let rpcServerManager: RpcServerManager; - - beforeEach(() => { - const outgoingDataStreamManager = new OutgoingDataStreamManager( - {} as unknown as RTCEngine, - log, - ); - - rpcServerManager = new RpcServerManager( - log, - outgoingDataStreamManager, - (_identity) => CLIENT_PROTOCOL_DEFAULT, - ); - }); + describe('v1 -> v1', () => { + let rpcServerManager: RpcServerManager; - it('should receive a rpc message from a participant', async () => { - const managerEvents = subscribeToEvents(rpcServerManager, [ - 'sendDataPacket', - ]); - - const handler = async () => 'response payload'; - rpcServerManager.registerRpcMethod('test-method', handler); - - const requestId = crypto.randomUUID(); - const responseTimeoutMs = 10_000; - await rpcServerManager.handleIncomingRpcRequest( - 'caller-identity', - new RpcRequest({ - id: requestId, - method: 'test-method', - payload: 'request payload', - responseTimeoutMs, - version: 1, - }), - ); - - // The first event is an acknowledgement of the request - const ackEvent = await managerEvents.waitFor('sendDataPacket'); - assert(ackEvent.packet.value.case === 'rpcAck'); - expect(ackEvent.packet.value.value.requestId).toStrictEqual(requestId); - - // And the second being the actual response - const responseEvent = await managerEvents.waitFor('sendDataPacket'); - assert(responseEvent.packet.value.case === 'rpcResponse'); - const rpcResponse = responseEvent.packet.value.value; - expect(rpcResponse.requestId).toStrictEqual(requestId); - assert(rpcResponse.value.case === 'payload'); - expect(rpcResponse.value.value).toStrictEqual('response payload'); - - expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); - }); + beforeEach(() => { + const outgoingDataStreamManager = new OutgoingDataStreamManager( + {} as unknown as RTCEngine, + log, + ); + + rpcServerManager = new RpcServerManager( + log, + outgoingDataStreamManager, + (_identity) => CLIENT_PROTOCOL_DEFAULT, + ); + }); + + it('should receive a rpc message from a participant', async () => { + const managerEvents = subscribeToEvents(rpcServerManager, [ + 'sendDataPacket', + ]); + + const handler = async () => 'response payload'; + rpcServerManager.registerRpcMethod('test-method', handler); + + const requestId = crypto.randomUUID(); + const responseTimeoutMs = 10_000; + await rpcServerManager.handleIncomingRpcRequest( + 'caller-identity', + new RpcRequest({ + id: requestId, + method: 'test-method', + payload: 'request payload', + responseTimeoutMs, + version: 1, + }), + ); + + // The first event is an acknowledgement of the request + const ackEvent = await managerEvents.waitFor('sendDataPacket'); + assert(ackEvent.packet.value.case === 'rpcAck'); + expect(ackEvent.packet.value.value.requestId).toStrictEqual(requestId); + + // And the second being the actual response + const responseEvent = await managerEvents.waitFor('sendDataPacket'); + assert(responseEvent.packet.value.case === 'rpcResponse'); + const rpcResponse = responseEvent.packet.value.value; + expect(rpcResponse.requestId).toStrictEqual(requestId); + assert(rpcResponse.value.case === 'payload'); + expect(rpcResponse.value.value).toStrictEqual('response payload'); - it('should register an RPC method handler', async () => { - const managerEvents = subscribeToEvents(rpcServerManager, [ - 'sendDataPacket', - ]); + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + }); + + it('should register an RPC method handler', async () => { + const managerEvents = subscribeToEvents(rpcServerManager, [ + 'sendDataPacket', + ]); + + const methodName = 'testMethod'; + const handler = vi.fn().mockResolvedValue('test response'); - const methodName = 'testMethod'; - const handler = vi.fn().mockResolvedValue('test response'); + rpcServerManager.registerRpcMethod(methodName, handler); - rpcServerManager.registerRpcMethod(methodName, handler); + await rpcServerManager.handleIncomingRpcRequest( + 'remote-identity', + new RpcRequest({ + id: 'test-request-id', + method: methodName, + payload: 'test payload', + responseTimeoutMs: 5000, + version: 1, + }), + ); - await rpcServerManager.handleIncomingRpcRequest( - 'remote-identity', - new RpcRequest({ - id: 'test-request-id', - method: methodName, + expect(handler).toHaveBeenCalledWith({ + requestId: 'test-request-id', + callerIdentity: 'remote-identity', payload: 'test payload', - responseTimeoutMs: 5000, - version: 1, - }), - ); - - expect(handler).toHaveBeenCalledWith({ - requestId: 'test-request-id', - callerIdentity: 'remote-identity', - payload: 'test payload', - responseTimeout: 5000, + responseTimeout: 5000, + }); + + // Ensure the first event was for the ack + const ackEvent = await managerEvents.waitFor('sendDataPacket'); + expect(ackEvent.packet.value.case).toStrictEqual('rpcAck'); + + // And the second event was for the response + const responseEvent = await managerEvents.waitFor('sendDataPacket'); + expect(responseEvent.packet.value.case).toStrictEqual('rpcResponse'); + + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); }); - // Ensure the first event was for the ack - const ackEvent = await managerEvents.waitFor('sendDataPacket'); - expect(ackEvent.packet.value.case).toStrictEqual('rpcAck'); + it('should catch and transform unhandled errors in the RPC method handler', async () => { + const managerEvents = subscribeToEvents(rpcServerManager, [ + 'sendDataPacket', + ]); - // And the second event was for the response - const responseEvent = await managerEvents.waitFor('sendDataPacket'); - expect(responseEvent.packet.value.case).toStrictEqual('rpcResponse'); + const methodName = 'errorMethod'; + const errorMessage = 'Test error'; + const handler = async () => { + throw new Error(errorMessage); + }; - expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); - }); + rpcServerManager.registerRpcMethod(methodName, handler); + + await rpcServerManager.handleIncomingRpcRequest( + 'remote-identity', + new RpcRequest({ + id: 'test-error-request-id', + method: methodName, + payload: 'test payload', + responseTimeoutMs: 5000, + version: 1, + }), + ); + + // Ensure the first event was for the ack + const ackEvent = await managerEvents.waitFor('sendDataPacket'); + assert(ackEvent.packet.value.case === 'rpcAck'); - it('should catch and transform unhandled errors in the RPC method handler', async () => { - const managerEvents = subscribeToEvents(rpcServerManager, [ - 'sendDataPacket', - ]); + // And the second event was for the error response + const errorEvent = await managerEvents.waitFor('sendDataPacket'); + assert(errorEvent.packet.value.case === 'rpcResponse'); + assert(errorEvent.packet.value.value.value.case === 'error'); + const errorResponse = errorEvent.packet.value.value.value.value; + expect(errorResponse.code).toStrictEqual(RpcError.ErrorCode.APPLICATION_ERROR); - const methodName = 'errorMethod'; - const errorMessage = 'Test error'; - const handler = async () => { - throw new Error(errorMessage); + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + }); + + it('should pass through RpcError thrown by the RPC method handler', async () => { + const managerEvents = subscribeToEvents(rpcServerManager, [ + 'sendDataPacket', + ]); + + const methodName = 'rpcErrorMethod'; + const errorCode = 101; + const errorMessage = 'some-error-message'; + const handler = async () => { + throw new RpcError(errorCode, errorMessage); + }; + + rpcServerManager.registerRpcMethod(methodName, handler); + + await rpcServerManager.handleIncomingRpcRequest( + 'remote-identity', + new RpcRequest({ + id: 'test-rpc-error-request-id', + method: methodName, + payload: 'test payload', + responseTimeoutMs: 5000, + version: 1, + }), + ); + + // Ensure the first event was for the ack + const ackEvent = await managerEvents.waitFor('sendDataPacket'); + assert(ackEvent.packet.value.case === 'rpcAck'); + + // And the second event was for the error response + const errorEvent = await managerEvents.waitFor('sendDataPacket'); + assert(errorEvent.packet.value.case === 'rpcResponse'); + assert(errorEvent.packet.value.value.value.case === 'error'); + const errorResponse = errorEvent.packet.value.value.value.value; + expect(errorResponse.code).toStrictEqual(errorCode); + expect(errorResponse.message).toStrictEqual(errorMessage); + + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + }); + }); + + describe('v2 -> v2', () => { + let rpcServerManager: RpcServerManager; + let outgoingDataStreamManager: OutgoingDataStreamManager; + let mockStreamTextWriter: { + write: ReturnType; + close: ReturnType; }; - rpcServerManager.registerRpcMethod(methodName, handler); + beforeEach(() => { + outgoingDataStreamManager = new OutgoingDataStreamManager( + {} as unknown as RTCEngine, + log, + ); + + mockStreamTextWriter = { + write: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; + vi.spyOn(outgoingDataStreamManager, 'streamText').mockResolvedValue( + mockStreamTextWriter as any, + ); + + rpcServerManager = new RpcServerManager( + log, + outgoingDataStreamManager, + (_identity) => CLIENT_PROTOCOL_DATA_STREAM_RPC, + ); + }); + + function makeDataStreamAttrs(requestId: string, method: string, responseTimeout: number) { + return { + [RPC_REQUEST_ID_ATTR]: requestId, + [RPC_REQUEST_METHOD_ATTR]: method, + [RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR]: `${responseTimeout}`, + [RPC_REQUEST_VERSION_ATTR]: '2', + }; + } + + function mockTextStreamReader(payload: string) { + return { readAll: vi.fn().mockResolvedValue(payload) } as any; + } + + it('should receive a rpc message via data stream from a participant', async () => { + const managerEvents = subscribeToEvents(rpcServerManager, [ + 'sendDataPacket', + ]); - await rpcServerManager.handleIncomingRpcRequest( - 'remote-identity', - new RpcRequest({ - id: 'test-error-request-id', - method: methodName, + const handler = async () => 'response payload'; + rpcServerManager.registerRpcMethod('test-method', handler); + + const requestId = crypto.randomUUID(); + const responseTimeoutMs = 10_000; + await rpcServerManager.handleIncomingDataStream( + mockTextStreamReader('request payload'), + 'caller-identity', + makeDataStreamAttrs(requestId, 'test-method', responseTimeoutMs), + ); + + // The first event is an acknowledgement of the request + const ackEvent = await managerEvents.waitFor('sendDataPacket'); + assert(ackEvent.packet.value.case === 'rpcAck'); + expect(ackEvent.packet.value.value.requestId).toStrictEqual(requestId); + + // The response should have been sent via data stream, not packet + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + expect(outgoingDataStreamManager.streamText).toHaveBeenCalledWith( + expect.objectContaining({ + topic: RPC_DATA_STREAM_TOPIC, + destinationIdentities: ['caller-identity'], + attributes: { [RPC_RESPONSE_ID_ATTR]: requestId }, + }), + ); + expect(mockStreamTextWriter.write).toHaveBeenCalledWith('response payload'); + expect(mockStreamTextWriter.close).toHaveBeenCalled(); + }); + + it('should register an RPC method handler', async () => { + const managerEvents = subscribeToEvents(rpcServerManager, [ + 'sendDataPacket', + ]); + + const methodName = 'testMethod'; + const handler = vi.fn().mockResolvedValue('test response'); + + rpcServerManager.registerRpcMethod(methodName, handler); + + await rpcServerManager.handleIncomingDataStream( + mockTextStreamReader('test payload'), + 'remote-identity', + makeDataStreamAttrs('test-request-id', methodName, 5000), + ); + + expect(handler).toHaveBeenCalledWith({ + requestId: 'test-request-id', + callerIdentity: 'remote-identity', payload: 'test payload', - responseTimeoutMs: 5000, - version: 1, - }), - ); - - // Ensure the first event was for the ack - const ackEvent = await managerEvents.waitFor('sendDataPacket'); - assert(ackEvent.packet.value.case === 'rpcAck'); - - // And the second event was for the error response - const errorEvent = await managerEvents.waitFor('sendDataPacket'); - assert(errorEvent.packet.value.case === 'rpcResponse'); - assert(errorEvent.packet.value.value.value.case === 'error'); - const errorResponse = errorEvent.packet.value.value.value.value; - expect(errorResponse.code).toStrictEqual(RpcError.ErrorCode.APPLICATION_ERROR); - - expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + responseTimeout: 5000, + }); + + // Ensure the ack was sent + const ackEvent = await managerEvents.waitFor('sendDataPacket'); + expect(ackEvent.packet.value.case).toStrictEqual('rpcAck'); + + // Response goes via data stream, not packet + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + expect(outgoingDataStreamManager.streamText).toHaveBeenCalled(); + }); + + it('should catch and transform unhandled errors in the RPC method handler', async () => { + const managerEvents = subscribeToEvents(rpcServerManager, [ + 'sendDataPacket', + ]); + + const methodName = 'errorMethod'; + const errorMessage = 'Test error'; + const handler = async () => { + throw new Error(errorMessage); + }; + + rpcServerManager.registerRpcMethod(methodName, handler); + + await rpcServerManager.handleIncomingDataStream( + mockTextStreamReader('test payload'), + 'remote-identity', + makeDataStreamAttrs('test-error-request-id', methodName, 5000), + ); + + // Ensure the first event was for the ack + const ackEvent = await managerEvents.waitFor('sendDataPacket'); + assert(ackEvent.packet.value.case === 'rpcAck'); + + // Error responses always go via packet, even for v2 callers + const errorEvent = await managerEvents.waitFor('sendDataPacket'); + assert(errorEvent.packet.value.case === 'rpcResponse'); + assert(errorEvent.packet.value.value.value.case === 'error'); + const errorResponse = errorEvent.packet.value.value.value.value; + expect(errorResponse.code).toStrictEqual(RpcError.ErrorCode.APPLICATION_ERROR); + + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + }); + + it('should pass through RpcError thrown by the RPC method handler', async () => { + const managerEvents = subscribeToEvents(rpcServerManager, [ + 'sendDataPacket', + ]); + + const methodName = 'rpcErrorMethod'; + const errorCode = 101; + const errorMessage = 'some-error-message'; + const handler = async () => { + throw new RpcError(errorCode, errorMessage); + }; + + rpcServerManager.registerRpcMethod(methodName, handler); + + await rpcServerManager.handleIncomingDataStream( + mockTextStreamReader('test payload'), + 'remote-identity', + makeDataStreamAttrs('test-rpc-error-request-id', methodName, 5000), + ); + + // Ensure the first event was for the ack + const ackEvent = await managerEvents.waitFor('sendDataPacket'); + assert(ackEvent.packet.value.case === 'rpcAck'); + + // Error responses always go via packet, even for v2 callers + const errorEvent = await managerEvents.waitFor('sendDataPacket'); + assert(errorEvent.packet.value.case === 'rpcResponse'); + assert(errorEvent.packet.value.value.value.case === 'error'); + const errorResponse = errorEvent.packet.value.value.value.value; + expect(errorResponse.code).toStrictEqual(errorCode); + expect(errorResponse.message).toStrictEqual(errorMessage); + + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + }); }); - it('should pass through RpcError thrown by the RPC method handler', async () => { - const managerEvents = subscribeToEvents(rpcServerManager, [ - 'sendDataPacket', - ]); + describe('v1 -> v2', () => { + it('should use v1 protocol (RpcResponse packet) when responding to a v1 caller', async () => { + const outgoingDataStreamManager = new OutgoingDataStreamManager( + {} as unknown as RTCEngine, + log, + ); + const streamTextSpy = vi.spyOn(outgoingDataStreamManager, 'streamText'); - const methodName = 'rpcErrorMethod'; - const errorCode = 101; - const errorMessage = 'some-error-message'; - const handler = async () => { - throw new RpcError(errorCode, errorMessage); - }; + const rpcServerManager = new RpcServerManager( + log, + outgoingDataStreamManager, + (_identity) => CLIENT_PROTOCOL_DEFAULT, + ); - rpcServerManager.registerRpcMethod(methodName, handler); + const managerEvents = subscribeToEvents(rpcServerManager, [ + 'sendDataPacket', + ]); - await rpcServerManager.handleIncomingRpcRequest( - 'remote-identity', - new RpcRequest({ - id: 'test-rpc-error-request-id', - method: methodName, - payload: 'test payload', - responseTimeoutMs: 5000, - version: 1, - }), - ); - - // Ensure the first event was for the ack - const ackEvent = await managerEvents.waitFor('sendDataPacket'); - assert(ackEvent.packet.value.case === 'rpcAck'); - - // And the second event was for the error response - const errorEvent = await managerEvents.waitFor('sendDataPacket'); - assert(errorEvent.packet.value.case === 'rpcResponse'); - assert(errorEvent.packet.value.value.value.case === 'error'); - const errorResponse = errorEvent.packet.value.value.value.value; - expect(errorResponse.code).toStrictEqual(errorCode); - expect(errorResponse.message).toStrictEqual(errorMessage); - - expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + const handler = async () => 'response payload'; + rpcServerManager.registerRpcMethod('test-method', handler); + + const requestId = crypto.randomUUID(); + await rpcServerManager.handleIncomingRpcRequest( + 'v1-caller', + new RpcRequest({ + id: requestId, + method: 'test-method', + payload: 'request payload', + responseTimeoutMs: 10_000, + version: 1, + }), + ); + + // Ack via packet + const ackEvent = await managerEvents.waitFor('sendDataPacket'); + assert(ackEvent.packet.value.case === 'rpcAck'); + + // Response should be a v1 RpcResponse packet, not a data stream + expect(streamTextSpy).not.toHaveBeenCalled(); + const responseEvent = await managerEvents.waitFor('sendDataPacket'); + assert(responseEvent.packet.value.case === 'rpcResponse'); + const rpcResponse = responseEvent.packet.value.value; + expect(rpcResponse.requestId).toStrictEqual(requestId); + assert(rpcResponse.value.case === 'payload'); + expect(rpcResponse.value.value).toStrictEqual('response payload'); + + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + }); }); }); From ff757783a010e6d98a6041d3023720ff41f78eaf Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 12:18:08 -0400 Subject: [PATCH 44/54] fix: update tests to exercise new v1 data streams with long payload path to verify this fails --- src/room/rpc/client/RpcClientManager.test.ts | 89 +++++++++++--------- src/version.ts | 4 +- 2 files changed, 53 insertions(+), 40 deletions(-) diff --git a/src/room/rpc/client/RpcClientManager.test.ts b/src/room/rpc/client/RpcClientManager.test.ts index 19d6220c30..3bff4e1042 100644 --- a/src/room/rpc/client/RpcClientManager.test.ts +++ b/src/room/rpc/client/RpcClientManager.test.ts @@ -33,7 +33,7 @@ describe('RpcClientManager', () => { ); }); - it('should send v1 RPC request to a "legacy" client and receive successful response', async () => { + it('should send v1 RPC request to a "legacy" client (happy path)', async () => { const managerEvents = subscribeToEvents(rpcClientManager, [ 'sendDataPacket', ]); @@ -63,6 +63,18 @@ describe('RpcClientManager', () => { expect(result).toStrictEqual('response payload'); }); + it('should fail to send long (> 15kb) v1 RPC request', async () => { + const longPayload = new Array(20_000).fill('A').join(''); + + const performRpcPromise = rpcClientManager.performRpc({ + destinationIdentity: 'destination-identity', + method: 'test-method', + payload: longPayload, + }); + + await expect(performRpcPromise).rejects.toThrow('Request payload too large'); + }); + it('should handle v1 RPC request timeout', async () => { vi.useFakeTimers(); @@ -149,12 +161,12 @@ describe('RpcClientManager', () => { rpcClientManager = new RpcClientManager( log, mockOutgoingDataStreamManager, - (_identity) => CLIENT_PROTOCOL_DATA_STREAM_RPC, + (_identity) => CLIENT_PROTOCOL_DATA_STREAM_RPC, // (other participant is "v2") () => undefined, ); }); - it('should send v2 RPC request via data stream and receive successful response', async () => { + it('should send short v2 RPC request via data stream (happy path)', async () => { const managerEvents = subscribeToEvents(rpcClientManager, [ 'sendDataPacket', ]); @@ -183,35 +195,53 @@ describe('RpcClientManager', () => { // No packet should have been emitted expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); + // Asynchronously send a response back + await sleep(10); rpcClientManager.handleIncomingRpcAck(requestId); + await sleep(10); rpcClientManager.handleIncomingRpcResponseSuccess(requestId, 'response-payload'); await expect(completionPromise).resolves.toStrictEqual('response-payload'); }); - it('should send RPC request via data stream with delayed ack and response', async () => { - const method = 'testMethod'; - const payload = 'testPayload'; - const responsePayload = 'responsePayload'; + it('should send long (> 15kb) v2 RPC request via data stream', async () => { + const managerEvents = subscribeToEvents(rpcClientManager, [ + 'sendDataPacket', + ]); + + const longPayload = new Array(20_000).fill('A').join(''); const [requestId, completionPromise] = await rpcClientManager.performRpc({ - destinationIdentity: 'remote-identity', - method, - payload, + destinationIdentity: 'destination-identity', + method: 'test-method', + payload: longPayload, }); - setTimeout(() => { - rpcClientManager.handleIncomingRpcAck(requestId); - setTimeout(() => { - rpcClientManager.handleIncomingRpcResponseSuccess(requestId, responsePayload); - }, 10); - }, 10); + // Verify the data stream was used with correct attributes + expect(mockOutgoingDataStreamManager.streamText).toHaveBeenCalledWith( + expect.objectContaining({ + topic: RPC_DATA_STREAM_TOPIC, + destinationIdentities: ['destination-identity'], + attributes: expect.objectContaining({ + [RPC_REQUEST_ID_ATTR]: requestId, + [RPC_REQUEST_METHOD_ATTR]: 'test-method', + [RPC_REQUEST_VERSION_ATTR]: '2', + }), + }), + ); + expect(mockStreamTextWriter.write).toHaveBeenCalledWith(longPayload); + expect(mockStreamTextWriter.close).toHaveBeenCalled(); + + // No packet should have been emitted + expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); - const result = await completionPromise; - expect(result).toStrictEqual(responsePayload); + rpcClientManager.handleIncomingRpcAck(requestId); + rpcClientManager.handleIncomingRpcResponseSuccess(requestId, 'response-payload'); + + await expect(completionPromise).resolves.toStrictEqual('response-payload'); }); - it('should handle RPC request timeout', async () => { + it('should handle a v2 RPC request timeout', async () => { vi.useFakeTimers(); try { @@ -232,7 +262,7 @@ describe('RpcClientManager', () => { } }); - it('should handle RPC error response', async () => { + it('should handle a v2 RPC error response', async () => { const errorCode = 101; const errorMessage = 'Test error message'; @@ -251,7 +281,7 @@ describe('RpcClientManager', () => { await expect(completionPromise).rejects.toThrow(errorMessage); }); - it('should handle participant disconnection during RPC request', async () => { + it('should handle participant disconnection during v2 RPC request', async () => { const [, completionPromise] = await rpcClientManager.performRpc({ destinationIdentity: 'remote-identity', method: 'disconnectMethod', @@ -263,22 +293,5 @@ describe('RpcClientManager', () => { await expect(completionPromise).rejects.toThrow('Recipient disconnected'); }); - - it('should receive response via data stream', async () => { - const [requestId, completionPromise] = await rpcClientManager.performRpc({ - destinationIdentity: 'remote-identity', - method: 'test-method', - payload: 'request-payload', - }); - - rpcClientManager.handleIncomingRpcAck(requestId); - - const mockReader = { - readAll: vi.fn().mockResolvedValue('data-stream-response'), - }; - await rpcClientManager.handleIncomingDataStream(mockReader as any, requestId); - - await expect(completionPromise).resolves.toStrictEqual('data-stream-response'); - }); }); }); diff --git a/src/version.ts b/src/version.ts index 096e8faab7..ea24368829 100644 --- a/src/version.ts +++ b/src/version.ts @@ -5,8 +5,8 @@ export const protocolVersion = 16; /** Initial client protocol. */ export const CLIENT_PROTOCOL_DEFAULT = 0; -/** Replaces RPC v1 protocol with a data streams based one to support unlimited request / response - * payload length. */ +/** Replaces RPC v1 protocol with a v2 data streams based one to support unlimited request / + * response payload length. */ export const CLIENT_PROTOCOL_DATA_STREAM_RPC = 1; /** The client protocol version indicates what level of support that the client has for From 241bbe529a1c00c83e893e473d87c5359b7ac616 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 12:18:45 -0400 Subject: [PATCH 45/54] fix: make rpc example payload size longer --- examples/rpc/rpc-demo.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/rpc/rpc-demo.ts b/examples/rpc/rpc-demo.ts index d2d5d0ea30..95ab445e9a 100644 --- a/examples/rpc/rpc-demo.ts +++ b/examples/rpc/rpc-demo.ts @@ -100,7 +100,7 @@ const registerReceiverMethods = async (greetersRoom: Room, mathGeniusRoom: Room) `[Greeter] ${data.callerIdentity} has arrived and said that its long info is "${data.payload}"`, ); await new Promise((resolve) => setTimeout(resolve, 2000)); - return new Array(10_000).fill('Y').join(''); + return new Array(20_000).fill('Y').join(''); }, ); @@ -161,7 +161,7 @@ const performSendVeryLongInfo = async (room: Room): Promise => { const response = await room.localParticipant.performRpc({ destinationIdentity: 'greeter', method: 'exchanging-long-info', - payload: new Array(10_000).fill('X').join(''), + payload: new Array(20_000).fill('X').join(''), }); console.log(`[Caller] The greeter's long info is: "${response}"`); } catch (error) { From 0632d407a7f42a1bd4749cb4ba88c5a2cffe8439 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 12:26:37 -0400 Subject: [PATCH 46/54] fix: run npm run format --- src/room/rpc/server/RpcServerManager.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/room/rpc/server/RpcServerManager.test.ts b/src/room/rpc/server/RpcServerManager.test.ts index 2931026aab..453c413695 100644 --- a/src/room/rpc/server/RpcServerManager.test.ts +++ b/src/room/rpc/server/RpcServerManager.test.ts @@ -198,10 +198,7 @@ describe('RpcServerManager', () => { }; beforeEach(() => { - outgoingDataStreamManager = new OutgoingDataStreamManager( - {} as unknown as RTCEngine, - log, - ); + outgoingDataStreamManager = new OutgoingDataStreamManager({} as unknown as RTCEngine, log); mockStreamTextWriter = { write: vi.fn().mockResolvedValue(undefined), From 4ccc761a13630851af806434852ac763867f4362 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 13:16:23 -0400 Subject: [PATCH 47/54] feat: make a few small changes to the rpc v2 protocol 1. Break out request / responses onto seperate topics 2. Get rid of distinct RPC_RESPONSE_ID_ATTR --- src/room/Room.ts | 21 ++++++++++---------- src/room/rpc/client/RpcClientManager.test.ts | 6 +++--- src/room/rpc/client/RpcClientManager.ts | 19 +++++++++++++----- src/room/rpc/index.ts | 5 +++-- src/room/rpc/server/RpcServerManager.test.ts | 7 +++---- src/room/rpc/server/RpcServerManager.ts | 7 +++---- src/room/rpc/utils.ts | 15 ++++++-------- 7 files changed, 43 insertions(+), 37 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index c7e3f6ff17..d2bcbde39b 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -80,8 +80,8 @@ import Participant from './participant/Participant'; import { type ConnectionQuality, ParticipantKind } from './participant/Participant'; import RemoteParticipant from './participant/RemoteParticipant'; import { - RPC_DATA_STREAM_TOPIC, - RPC_RESPONSE_ID_ATTR, + RPC_REQUEST_DATA_STREAM_TOPIC, + RPC_RESPONSE_DATA_STREAM_TOPIC, RpcClientManager, RpcError, type RpcInvocationData, @@ -2399,16 +2399,17 @@ class Room extends (EventEmitter as new () => TypedEmitter) private registerRpcDataStreamHandler() { this.incomingDataStreamManager.registerTextStreamHandler( - RPC_DATA_STREAM_TOPIC, + RPC_REQUEST_DATA_STREAM_TOPIC, async (reader, { identity }) => { const attributes = reader.info.attributes ?? {}; - const responseId = attributes[RPC_RESPONSE_ID_ATTR]; - - if (responseId) { - await this.rpcClientManager.handleIncomingDataStream(reader, responseId); - } else { - await this.rpcServerManager.handleIncomingDataStream(reader, identity, attributes); - } + await this.rpcServerManager.handleIncomingDataStream(reader, identity, attributes); + }, + ); + this.incomingDataStreamManager.registerTextStreamHandler( + RPC_RESPONSE_DATA_STREAM_TOPIC, + async (reader) => { + const attributes = reader.info.attributes ?? {}; + await this.rpcClientManager.handleIncomingDataStream(reader, attributes); }, ); } diff --git a/src/room/rpc/client/RpcClientManager.test.ts b/src/room/rpc/client/RpcClientManager.test.ts index 3bff4e1042..5aaf8b17e6 100644 --- a/src/room/rpc/client/RpcClientManager.test.ts +++ b/src/room/rpc/client/RpcClientManager.test.ts @@ -6,7 +6,7 @@ import type RTCEngine from '../../RTCEngine'; import OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; import { sleep } from '../../utils'; import { - RPC_DATA_STREAM_TOPIC, + RPC_REQUEST_DATA_STREAM_TOPIC, RPC_REQUEST_ID_ATTR, RPC_REQUEST_METHOD_ATTR, RPC_REQUEST_VERSION_ATTR, @@ -180,7 +180,7 @@ describe('RpcClientManager', () => { // Verify the data stream was used with correct attributes expect(mockOutgoingDataStreamManager.streamText).toHaveBeenCalledWith( expect.objectContaining({ - topic: RPC_DATA_STREAM_TOPIC, + topic: RPC_REQUEST_DATA_STREAM_TOPIC, destinationIdentities: ['destination-identity'], attributes: expect.objectContaining({ [RPC_REQUEST_ID_ATTR]: requestId, @@ -220,7 +220,7 @@ describe('RpcClientManager', () => { // Verify the data stream was used with correct attributes expect(mockOutgoingDataStreamManager.streamText).toHaveBeenCalledWith( expect.objectContaining({ - topic: RPC_DATA_STREAM_TOPIC, + topic: RPC_REQUEST_DATA_STREAM_TOPIC, destinationIdentities: ['destination-identity'], attributes: expect.objectContaining({ [RPC_REQUEST_ID_ATTR]: requestId, diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index 8cd8c7730b..a3a68e31aa 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -10,7 +10,7 @@ import { Future, compareVersions } from '../../utils'; import { MAX_V1_PAYLOAD_BYTES, type PerformRpcParams, - RPC_DATA_STREAM_TOPIC, + RPC_REQUEST_DATA_STREAM_TOPIC, RPC_REQUEST_ID_ATTR, RPC_REQUEST_METHOD_ATTR, RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR, @@ -141,7 +141,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm if (remoteClientProtocol >= CLIENT_PROTOCOL_DATA_STREAM_RPC) { // Send payload as a data stream - a "version 2" rpc request. const writer = await this.outgoingDataStreamManager.streamText({ - topic: RPC_DATA_STREAM_TOPIC, + topic: RPC_REQUEST_DATA_STREAM_TOPIC, destinationIdentities: [destinationIdentity], attributes: { [RPC_REQUEST_ID_ATTR]: requestId, @@ -179,17 +179,26 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm * Handle an incoming data stream containing an RPC response payload. * @internal */ - async handleIncomingDataStream(reader: TextStreamReader, responseId: string) { + async handleIncomingDataStream(reader: TextStreamReader, attributes: Record) { + const associatedRequestId = attributes[RPC_REQUEST_ID_ATTR]; + if (!associatedRequestId) { + this.log.warn( + `RPC data stream malformed: ${RPC_REQUEST_ID_ATTR} not set.`, + ); + this.handleIncomingRpcResponseFailure(associatedRequestId, RpcError.builtIn('APPLICATION_ERROR')); + return; + } + let payload: string; try { payload = await reader.readAll(); } catch (e) { this.log.warn(`Error reading RPC response payload: ${e}`); - this.handleIncomingRpcResponseFailure(responseId, RpcError.builtIn('APPLICATION_ERROR')); + this.handleIncomingRpcResponseFailure(associatedRequestId, RpcError.builtIn('APPLICATION_ERROR')); return; } - this.handleIncomingRpcResponseSuccess(responseId, payload); + this.handleIncomingRpcResponseSuccess(associatedRequestId, payload); } /** @internal */ diff --git a/src/room/rpc/index.ts b/src/room/rpc/index.ts index c30f833455..71574644ce 100644 --- a/src/room/rpc/index.ts +++ b/src/room/rpc/index.ts @@ -7,8 +7,9 @@ export { default as RpcServerManager } from './server/RpcServerManager'; export type { RpcServerManagerCallbacks } from './server/events'; export { type PerformRpcParams, - RPC_DATA_STREAM_TOPIC, - RPC_RESPONSE_ID_ATTR, + RPC_REQUEST_DATA_STREAM_TOPIC, + RPC_RESPONSE_DATA_STREAM_TOPIC, + RPC_REQUEST_ID_ATTR, RpcError, type RpcInvocationData, byteLength, diff --git a/src/room/rpc/server/RpcServerManager.test.ts b/src/room/rpc/server/RpcServerManager.test.ts index 453c413695..81bd33a096 100644 --- a/src/room/rpc/server/RpcServerManager.test.ts +++ b/src/room/rpc/server/RpcServerManager.test.ts @@ -6,12 +6,11 @@ import { CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT } from '../../ import type RTCEngine from '../../RTCEngine'; import OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; import { - RPC_DATA_STREAM_TOPIC, + RPC_RESPONSE_DATA_STREAM_TOPIC, RPC_REQUEST_ID_ATTR, RPC_REQUEST_METHOD_ATTR, RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR, RPC_REQUEST_VERSION_ATTR, - RPC_RESPONSE_ID_ATTR, RpcError, } from '../utils'; import RpcServerManager from './RpcServerManager'; @@ -253,9 +252,9 @@ describe('RpcServerManager', () => { expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); expect(outgoingDataStreamManager.streamText).toHaveBeenCalledWith( expect.objectContaining({ - topic: RPC_DATA_STREAM_TOPIC, + topic: RPC_RESPONSE_DATA_STREAM_TOPIC, destinationIdentities: ['caller-identity'], - attributes: { [RPC_RESPONSE_ID_ATTR]: requestId }, + attributes: { [RPC_REQUEST_ID_ATTR]: requestId }, }), ); expect(mockStreamTextWriter.write).toHaveBeenCalledWith('response payload'); diff --git a/src/room/rpc/server/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts index 4d21547057..d11c43d36d 100644 --- a/src/room/rpc/server/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -8,12 +8,11 @@ import type OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingD import type Participant from '../../participant/Participant'; import { MAX_V1_PAYLOAD_BYTES, - RPC_DATA_STREAM_TOPIC, + RPC_RESPONSE_DATA_STREAM_TOPIC, RPC_REQUEST_ID_ATTR, RPC_REQUEST_METHOD_ATTR, RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR, RPC_REQUEST_VERSION_ATTR, - RPC_RESPONSE_ID_ATTR, RpcError, type RpcInvocationData, byteLength, @@ -258,9 +257,9 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm if (callerClientProtocol >= CLIENT_PROTOCOL_DATA_STREAM_RPC) { // Send response as a data stream const writer = await this.outgoingDataStreamManager.streamText({ - topic: RPC_DATA_STREAM_TOPIC, + topic: RPC_RESPONSE_DATA_STREAM_TOPIC, destinationIdentities: [destinationIdentity], - attributes: { [RPC_RESPONSE_ID_ATTR]: requestId }, + attributes: { [RPC_REQUEST_ID_ATTR]: requestId }, }); await writer.write(payload); await writer.close(); diff --git a/src/room/rpc/utils.ts b/src/room/rpc/utils.ts index 3b83337574..d954e194b5 100644 --- a/src/room/rpc/utils.ts +++ b/src/room/rpc/utils.ts @@ -148,9 +148,12 @@ export class RpcError extends Error { export const MAX_V1_PAYLOAD_BYTES = 15360; // 15 KB /** - * Attribute key set on a data stream to associate it with an RPC request. + * Topic used for v2 RPC request data streams. * @internal */ +export const RPC_REQUEST_DATA_STREAM_TOPIC = 'lk.rpc_request'; + +/** @internal */ export const RPC_REQUEST_ID_ATTR = 'lk.rpc_request_id'; /** @internal */ @@ -163,16 +166,10 @@ export const RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR = 'lk.rpc_request_response_tim export const RPC_REQUEST_VERSION_ATTR = 'lk.rpc_request_version'; /** - * Attribute key set on a data stream to associate it with an RPC response. - * @internal - */ -export const RPC_RESPONSE_ID_ATTR = 'lk.rpc_response_id'; - -/** - * Topic used for RPC payload data streams. + * Topic used for v2 RPC request data streams. * @internal */ -export const RPC_DATA_STREAM_TOPIC = 'lk.rpc_payload'; +export const RPC_RESPONSE_DATA_STREAM_TOPIC = 'lk.rpc_response'; /** * @internal From d9e1e6e50c34d4c56146961da1201257a1080133 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 13:34:30 -0400 Subject: [PATCH 48/54] fix: npm run format --- src/room/rpc/client/RpcClientManager.ts | 12 ++++++++---- src/room/rpc/server/RpcServerManager.test.ts | 2 +- src/room/rpc/server/RpcServerManager.ts | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index a3a68e31aa..43fe028677 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -182,10 +182,11 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm async handleIncomingDataStream(reader: TextStreamReader, attributes: Record) { const associatedRequestId = attributes[RPC_REQUEST_ID_ATTR]; if (!associatedRequestId) { - this.log.warn( - `RPC data stream malformed: ${RPC_REQUEST_ID_ATTR} not set.`, + this.log.warn(`RPC data stream malformed: ${RPC_REQUEST_ID_ATTR} not set.`); + this.handleIncomingRpcResponseFailure( + associatedRequestId, + RpcError.builtIn('APPLICATION_ERROR'), ); - this.handleIncomingRpcResponseFailure(associatedRequestId, RpcError.builtIn('APPLICATION_ERROR')); return; } @@ -194,7 +195,10 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm payload = await reader.readAll(); } catch (e) { this.log.warn(`Error reading RPC response payload: ${e}`); - this.handleIncomingRpcResponseFailure(associatedRequestId, RpcError.builtIn('APPLICATION_ERROR')); + this.handleIncomingRpcResponseFailure( + associatedRequestId, + RpcError.builtIn('APPLICATION_ERROR'), + ); return; } diff --git a/src/room/rpc/server/RpcServerManager.test.ts b/src/room/rpc/server/RpcServerManager.test.ts index 81bd33a096..e5742f20bd 100644 --- a/src/room/rpc/server/RpcServerManager.test.ts +++ b/src/room/rpc/server/RpcServerManager.test.ts @@ -6,11 +6,11 @@ import { CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT } from '../../ import type RTCEngine from '../../RTCEngine'; import OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; import { - RPC_RESPONSE_DATA_STREAM_TOPIC, RPC_REQUEST_ID_ATTR, RPC_REQUEST_METHOD_ATTR, RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR, RPC_REQUEST_VERSION_ATTR, + RPC_RESPONSE_DATA_STREAM_TOPIC, RpcError, } from '../utils'; import RpcServerManager from './RpcServerManager'; diff --git a/src/room/rpc/server/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts index d11c43d36d..c5c33187d9 100644 --- a/src/room/rpc/server/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -8,11 +8,11 @@ import type OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingD import type Participant from '../../participant/Participant'; import { MAX_V1_PAYLOAD_BYTES, - RPC_RESPONSE_DATA_STREAM_TOPIC, RPC_REQUEST_ID_ATTR, RPC_REQUEST_METHOD_ATTR, RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR, RPC_REQUEST_VERSION_ATTR, + RPC_RESPONSE_DATA_STREAM_TOPIC, RpcError, type RpcInvocationData, byteLength, From 0ac0561c9d123065c4c08d33f56e91b1b7840254 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 13:36:14 -0400 Subject: [PATCH 49/54] fix: add missing changeset --- .changeset/chubby-buckets-drop.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chubby-buckets-drop.md diff --git a/.changeset/chubby-buckets-drop.md b/.changeset/chubby-buckets-drop.md new file mode 100644 index 0000000000..a5678201c5 --- /dev/null +++ b/.changeset/chubby-buckets-drop.md @@ -0,0 +1,5 @@ +--- +'livekit-client': patch +--- + +Add new RPC protocol updates to support infinite payload length in requests / responses From 0dd63ca1eb36c53d985989edd1491f74ce9228c9 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 13:42:08 -0400 Subject: [PATCH 50/54] fix: remove more LLM added copyright notices --- src/room/rpc/client/events.ts | 3 --- src/room/rpc/index.ts | 3 --- src/room/rpc/server/events.ts | 3 --- src/room/rpc/utils.ts | 3 --- 4 files changed, 12 deletions(-) diff --git a/src/room/rpc/client/events.ts b/src/room/rpc/client/events.ts index 3f2a60fd4d..427fedef75 100644 --- a/src/room/rpc/client/events.ts +++ b/src/room/rpc/client/events.ts @@ -1,6 +1,3 @@ -// SPDX-FileCopyrightText: 2026 LiveKit, Inc. -// -// SPDX-License-Identifier: Apache-2.0 import type { DataPacket } from '@livekit/protocol'; export type EventSendDataPacket = { diff --git a/src/room/rpc/index.ts b/src/room/rpc/index.ts index 71574644ce..368eface8d 100644 --- a/src/room/rpc/index.ts +++ b/src/room/rpc/index.ts @@ -1,6 +1,3 @@ -// SPDX-FileCopyrightText: 2026 LiveKit, Inc. -// -// SPDX-License-Identifier: Apache-2.0 export { default as RpcClientManager } from './client/RpcClientManager'; export type { RpcClientManagerCallbacks } from './client/events'; export { default as RpcServerManager } from './server/RpcServerManager'; diff --git a/src/room/rpc/server/events.ts b/src/room/rpc/server/events.ts index 0e685c8b03..913c935e44 100644 --- a/src/room/rpc/server/events.ts +++ b/src/room/rpc/server/events.ts @@ -1,6 +1,3 @@ -// SPDX-FileCopyrightText: 2026 LiveKit, Inc. -// -// SPDX-License-Identifier: Apache-2.0 import type { DataPacket } from '@livekit/protocol'; export type EventSendDataPacket = { diff --git a/src/room/rpc/utils.ts b/src/room/rpc/utils.ts index d954e194b5..11d0b69100 100644 --- a/src/room/rpc/utils.ts +++ b/src/room/rpc/utils.ts @@ -1,6 +1,3 @@ -// SPDX-FileCopyrightText: 2024 LiveKit, Inc. -// -// SPDX-License-Identifier: Apache-2.0 import { RpcError as RpcError_Proto } from '@livekit/protocol'; /** Parameters for initiating an RPC call */ From a4ed27d9be95d7fd93cee43949ca03fe10bb9c81 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 13:59:05 -0400 Subject: [PATCH 51/54] refactor: reorganize some code / cosmetic changes --- src/room/rpc/client/RpcClientManager.ts | 4 ++-- src/room/rpc/server/RpcServerManager.ts | 2 ++ src/room/rpc/utils.ts | 12 ++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index 43fe028677..1a15c04f24 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -62,14 +62,14 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm method, payload, responseTimeout: responseTimeoutMs = 15000, - }: PerformRpcParams): Promise<[string /* id */, Promise]> { + }: PerformRpcParams): Promise<[id: string, completionPromise: Promise]> { const maxRoundTripLatencyMs = 7000; const minEffectiveTimeoutMs = maxRoundTripLatencyMs + 1000; const remoteClientProtocol = this.getRemoteParticipantClientProtocol(destinationIdentity); const payloadBytes = byteLength(payload); - // Only enforce the legacy size limit when compression is not available + // Only enforce the legacy size limit when on rpc v1 if (payloadBytes > MAX_V1_PAYLOAD_BYTES && remoteClientProtocol < 1) { throw RpcError.builtIn('REQUEST_PAYLOAD_TOO_LARGE'); } diff --git a/src/room/rpc/server/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts index c5c33187d9..166b8e696a 100644 --- a/src/room/rpc/server/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -60,6 +60,7 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm /** * Handle an incoming RPCRequest message containing a payload. * This handles "version 1" of rpc requests. + * @internal */ async handleIncomingRpcRequest(callerIdentity: string, rpcRequest: RpcRequest) { this.publishRpcAck(callerIdentity, rpcRequest.id); @@ -116,6 +117,7 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm /** * Handle an incoming data stream containing a RPC request payload. * This handles "version 2" of rpc requests. + * @internal */ async handleIncomingDataStream( reader: TextStreamReader, diff --git a/src/room/rpc/utils.ts b/src/room/rpc/utils.ts index 11d0b69100..e817f4612a 100644 --- a/src/room/rpc/utils.ts +++ b/src/room/rpc/utils.ts @@ -150,6 +150,12 @@ export const MAX_V1_PAYLOAD_BYTES = 15360; // 15 KB */ export const RPC_REQUEST_DATA_STREAM_TOPIC = 'lk.rpc_request'; +/** + * Topic used for v2 RPC response data streams. + * @internal + */ +export const RPC_RESPONSE_DATA_STREAM_TOPIC = 'lk.rpc_response'; + /** @internal */ export const RPC_REQUEST_ID_ATTR = 'lk.rpc_request_id'; @@ -162,12 +168,6 @@ export const RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR = 'lk.rpc_request_response_tim /** @internal */ export const RPC_REQUEST_VERSION_ATTR = 'lk.rpc_request_version'; -/** - * Topic used for v2 RPC request data streams. - * @internal - */ -export const RPC_RESPONSE_DATA_STREAM_TOPIC = 'lk.rpc_response'; - /** * @internal */ From 07ff61a68d02e61934713a311aa098a82e6ee77b Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 14:12:55 -0400 Subject: [PATCH 52/54] feat: stop checking in rpc-benchmark --- examples/rpc-benchmark/README.md | 39 - examples/rpc-benchmark/api.ts | 39 - examples/rpc-benchmark/index.html | 113 -- examples/rpc-benchmark/package.json | 26 - examples/rpc-benchmark/pnpm-lock.yaml | 2446 ----------------------- examples/rpc-benchmark/rpc-benchmark.ts | 471 ----- examples/rpc-benchmark/styles.css | 231 --- examples/rpc-benchmark/test-data.ts | 93 - examples/rpc-benchmark/tsconfig.json | 20 - examples/rpc-benchmark/vite.config.js | 10 - 10 files changed, 3488 deletions(-) delete mode 100644 examples/rpc-benchmark/README.md delete mode 100644 examples/rpc-benchmark/api.ts delete mode 100644 examples/rpc-benchmark/index.html delete mode 100644 examples/rpc-benchmark/package.json delete mode 100644 examples/rpc-benchmark/pnpm-lock.yaml delete mode 100644 examples/rpc-benchmark/rpc-benchmark.ts delete mode 100644 examples/rpc-benchmark/styles.css delete mode 100644 examples/rpc-benchmark/test-data.ts delete mode 100644 examples/rpc-benchmark/tsconfig.json delete mode 100644 examples/rpc-benchmark/vite.config.js diff --git a/examples/rpc-benchmark/README.md b/examples/rpc-benchmark/README.md deleted file mode 100644 index 18e37ea29b..0000000000 --- a/examples/rpc-benchmark/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# RPC Benchmark - -Stress test for LiveKit RPC with configurable payload sizes. Exercises both RPC transport paths: - -| Path | Payload Size | Description | -|------|-------------|-------------| -| Compressed | < 15 KB | Gzip-compressed inline payload | -| Data Stream | >= 15 KB | Gzip-compressed via one-time data stream | - -## Setup - -1. Create a `.env.local` in this directory: - ``` - LIVEKIT_API_KEY=your-api-key - LIVEKIT_API_SECRET=your-api-secret - LIVEKIT_URL=wss://your-livekit-server.example.com - ``` - -2. Install and run: - ```bash - pnpm install - pnpm dev - ``` - -3. Open the URL shown by Vite (typically `http://localhost:5173`). - -## Usage - -1. Configure benchmark parameters in the UI: - - **Payload Size**: use presets or enter a custom byte count - - **Duration**: how long the benchmark runs (seconds) - - **Concurrent Callers**: number of parallel async caller "threads" - - **Delay Between Calls**: ms to wait between each call per thread - -2. Click **Run Benchmark**. The page connects a caller and receiver to the same room, then the caller fires RPCs and verifies round-trip integrity via checksum. - -3. Live stats update every 500ms. Click **Stop** to end early. - -Everything runs in a single browser tab — no need for multiple tabs. diff --git a/examples/rpc-benchmark/api.ts b/examples/rpc-benchmark/api.ts deleted file mode 100644 index fabd2d243f..0000000000 --- a/examples/rpc-benchmark/api.ts +++ /dev/null @@ -1,39 +0,0 @@ -import dotenv from 'dotenv'; -import express from 'express'; -import { AccessToken } from 'livekit-server-sdk'; -import type { Express } from 'express'; - -dotenv.config({ path: '.env.local' }); - -const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY; -const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET; -const LIVEKIT_URL = process.env.LIVEKIT_URL; - -const app = express(); -app.use(express.json()); - -app.post('/api/get-token', async (req, res) => { - const { identity, roomName } = req.body; - - if (!LIVEKIT_API_KEY || !LIVEKIT_API_SECRET) { - res.status(500).json({ error: 'Server misconfigured' }); - return; - } - - const token = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { - identity, - }); - token.addGrant({ - room: roomName, - roomJoin: true, - canPublish: true, - canSubscribe: true, - }); - - res.json({ - token: await token.toJwt(), - url: LIVEKIT_URL, - }); -}); - -export const handler: Express = app; diff --git a/examples/rpc-benchmark/index.html b/examples/rpc-benchmark/index.html deleted file mode 100644 index f0cb2a52f8..0000000000 --- a/examples/rpc-benchmark/index.html +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - LiveKit RPC Benchmark - - - -
-
-

LiveKit RPC Benchmark

-
- For informational use only - - -
-
- -
-
-
- - -
- - - - -
-
-
- - -
-
- - -
-
- - -
-
-
- - - - - - -
-

Log

- -
-
- - - diff --git a/examples/rpc-benchmark/package.json b/examples/rpc-benchmark/package.json deleted file mode 100644 index 459d0abcb4..0000000000 --- a/examples/rpc-benchmark/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "livekit-rpc-benchmark", - "version": "1.0.0", - "description": "Benchmark for LiveKit RPC with varying payload sizes", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview" - }, - "dependencies": { - "cors": "^2.8.5", - "dotenv": "^16.4.5", - "express": "^5.2.1", - "livekit-server-sdk": "^2.7.0", - "vite": "^5.4.21", - "vite-plugin-mix": "^0.4.0" - }, - "devDependencies": { - "@types/cors": "^2.8.17", - "@types/express": "^5.0.0", - "concurrently": "^8.2.0", - "tsx": "^4.7.0", - "typescript": "^5.4.5" - } -} diff --git a/examples/rpc-benchmark/pnpm-lock.yaml b/examples/rpc-benchmark/pnpm-lock.yaml deleted file mode 100644 index d8acbb612b..0000000000 --- a/examples/rpc-benchmark/pnpm-lock.yaml +++ /dev/null @@ -1,2446 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - cors: - specifier: ^2.8.5 - version: 2.8.6 - dotenv: - specifier: ^16.4.5 - version: 16.6.1 - express: - specifier: ^5.2.1 - version: 5.2.1 - livekit-server-sdk: - specifier: ^2.7.0 - version: 2.15.0 - vite: - specifier: ^5.4.21 - version: 5.4.21(@types/node@25.4.0) - vite-plugin-mix: - specifier: ^0.4.0 - version: 0.4.0(vite@5.4.21(@types/node@25.4.0)) - devDependencies: - '@types/cors': - specifier: ^2.8.17 - version: 2.8.19 - '@types/express': - specifier: ^5.0.0 - version: 5.0.6 - concurrently: - specifier: ^8.2.0 - version: 8.2.2 - tsx: - specifier: ^4.7.0 - version: 4.21.0 - typescript: - specifier: ^5.4.5 - version: 5.9.3 - -packages: - - '@babel/runtime@7.28.6': - resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} - engines: {node: '>=6.9.0'} - - '@bufbuild/protobuf@1.10.1': - resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==} - - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@livekit/protocol@1.45.0': - resolution: {integrity: sha512-z22Ej7RRBFm5uVZpU7kBHOdDwZV6Hz+1crCOrse2g7yx8TcHXG0bKnOKwyN/meD233nEDlU2IHNCoT8Vq8lvtg==} - - '@rollup/rollup-android-arm-eabi@4.59.0': - resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.59.0': - resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.59.0': - resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.59.0': - resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.59.0': - resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.59.0': - resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.59.0': - resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.59.0': - resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.59.0': - resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loong64-musl@4.59.0': - resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-ppc64-musl@4.59.0': - resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.59.0': - resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.59.0': - resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.59.0': - resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.59.0': - resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openbsd-x64@4.59.0': - resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.59.0': - resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.59.0': - resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.59.0': - resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.59.0': - resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.59.0': - resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} - cpu: [x64] - os: [win32] - - '@types/body-parser@1.19.6': - resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - - '@types/cors@2.8.19': - resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/express-serve-static-core@5.1.1': - resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} - - '@types/express@5.0.6': - resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} - - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - - '@types/node@25.4.0': - resolution: {integrity: sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==} - - '@types/qs@6.15.0': - resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} - - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - - '@types/send@1.2.1': - resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - - '@types/serve-static@2.2.0': - resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} - - '@vercel/nft@0.10.1': - resolution: {integrity: sha512-xhINCdohfeWg/70QLs3De/rfNFcO2+Sw4tL9oqgFl4zQzhogT3q0MjH6Hda5uM2KuFGndRPs6VkKJphAhWmymg==} - hasBin: true - - abbrev@1.1.1: - resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} - - accepts@2.0.0: - resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} - engines: {node: '>= 0.6'} - - acorn-class-fields@1.0.0: - resolution: {integrity: sha512-l+1FokF34AeCXGBHkrXFmml9nOIRI+2yBnBpO5MaVAaTIJ96irWLtcCxX+7hAp6USHFCe+iyyBB4ZhxV807wmA==} - engines: {node: '>=4.8.2'} - peerDependencies: - acorn: ^6 || ^7 || ^8 - - acorn-private-class-elements@1.0.0: - resolution: {integrity: sha512-zYNcZtxKgVCg1brS39BEou86mIao1EV7eeREG+6WMwKbuYTeivRRs6S2XdWnboRde6G9wKh2w+WBydEyJsJ6mg==} - engines: {node: '>=4.8.2'} - peerDependencies: - acorn: ^6.1.0 || ^7 || ^8 - - acorn-static-class-features@1.0.0: - resolution: {integrity: sha512-XZJECjbmMOKvMHiNzbiPXuXpLAJfN3dAKtfIYbk1eHiWdsutlek+gS7ND4B8yJ3oqvHo1NxfafnezVmq7NXK0A==} - engines: {node: '>=4.8.2'} - peerDependencies: - acorn: ^6.1.0 || ^7 || ^8 - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - ansi-regex@2.1.1: - resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} - engines: {node: '>=0.10.0'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - aproba@1.2.0: - resolution: {integrity: sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==} - - are-we-there-yet@1.1.7: - resolution: {integrity: sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==} - deprecated: This package is no longer supported. - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - bindings@1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} - engines: {node: '>=18'} - - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - camelcase-keys@9.1.3: - resolution: {integrity: sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==} - engines: {node: '>=16'} - - camelcase@8.0.0: - resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} - engines: {node: '>=16'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - code-point-at@1.1.0: - resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==} - engines: {node: '>=0.10.0'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - concurrently@8.2.2: - resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} - engines: {node: ^14.13.0 || >=16.0.0} - hasBin: true - - console-control-strings@1.1.0: - resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} - - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} - engines: {node: '>=18'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - - cookie-signature@1.2.2: - resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} - engines: {node: '>=6.6.0'} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - - date-fns@2.30.0: - resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} - engines: {node: '>=0.11'} - - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - - delegates@1.0.0: - resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} - - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - - detect-libc@1.0.3: - resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} - engines: {node: '>=0.10'} - hasBin: true - - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} - engines: {node: '>=12'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - estree-walker@0.6.1: - resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} - - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} - engines: {node: '>= 18'} - - file-uri-to-path@1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - finalhandler@2.1.1: - resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} - engines: {node: '>= 18.0.0'} - - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - - fresh@2.0.0: - resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} - engines: {node: '>= 0.8'} - - fs-minipass@1.2.7: - resolution: {integrity: sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==} - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - gauge@2.7.4: - resolution: {integrity: sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==} - deprecated: This package is no longer supported. - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-tsconfig@4.13.6: - resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} - - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-unicode@2.0.1: - resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} - engines: {node: '>= 0.8'} - - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - - ignore-walk@3.0.4: - resolution: {integrity: sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - - is-fullwidth-code-point@1.0.0: - resolution: {integrity: sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-promise@4.0.0: - resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - - isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - - jose@5.10.0: - resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} - - livekit-server-sdk@2.15.0: - resolution: {integrity: sha512-HmzjWnwEwwShu8yUf7VGFXdc+BuMJR5pnIY4qsdlhqI9d9wDgq+4cdTEHg0NEBaiGnc6PCOBiaTYgmIyVJ0S9w==} - engines: {node: '>=18'} - - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - - map-obj@5.0.0: - resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} - engines: {node: '>= 0.8'} - - merge-descriptors@2.0.0: - resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} - engines: {node: '>=18'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} - engines: {node: '>= 0.6'} - - mime-types@3.0.2: - resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} - engines: {node: '>=18'} - - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - minipass@2.9.0: - resolution: {integrity: sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==} - - minizlib@1.3.3: - resolution: {integrity: sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==} - - mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - needle@2.9.1: - resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==} - engines: {node: '>= 4.4.x'} - hasBin: true - - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} - - node-gyp-build@4.8.4: - resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} - hasBin: true - - node-pre-gyp@0.13.0: - resolution: {integrity: sha512-Md1D3xnEne8b/HGVQkZZwV27WUi1ZRuZBij24TNaZwUPU3ZAFtvT6xxJGaUVillfmMKnn5oD1HoGsp2Ftik7SQ==} - deprecated: 'Please upgrade to @mapbox/node-pre-gyp: the non-scoped node-pre-gyp package is deprecated and only the @mapbox scoped package will recieve updates in the future' - hasBin: true - - nopt@4.0.3: - resolution: {integrity: sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==} - hasBin: true - - npm-bundled@1.1.2: - resolution: {integrity: sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ==} - - npm-normalize-package-bin@1.0.1: - resolution: {integrity: sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==} - - npm-packlist@1.4.8: - resolution: {integrity: sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==} - - npmlog@4.1.2: - resolution: {integrity: sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==} - deprecated: This package is no longer supported. - - number-is-nan@1.0.1: - resolution: {integrity: sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==} - engines: {node: '>=0.10.0'} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - os-homedir@1.0.2: - resolution: {integrity: sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==} - engines: {node: '>=0.10.0'} - - os-tmpdir@1.0.2: - resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} - engines: {node: '>=0.10.0'} - - osenv@0.1.5: - resolution: {integrity: sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==} - deprecated: This package is no longer supported. - - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} - engines: {node: ^10 || ^12 || >=14} - - process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - - qs@6.15.0: - resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} - engines: {node: '>=0.6'} - - quick-lru@6.1.2: - resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} - engines: {node: '>=12'} - - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} - - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - - readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - - rimraf@2.7.1: - resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - - rollup-pluginutils@2.8.2: - resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} - - rollup@4.59.0: - resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} - engines: {node: '>= 18'} - - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - sax@1.5.0: - resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==} - engines: {node: '>=11.0.0'} - - semver@5.7.2: - resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} - hasBin: true - - send@1.2.1: - resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} - engines: {node: '>= 18'} - - serve-static@2.2.1: - resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} - engines: {node: '>= 18'} - - set-blocking@2.0.0: - resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - shell-quote@1.8.3: - resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} - engines: {node: '>= 0.4'} - - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - spawn-command@0.0.2: - resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} - - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - - string-width@1.0.2: - resolution: {integrity: sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==} - engines: {node: '>=0.10.0'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - - strip-ansi@3.0.1: - resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==} - engines: {node: '>=0.10.0'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - - tar@4.4.19: - resolution: {integrity: sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==} - engines: {node: '>=4.5'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} - engines: {node: '>=18.0.0'} - hasBin: true - - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - - vite-plugin-mix@0.4.0: - resolution: {integrity: sha512-9X8hiwhl0RbtEXBB0XqnQ5suheAtP3VHn794WcWwjU5ziYYWdlqpMh/2J8APpx/YdpvQ2CZT7dlcGGd/31ya3w==} - peerDependencies: - vite: ^3 - - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - - wide-align@1.1.5: - resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - -snapshots: - - '@babel/runtime@7.28.6': {} - - '@bufbuild/protobuf@1.10.1': {} - - '@esbuild/aix-ppc64@0.21.5': - optional: true - - '@esbuild/aix-ppc64@0.27.3': - optional: true - - '@esbuild/android-arm64@0.21.5': - optional: true - - '@esbuild/android-arm64@0.27.3': - optional: true - - '@esbuild/android-arm@0.21.5': - optional: true - - '@esbuild/android-arm@0.27.3': - optional: true - - '@esbuild/android-x64@0.21.5': - optional: true - - '@esbuild/android-x64@0.27.3': - optional: true - - '@esbuild/darwin-arm64@0.21.5': - optional: true - - '@esbuild/darwin-arm64@0.27.3': - optional: true - - '@esbuild/darwin-x64@0.21.5': - optional: true - - '@esbuild/darwin-x64@0.27.3': - optional: true - - '@esbuild/freebsd-arm64@0.21.5': - optional: true - - '@esbuild/freebsd-arm64@0.27.3': - optional: true - - '@esbuild/freebsd-x64@0.21.5': - optional: true - - '@esbuild/freebsd-x64@0.27.3': - optional: true - - '@esbuild/linux-arm64@0.21.5': - optional: true - - '@esbuild/linux-arm64@0.27.3': - optional: true - - '@esbuild/linux-arm@0.21.5': - optional: true - - '@esbuild/linux-arm@0.27.3': - optional: true - - '@esbuild/linux-ia32@0.21.5': - optional: true - - '@esbuild/linux-ia32@0.27.3': - optional: true - - '@esbuild/linux-loong64@0.21.5': - optional: true - - '@esbuild/linux-loong64@0.27.3': - optional: true - - '@esbuild/linux-mips64el@0.21.5': - optional: true - - '@esbuild/linux-mips64el@0.27.3': - optional: true - - '@esbuild/linux-ppc64@0.21.5': - optional: true - - '@esbuild/linux-ppc64@0.27.3': - optional: true - - '@esbuild/linux-riscv64@0.21.5': - optional: true - - '@esbuild/linux-riscv64@0.27.3': - optional: true - - '@esbuild/linux-s390x@0.21.5': - optional: true - - '@esbuild/linux-s390x@0.27.3': - optional: true - - '@esbuild/linux-x64@0.21.5': - optional: true - - '@esbuild/linux-x64@0.27.3': - optional: true - - '@esbuild/netbsd-arm64@0.27.3': - optional: true - - '@esbuild/netbsd-x64@0.21.5': - optional: true - - '@esbuild/netbsd-x64@0.27.3': - optional: true - - '@esbuild/openbsd-arm64@0.27.3': - optional: true - - '@esbuild/openbsd-x64@0.21.5': - optional: true - - '@esbuild/openbsd-x64@0.27.3': - optional: true - - '@esbuild/openharmony-arm64@0.27.3': - optional: true - - '@esbuild/sunos-x64@0.21.5': - optional: true - - '@esbuild/sunos-x64@0.27.3': - optional: true - - '@esbuild/win32-arm64@0.21.5': - optional: true - - '@esbuild/win32-arm64@0.27.3': - optional: true - - '@esbuild/win32-ia32@0.21.5': - optional: true - - '@esbuild/win32-ia32@0.27.3': - optional: true - - '@esbuild/win32-x64@0.21.5': - optional: true - - '@esbuild/win32-x64@0.27.3': - optional: true - - '@livekit/protocol@1.45.0': - dependencies: - '@bufbuild/protobuf': 1.10.1 - - '@rollup/rollup-android-arm-eabi@4.59.0': - optional: true - - '@rollup/rollup-android-arm64@4.59.0': - optional: true - - '@rollup/rollup-darwin-arm64@4.59.0': - optional: true - - '@rollup/rollup-darwin-x64@4.59.0': - optional: true - - '@rollup/rollup-freebsd-arm64@4.59.0': - optional: true - - '@rollup/rollup-freebsd-x64@4.59.0': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-musl@4.59.0': - optional: true - - '@rollup/rollup-openbsd-x64@4.59.0': - optional: true - - '@rollup/rollup-openharmony-arm64@4.59.0': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.59.0': - optional: true - - '@types/body-parser@1.19.6': - dependencies: - '@types/connect': 3.4.38 - '@types/node': 25.4.0 - - '@types/connect@3.4.38': - dependencies: - '@types/node': 25.4.0 - - '@types/cors@2.8.19': - dependencies: - '@types/node': 25.4.0 - - '@types/estree@1.0.8': {} - - '@types/express-serve-static-core@5.1.1': - dependencies: - '@types/node': 25.4.0 - '@types/qs': 6.15.0 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 - - '@types/express@5.0.6': - dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 5.1.1 - '@types/serve-static': 2.2.0 - - '@types/http-errors@2.0.5': {} - - '@types/node@25.4.0': - dependencies: - undici-types: 7.18.2 - - '@types/qs@6.15.0': {} - - '@types/range-parser@1.2.7': {} - - '@types/send@1.2.1': - dependencies: - '@types/node': 25.4.0 - - '@types/serve-static@2.2.0': - dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 25.4.0 - - '@vercel/nft@0.10.1': - dependencies: - acorn: 8.16.0 - acorn-class-fields: 1.0.0(acorn@8.16.0) - acorn-static-class-features: 1.0.0(acorn@8.16.0) - bindings: 1.5.0 - estree-walker: 0.6.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - mkdirp: 0.5.6 - node-gyp-build: 4.8.4 - node-pre-gyp: 0.13.0 - resolve-from: 5.0.0 - rollup-pluginutils: 2.8.2 - transitivePeerDependencies: - - supports-color - - abbrev@1.1.1: {} - - accepts@2.0.0: - dependencies: - mime-types: 3.0.2 - negotiator: 1.0.0 - - acorn-class-fields@1.0.0(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - acorn-private-class-elements: 1.0.0(acorn@8.16.0) - - acorn-private-class-elements@1.0.0(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn-static-class-features@1.0.0(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - acorn-private-class-elements: 1.0.0(acorn@8.16.0) - - acorn@8.16.0: {} - - ansi-regex@2.1.1: {} - - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - aproba@1.2.0: {} - - are-we-there-yet@1.1.7: - dependencies: - delegates: 1.0.0 - readable-stream: 2.3.8 - - balanced-match@1.0.2: {} - - bindings@1.5.0: - dependencies: - file-uri-to-path: 1.0.0 - - body-parser@2.2.2: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 4.4.3 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - on-finished: 2.4.1 - qs: 6.15.0 - raw-body: 3.0.2 - type-is: 2.0.1 - transitivePeerDependencies: - - supports-color - - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - bytes@3.1.2: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - camelcase-keys@9.1.3: - dependencies: - camelcase: 8.0.0 - map-obj: 5.0.0 - quick-lru: 6.1.2 - type-fest: 4.41.0 - - camelcase@8.0.0: {} - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chownr@1.1.4: {} - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - code-point-at@1.1.0: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - concat-map@0.0.1: {} - - concurrently@8.2.2: - dependencies: - chalk: 4.1.2 - date-fns: 2.30.0 - lodash: 4.17.23 - rxjs: 7.8.2 - shell-quote: 1.8.3 - spawn-command: 0.0.2 - supports-color: 8.1.1 - tree-kill: 1.2.2 - yargs: 17.7.2 - - console-control-strings@1.1.0: {} - - content-disposition@1.0.1: {} - - content-type@1.0.5: {} - - cookie-signature@1.2.2: {} - - cookie@0.7.2: {} - - core-util-is@1.0.3: {} - - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - - date-fns@2.30.0: - dependencies: - '@babel/runtime': 7.28.6 - - debug@3.2.7: - dependencies: - ms: 2.1.3 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-extend@0.6.0: {} - - delegates@1.0.0: {} - - depd@2.0.0: {} - - detect-libc@1.0.3: {} - - dotenv@16.6.1: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - ee-first@1.1.1: {} - - emoji-regex@8.0.0: {} - - encodeurl@2.0.0: {} - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - - esbuild@0.27.3: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 - - escalade@3.2.0: {} - - escape-html@1.0.3: {} - - estree-walker@0.6.1: {} - - etag@1.8.1: {} - - express@5.2.1: - dependencies: - accepts: 2.0.0 - body-parser: 2.2.2 - content-disposition: 1.0.1 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.2.2 - debug: 4.4.3 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 2.1.1 - fresh: 2.0.0 - http-errors: 2.0.1 - merge-descriptors: 2.0.0 - mime-types: 3.0.2 - on-finished: 2.4.1 - once: 1.4.0 - parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.15.0 - range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 - statuses: 2.0.2 - type-is: 2.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - file-uri-to-path@1.0.0: {} - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - finalhandler@2.1.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - forwarded@0.2.0: {} - - fresh@2.0.0: {} - - fs-minipass@1.2.7: - dependencies: - minipass: 2.9.0 - - fs.realpath@1.0.0: {} - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - gauge@2.7.4: - dependencies: - aproba: 1.2.0 - console-control-strings: 1.1.0 - has-unicode: 2.0.1 - object-assign: 4.1.1 - signal-exit: 3.0.7 - string-width: 1.0.2 - strip-ansi: 3.0.1 - wide-align: 1.1.5 - - get-caller-file@2.0.5: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-tsconfig@4.13.6: - dependencies: - resolve-pkg-maps: 1.0.0 - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.5 - once: 1.4.0 - path-is-absolute: 1.0.1 - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - has-flag@4.0.0: {} - - has-symbols@1.1.0: {} - - has-unicode@2.0.1: {} - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 - - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - - ignore-walk@3.0.4: - dependencies: - minimatch: 3.1.5 - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - ini@1.3.8: {} - - ipaddr.js@1.9.1: {} - - is-fullwidth-code-point@1.0.0: - dependencies: - number-is-nan: 1.0.1 - - is-fullwidth-code-point@3.0.0: {} - - is-number@7.0.0: {} - - is-promise@4.0.0: {} - - isarray@1.0.0: {} - - jose@5.10.0: {} - - livekit-server-sdk@2.15.0: - dependencies: - '@bufbuild/protobuf': 1.10.1 - '@livekit/protocol': 1.45.0 - camelcase-keys: 9.1.3 - jose: 5.10.0 - - lodash@4.17.23: {} - - map-obj@5.0.0: {} - - math-intrinsics@1.1.0: {} - - media-typer@1.1.0: {} - - merge-descriptors@2.0.0: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - - mime-db@1.54.0: {} - - mime-types@3.0.2: - dependencies: - mime-db: 1.54.0 - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.12 - - minimist@1.2.8: {} - - minipass@2.9.0: - dependencies: - safe-buffer: 5.2.1 - yallist: 3.1.1 - - minizlib@1.3.3: - dependencies: - minipass: 2.9.0 - - mkdirp@0.5.6: - dependencies: - minimist: 1.2.8 - - ms@2.1.3: {} - - nanoid@3.3.11: {} - - needle@2.9.1: - dependencies: - debug: 3.2.7 - iconv-lite: 0.4.24 - sax: 1.5.0 - transitivePeerDependencies: - - supports-color - - negotiator@1.0.0: {} - - node-gyp-build@4.8.4: {} - - node-pre-gyp@0.13.0: - dependencies: - detect-libc: 1.0.3 - mkdirp: 0.5.6 - needle: 2.9.1 - nopt: 4.0.3 - npm-packlist: 1.4.8 - npmlog: 4.1.2 - rc: 1.2.8 - rimraf: 2.7.1 - semver: 5.7.2 - tar: 4.4.19 - transitivePeerDependencies: - - supports-color - - nopt@4.0.3: - dependencies: - abbrev: 1.1.1 - osenv: 0.1.5 - - npm-bundled@1.1.2: - dependencies: - npm-normalize-package-bin: 1.0.1 - - npm-normalize-package-bin@1.0.1: {} - - npm-packlist@1.4.8: - dependencies: - ignore-walk: 3.0.4 - npm-bundled: 1.1.2 - npm-normalize-package-bin: 1.0.1 - - npmlog@4.1.2: - dependencies: - are-we-there-yet: 1.1.7 - console-control-strings: 1.1.0 - gauge: 2.7.4 - set-blocking: 2.0.0 - - number-is-nan@1.0.1: {} - - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} - - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - os-homedir@1.0.2: {} - - os-tmpdir@1.0.2: {} - - osenv@0.1.5: - dependencies: - os-homedir: 1.0.2 - os-tmpdir: 1.0.2 - - parseurl@1.3.3: {} - - path-is-absolute@1.0.1: {} - - path-to-regexp@8.3.0: {} - - picocolors@1.1.1: {} - - picomatch@2.3.1: {} - - postcss@8.5.8: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - process-nextick-args@2.0.1: {} - - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - qs@6.15.0: - dependencies: - side-channel: 1.1.0 - - quick-lru@6.1.2: {} - - range-parser@1.2.1: {} - - raw-body@3.0.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - unpipe: 1.0.0 - - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - - readable-stream@2.3.8: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 - - require-directory@2.1.1: {} - - resolve-from@5.0.0: {} - - resolve-pkg-maps@1.0.0: {} - - rimraf@2.7.1: - dependencies: - glob: 7.2.3 - - rollup-pluginutils@2.8.2: - dependencies: - estree-walker: 0.6.1 - - rollup@4.59.0: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.59.0 - '@rollup/rollup-android-arm64': 4.59.0 - '@rollup/rollup-darwin-arm64': 4.59.0 - '@rollup/rollup-darwin-x64': 4.59.0 - '@rollup/rollup-freebsd-arm64': 4.59.0 - '@rollup/rollup-freebsd-x64': 4.59.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 - '@rollup/rollup-linux-arm-musleabihf': 4.59.0 - '@rollup/rollup-linux-arm64-gnu': 4.59.0 - '@rollup/rollup-linux-arm64-musl': 4.59.0 - '@rollup/rollup-linux-loong64-gnu': 4.59.0 - '@rollup/rollup-linux-loong64-musl': 4.59.0 - '@rollup/rollup-linux-ppc64-gnu': 4.59.0 - '@rollup/rollup-linux-ppc64-musl': 4.59.0 - '@rollup/rollup-linux-riscv64-gnu': 4.59.0 - '@rollup/rollup-linux-riscv64-musl': 4.59.0 - '@rollup/rollup-linux-s390x-gnu': 4.59.0 - '@rollup/rollup-linux-x64-gnu': 4.59.0 - '@rollup/rollup-linux-x64-musl': 4.59.0 - '@rollup/rollup-openbsd-x64': 4.59.0 - '@rollup/rollup-openharmony-arm64': 4.59.0 - '@rollup/rollup-win32-arm64-msvc': 4.59.0 - '@rollup/rollup-win32-ia32-msvc': 4.59.0 - '@rollup/rollup-win32-x64-gnu': 4.59.0 - '@rollup/rollup-win32-x64-msvc': 4.59.0 - fsevents: 2.3.3 - - router@2.2.0: - dependencies: - debug: 4.4.3 - depd: 2.0.0 - is-promise: 4.0.0 - parseurl: 1.3.3 - path-to-regexp: 8.3.0 - transitivePeerDependencies: - - supports-color - - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - - safe-buffer@5.1.2: {} - - safe-buffer@5.2.1: {} - - safer-buffer@2.1.2: {} - - sax@1.5.0: {} - - semver@5.7.2: {} - - send@1.2.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.1 - mime-types: 3.0.2 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serve-static@2.2.1: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 1.2.1 - transitivePeerDependencies: - - supports-color - - set-blocking@2.0.0: {} - - setprototypeof@1.2.0: {} - - shell-quote@1.8.3: {} - - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - signal-exit@3.0.7: {} - - source-map-js@1.2.1: {} - - spawn-command@0.0.2: {} - - statuses@2.0.2: {} - - string-width@1.0.2: - dependencies: - code-point-at: 1.1.0 - is-fullwidth-code-point: 1.0.0 - strip-ansi: 3.0.1 - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string_decoder@1.1.1: - dependencies: - safe-buffer: 5.1.2 - - strip-ansi@3.0.1: - dependencies: - ansi-regex: 2.1.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-json-comments@2.0.1: {} - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - - tar@4.4.19: - dependencies: - chownr: 1.1.4 - fs-minipass: 1.2.7 - minipass: 2.9.0 - minizlib: 1.3.3 - mkdirp: 0.5.6 - safe-buffer: 5.2.1 - yallist: 3.1.1 - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - toidentifier@1.0.1: {} - - tree-kill@1.2.2: {} - - tslib@2.8.1: {} - - tsx@4.21.0: - dependencies: - esbuild: 0.27.3 - get-tsconfig: 4.13.6 - optionalDependencies: - fsevents: 2.3.3 - - type-fest@4.41.0: {} - - type-is@2.0.1: - dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 - mime-types: 3.0.2 - - typescript@5.9.3: {} - - undici-types@7.18.2: {} - - unpipe@1.0.0: {} - - util-deprecate@1.0.2: {} - - vary@1.1.2: {} - - vite-plugin-mix@0.4.0(vite@5.4.21(@types/node@25.4.0)): - dependencies: - '@vercel/nft': 0.10.1 - vite: 5.4.21(@types/node@25.4.0) - transitivePeerDependencies: - - supports-color - - vite@5.4.21(@types/node@25.4.0): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.8 - rollup: 4.59.0 - optionalDependencies: - '@types/node': 25.4.0 - fsevents: 2.3.3 - - wide-align@1.1.5: - dependencies: - string-width: 1.0.2 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrappy@1.0.2: {} - - y18n@5.0.8: {} - - yallist@3.1.1: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 diff --git a/examples/rpc-benchmark/rpc-benchmark.ts b/examples/rpc-benchmark/rpc-benchmark.ts deleted file mode 100644 index ef3458896b..0000000000 --- a/examples/rpc-benchmark/rpc-benchmark.ts +++ /dev/null @@ -1,471 +0,0 @@ -/** - * RPC Benchmark - stress tests LiveKit RPC with configurable payload sizes. - * - * Ported from client-sdk-cpp/src/tests/stress/test_rpc_stress.cpp - * - * Three RPC paths are exercised depending on payload size: - * 1. Legacy (< COMPRESS_MIN_BYTES = 1KB): uncompressed inline payload - * 2. Compressed (1KB .. < DATA_STREAM_MIN_BYTES = 15KB): gzip-compressed inline - * 3. Data stream (>= 15KB): gzip-compressed via a one-time data stream - */ -import { - Room, - type RoomConnectOptions, - RoomEvent, - RpcError, - type RpcInvocationData, -} from '../../src/index'; -import { generatePayload } from './test-data'; - -// --------------------------------------------------------------------------- -// Stats tracker (mirrors StressTestStats from the C++ test) -// --------------------------------------------------------------------------- - -interface CallRecord { - success: boolean; - latencyMs: number; - payloadBytes: number; -} - -interface ErrorBucket { - [key: string]: number; -} - -class BenchmarkStats { - private calls: CallRecord[] = []; - private errors: ErrorBucket = {}; - - recordCall(success: boolean, latencyMs: number, payloadBytes: number) { - this.calls.push({ success, latencyMs, payloadBytes }); - } - - recordError(kind: string) { - this.errors[kind] = (this.errors[kind] ?? 0) + 1; - } - - get totalCalls() { - return this.calls.length; - } - - get successfulCalls() { - return this.calls.filter((c) => c.success).length; - } - - get failedCalls() { - return this.calls.filter((c) => !c.success).length; - } - - get successRate() { - return this.totalCalls > 0 ? (100 * this.successfulCalls) / this.totalCalls : 0; - } - - get checksumMismatches() { - return this.errors['checksum_mismatch'] ?? 0; - } - - /** Sorted latencies for successful calls */ - private sortedLatencies(): number[] { - return this.calls - .filter((c) => c.success) - .map((c) => c.latencyMs) - .sort((a, b) => a - b); - } - - get avgLatency(): number { - const s = this.sortedLatencies(); - if (s.length === 0) { - return 0; - } - return s.reduce((a, b) => a + b, 0) / s.length; - } - - percentile(p: number): number { - const s = this.sortedLatencies(); - if (s.length === 0) { - return 0; - } - const idx = Math.min(Math.floor((p / 100) * s.length), s.length - 1); - return s[idx]; - } - - get errorSummary(): ErrorBucket { - return { ...this.errors }; - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function computeChecksum(str: string): number { - let sum = 0; - for (let i = 0; i < str.length; i += 1) { - sum += str.charCodeAt(i); - } - return sum; -} - -const fetchToken = async ( - identity: string, - roomName: string, -): Promise<{ token: string; url: string }> => { - const response = await fetch('/api/get-token', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ identity, roomName }), - }); - if (!response.ok) throw new Error('Failed to fetch token'); - const data = await response.json(); - return { token: data.token, url: data.url }; -}; - -const connectParticipant = async (identity: string, roomName: string): Promise => { - const room = new Room(); - const { token, url } = await fetchToken(identity, roomName); - - room.on(RoomEvent.Disconnected, () => { - log(`[${identity}] Disconnected from room`); - }); - - await room.connect(url, token, { autoSubscribe: true } as RoomConnectOptions); - - await new Promise((resolve) => { - if (room.state === 'connected') { - resolve(); - } else { - room.once(RoomEvent.Connected, () => resolve()); - } - }); - - log(`${identity} connected.`); - return room; -}; - -// --------------------------------------------------------------------------- -// UI helpers -// --------------------------------------------------------------------------- - -let startTime = Date.now(); - -function log(message: string) { - const elapsed = ((Date.now() - startTime) / 1000).toFixed(3); - const formatted = `[+${elapsed}s] ${message}`; - console.log(formatted); - const logArea = document.getElementById('log') as HTMLTextAreaElement | null; - if (logArea) { - logArea.value += formatted + '\n'; - logArea.scrollTop = logArea.scrollHeight; - } -} - -function updateStat(id: string, value: string | number) { - const el = document.getElementById(id); - if (el) el.textContent = String(value); -} - -function refreshStatsUI(stats: BenchmarkStats, elapsedSec: number) { - updateStat('stat-total', stats.totalCalls); - updateStat('stat-success', stats.successfulCalls); - updateStat('stat-failed', stats.failedCalls); - updateStat('stat-rate', stats.totalCalls > 0 ? stats.successRate.toFixed(1) + '%' : '-'); - updateStat( - 'stat-avg-latency', - stats.successfulCalls > 0 ? stats.avgLatency.toFixed(1) + 'ms' : '-', - ); - updateStat('stat-p50', stats.successfulCalls > 0 ? stats.percentile(50).toFixed(1) + 'ms' : '-'); - updateStat('stat-p95', stats.successfulCalls > 0 ? stats.percentile(95).toFixed(1) + 'ms' : '-'); - updateStat('stat-p99', stats.successfulCalls > 0 ? stats.percentile(99).toFixed(1) + 'ms' : '-'); - updateStat( - 'stat-throughput', - elapsedSec > 0 ? (stats.successfulCalls / elapsedSec).toFixed(2) : '-', - ); - updateStat('stat-checksum', stats.checksumMismatches); - updateStat('stat-elapsed', Math.floor(elapsedSec) + 's'); -} - -// --------------------------------------------------------------------------- -// Benchmark runner -// --------------------------------------------------------------------------- - -let running = false; - -async function runBenchmark() { - const payloadBytes = parseInt( - (document.getElementById('payload-size') as HTMLInputElement).value, - 10, - ); - const durationSec = parseInt((document.getElementById('duration') as HTMLInputElement).value, 10); - const concurrency = parseInt( - (document.getElementById('concurrency') as HTMLInputElement).value, - 10, - ); - const delayMs = parseInt((document.getElementById('delay') as HTMLInputElement).value, 10); - - running = true; - startTime = Date.now(); - - const logArea = document.getElementById('log') as HTMLTextAreaElement; - if (logArea) logArea.value = ''; - - document.getElementById('stats-area')!.style.display = ''; - (document.getElementById('run-benchmark') as HTMLButtonElement).style.display = 'none'; - (document.getElementById('stop-benchmark') as HTMLButtonElement).style.display = ''; - - log(`=== RPC Benchmark ===`); - log(`Payload size: ${payloadBytes} bytes`); - log(`Duration: ${durationSec}s | Concurrency: ${concurrency} | Delay: ${delayMs}ms`); - - const roomName = `rpc-bench-${Math.random().toString(36).substring(2, 8)}`; - log(`Connecting participants to room: ${roomName}`); - - let callerRoom: Room | undefined; - let receiverRoom: Room | undefined; - - try { - [callerRoom, receiverRoom] = await Promise.all([ - connectParticipant('bench-caller', roomName), - connectParticipant('bench-receiver', roomName), - ]); - - // Register echo handler on receiver - let totalReceived = 0; - receiverRoom.registerRpcMethod('benchmark-echo', async (data: RpcInvocationData) => { - totalReceived += 1; - // Echo back the payload for round-trip verification - return data.payload; - }); - - // Wait for participants to see each other - await new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error('Timed out waiting for receiver to be visible')), - 10_000, - ); - const check = () => { - const participants = callerRoom!.remoteParticipants; - for (const [, p] of participants) { - if (p.identity === 'bench-receiver') { - clearTimeout(timeout); - resolve(); - return; - } - } - }; - check(); - callerRoom!.on(RoomEvent.ParticipantConnected, () => check()); - }); - - log(`Both participants connected. Starting benchmark...`); - log(`Pre-generating payload (${payloadBytes} bytes)...`); - const payload = generatePayload(payloadBytes); - const expectedChecksum = computeChecksum(payload); - log(`Payload generated. Checksum: ${expectedChecksum}`); - - const stats = new BenchmarkStats(); - const benchStartMs = performance.now(); - const benchEndTimeMs = benchStartMs + durationSec * 1000; - - // Stats refresh interval - const statsInterval = setInterval(() => { - const elapsedMs = (performance.now() - benchStartMs) / 1000; - refreshStatsUI(stats, elapsedMs); - }, 500); - - // Caller loop for one "thread" (concurrent async worker) - const callerLoop = async (threadId: number) => { - while (running && performance.now() < benchEndTimeMs) { - const callStartMs = performance.now(); - - try { - const response = await callerRoom!.localParticipant.performRpc({ - destinationIdentity: 'bench-receiver', - method: 'benchmark-echo', - payload, - responseTimeout: 60_000, - }); - - const latencyMs = performance.now() - callStartMs; - const responseChecksum = computeChecksum(response); - - if (response.length === payload.length && responseChecksum === expectedChecksum) { - stats.recordCall(true, latencyMs, payloadBytes); - } else { - stats.recordCall(false, latencyMs, payloadBytes); - stats.recordError('checksum_mismatch'); - log( - `[Thread ${threadId}] CHECKSUM MISMATCH sent=${payload.length}/${expectedChecksum} recv=${response.length}/${responseChecksum}`, - ); - } - } catch (error) { - const latency = performance.now() - callStartMs; - stats.recordCall(false, latency, payloadBytes); - - if (error instanceof RpcError) { - const code = error.code; - if (code === RpcError.ErrorCode.RESPONSE_TIMEOUT) { - stats.recordError('timeout'); - } else if (code === RpcError.ErrorCode.CONNECTION_TIMEOUT) { - stats.recordError('connection_timeout'); - } else if (code === RpcError.ErrorCode.RECIPIENT_DISCONNECTED) { - stats.recordError('recipient_disconnected'); - } else { - stats.recordError(`rpc_error_${code}`); - } - log( - `[Thread ${threadId}] RPC Error code=${code} msg="${error.message}" latency=${latency.toFixed(1)}ms`, - ); - } else { - stats.recordError('exception'); - log(`[Thread ${threadId}] Exception: ${error}`); - } - } - - // Delay between calls - if (delayMs > 0 && running) { - await new Promise((r) => setTimeout(r, delayMs)); - } - } - }; - - // Launch concurrent caller "threads" - const threads = Array.from({ length: concurrency }, (_, i) => callerLoop(i)); - await Promise.all(threads); - - clearInterval(statsInterval); - - // Final stats update - const totalElapsed = (performance.now() - benchStartMs) / 1000; - refreshStatsUI(stats, totalElapsed); - - log(`\n=== Benchmark Complete ===`); - log(`Total calls: ${stats.totalCalls}`); - log(`Successful: ${stats.successfulCalls} | Failed: ${stats.failedCalls}`); - log(`Success rate: ${stats.successRate.toFixed(1)}%`); - log(`Avg latency: ${stats.avgLatency.toFixed(1)}ms`); - log( - `P50: ${stats.percentile(50).toFixed(1)}ms | P95: ${stats.percentile(95).toFixed(1)}ms | P99: ${stats.percentile(99).toFixed(1)}ms`, - ); - log(`Throughput: ${(stats.successfulCalls / totalElapsed).toFixed(2)} calls/sec`); - log(`Receiver total processed: ${totalReceived}`); - - const errors = stats.errorSummary; - if (Object.keys(errors).length > 0) { - log(`Errors: ${JSON.stringify(errors)}`); - } - - receiverRoom.localParticipant.unregisterRpcMethod('benchmark-echo'); - } catch (error) { - log(`Fatal error: ${error}`); - } finally { - running = false; - if (callerRoom) await callerRoom.disconnect(); - if (receiverRoom) await receiverRoom.disconnect(); - (document.getElementById('run-benchmark') as HTMLButtonElement).style.display = ''; - (document.getElementById('run-benchmark') as HTMLButtonElement).disabled = false; - (document.getElementById('stop-benchmark') as HTMLButtonElement).style.display = 'none'; - log('Disconnected.'); - } -} - -// --------------------------------------------------------------------------- -// Query string persistence -// --------------------------------------------------------------------------- - -const PARAM_DEFAULTS: Record = { - 'payload-size': '15360', - duration: '30', - concurrency: '3', - delay: '10', -}; - -// All element IDs managed in the URL (network has no default — omitted when empty) -const ALL_PARAM_IDS = ['network', ...Object.keys(PARAM_DEFAULTS)]; - -function loadParamsFromURL() { - const params = new URLSearchParams(window.location.search); - let needsReplace = false; - - // Network: only set if present in URL, otherwise leave empty - const networkEl = document.getElementById('network') as HTMLSelectElement; - if (params.has('network')) { - networkEl.value = params.get('network')!; - } - - for (const [id, defaultValue] of Object.entries(PARAM_DEFAULTS)) { - const input = document.getElementById(id) as HTMLInputElement; - if (!input) continue; - - if (params.has(id)) { - input.value = params.get(id)!; - } else { - params.set(id, defaultValue); - needsReplace = true; - } - } - - if (needsReplace) { - window.history.replaceState(null, '', '?' + params.toString()); - } -} - -function syncParamsToURL() { - const params = new URLSearchParams(window.location.search); - - // Network: include only when non-empty - const networkEl = document.getElementById('network') as HTMLSelectElement; - if (networkEl.value) { - params.set('network', networkEl.value); - } else { - params.delete('network'); - } - - for (const id of Object.keys(PARAM_DEFAULTS)) { - const input = document.getElementById(id) as HTMLInputElement; - if (input) { - params.set(id, input.value); - } - } - window.history.replaceState(null, '', '?' + params.toString()); -} - -// --------------------------------------------------------------------------- -// DOM wiring -// --------------------------------------------------------------------------- - -document.addEventListener('DOMContentLoaded', () => { - loadParamsFromURL(); - - // Sync to URL on any input change - for (const id of ALL_PARAM_IDS) { - const el = document.getElementById(id); - if (el) { - const event = el.tagName === 'SELECT' ? 'change' : 'input'; - el.addEventListener(event, () => syncParamsToURL()); - } - } - - const runBtn = document.getElementById('run-benchmark') as HTMLButtonElement; - const stopBtn = document.getElementById('stop-benchmark') as HTMLButtonElement; - - runBtn.addEventListener('click', async () => { - runBtn.disabled = true; - await runBenchmark(); - }); - - stopBtn.addEventListener('click', () => { - log('Stopping benchmark...'); - running = false; - }); - - // Preset buttons - document.querySelectorAll('.preset-btn').forEach((btn) => { - btn.addEventListener('click', () => { - const size = (btn as HTMLButtonElement).dataset.size; - if (size) { - const input = document.getElementById('payload-size') as HTMLInputElement; - input.value = size; - syncParamsToURL(); - } - }); - }); -}); diff --git a/examples/rpc-benchmark/styles.css b/examples/rpc-benchmark/styles.css deleted file mode 100644 index d9ef108571..0000000000 --- a/examples/rpc-benchmark/styles.css +++ /dev/null @@ -1,231 +0,0 @@ -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, - 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; - background-color: #f0f2f5; - color: #333; - line-height: 1.6; - margin: 0; - padding: 0; -} - -.container { - max-width: 900px; - margin: 40px auto; - padding: 30px; - background-color: #ffffff; - box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); - border-radius: 12px; -} - -.title-row { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 30px; -} - -h1 { - color: #2c3e50; - margin: 0; - font-weight: 600; -} - -h2 { - color: #2c3e50; - margin-top: 30px; - margin-bottom: 15px; - font-weight: 500; - font-size: 1.1em; -} - -.info-box { - border: 2px dotted #bdc3c7; - border-radius: 8px; - padding: 8px 12px; - position: relative; - display: flex; - align-items: center; - gap: 8px; - min-width: 320px; -} - -.info-label { - position: absolute; - top: -10px; - left: 12px; - background: #ffffff; - padding: 0 6px; - font-size: 11px; - color: #95a5a6; - text-transform: uppercase; - letter-spacing: 0.5px; - white-space: nowrap; -} - -.info-box label { - font-weight: 500; - font-size: 14px; - color: #555; - white-space: nowrap; -} - -.info-box select { - padding: 6px 10px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; -} - -.controls { - margin-bottom: 20px; -} - -.control-group { - margin-bottom: 12px; -} - -.payload-row { - display: flex; - align-items: flex-end; - gap: 8px; - flex-wrap: wrap; -} - -.payload-row .control-group { - margin-bottom: 0; -} - -.preset-btn { - padding: 7px 12px; - background: #ecf0f1; - border: 1px solid #bdc3c7; - border-radius: 4px; - font-size: 13px; - cursor: pointer; - transition: background-color 0.2s; - white-space: nowrap; -} - -.preset-btn:hover { - background: #d5dbdb; -} - -.control-group label { - display: block; - font-weight: 500; - margin-bottom: 4px; - font-size: 14px; - color: #555; -} - -.control-group input { - padding: 8px 12px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; - width: 160px; -} - -.control-row { - display: flex; - gap: 20px; - flex-wrap: wrap; -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 12px; -} - -.stat { - background: #f8f9fa; - border: 1px solid #e9ecef; - border-radius: 6px; - padding: 12px; - text-align: center; -} - -.stat-label { - display: block; - font-size: 11px; - color: #6c757d; - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 4px; -} - -.stat-value { - display: block; - font-size: 18px; - font-weight: 600; - color: #2c3e50; - font-variant-numeric: tabular-nums; -} - -.stat-success { - color: #27ae60; -} - -.stat-fail { - color: #e74c3c; -} - -#log-area { - margin-top: 20px; -} - -#log { - box-sizing: border-box; - width: 100%; - height: 250px; - padding: 10px; - border: 1px solid #ddd; - border-radius: 4px; - font-family: monospace; - font-size: 13px; - resize: vertical; -} - -.btn { - display: inline-block; - padding: 10px 24px; - background-color: #3498db; - color: white; - border: none; - border-radius: 5px; - font-size: 16px; - cursor: pointer; - transition: background-color 0.3s, transform 0.1s; - font-weight: 500; - margin-right: 10px; -} - -.btn:hover { - background-color: #2980b9; - transform: translateY(-2px); -} - -.btn:active { - transform: translateY(0); -} - -.btn:disabled { - background-color: #bdc3c7; - color: #7f8c8d; - cursor: not-allowed; - transform: none; -} - -.btn:disabled:hover { - background-color: #bdc3c7; - transform: none; -} - -.btn-danger { - background-color: #e74c3c; -} - -.btn-danger:hover { - background-color: #c0392b; -} diff --git a/examples/rpc-benchmark/test-data.ts b/examples/rpc-benchmark/test-data.ts deleted file mode 100644 index a3b299d7a4..0000000000 --- a/examples/rpc-benchmark/test-data.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Generated structured JSON test data for RPC benchmarking. - * - * Each line is a self-contained JSON object representing realistic structured - * data (user profiles, events, metrics, etc.). This data compresses roughly - * as well as typical structured payloads. - */ - -const TEST_DATA_LINES: string[] = [ - '{"id":"usr_a1b2c3","name":"Alice Chen","email":"alice.chen@example.com","role":"engineer","department":"platform","projects":["livekit-core","media-pipeline","signaling"],"metrics":{"commits":342,"reviews":128,"deployments":57},"location":"San Francisco, CA","joined":"2022-03-15T08:30:00Z"}', - '{"event":"room.participant_joined","timestamp":"2025-01-15T14:22:33.456Z","room_sid":"RM_xK9mPq2nR4","participant_sid":"PA_j7hLw3vYm1","identity":"speaker-042","metadata":{"display_name":"Dr. Sarah Mitchell","avatar_url":"https://cdn.example.com/avatars/sm042.jpg","hand_raised":false}}', - '{"sensor_id":"temp-rack-07b","readings":[{"ts":1705312800,"value":23.4,"unit":"celsius"},{"ts":1705312860,"value":23.6,"unit":"celsius"},{"ts":1705312920,"value":24.1,"unit":"celsius"},{"ts":1705312980,"value":23.8,"unit":"celsius"}],"status":"nominal","location":"datacenter-west-3"}', - '{"order_id":"ORD-2025-00847","customer":{"id":"cust_9f8e7d","name":"Bob Williams","tier":"premium"},"items":[{"sku":"WDG-1042","name":"Wireless Adapter Pro","qty":2,"price":49.99},{"sku":"CBL-3001","name":"USB-C Cable 2m","qty":5,"price":12.99}],"total":164.93,"currency":"USD","status":"processing"}', - '{"trace_id":"abc123def456","spans":[{"name":"http.request","duration_ms":245,"status":"ok","attributes":{"http.method":"POST","http.url":"/api/v2/rooms","http.status_code":201}},{"name":"db.query","duration_ms":12,"status":"ok","attributes":{"db.system":"postgresql","db.statement":"INSERT INTO rooms"}}]}', - '{"log_level":"warn","service":"media-router","instance":"mr-us-east-07","message":"Track subscription delayed due to network congestion","context":{"room_sid":"RM_pQ8nL2mK5x","track_sid":"TR_w4jR7vN9y3","participant_sid":"PA_k2mX5bH8r1","delay_ms":1847,"retry_count":3,"bandwidth_estimate_bps":2450000}}', - '{"config":{"video":{"codecs":["VP8","H264","AV1"],"simulcast":{"enabled":true,"layers":[{"rid":"f","maxBitrate":2500000,"maxFramerate":30},{"rid":"h","maxBitrate":800000,"maxFramerate":15},{"rid":"q","maxBitrate":200000,"maxFramerate":7}]},"dynacast":true},"audio":{"codecs":["opus"],"dtx":true,"red":true,"stereo":false}}}', - '{"benchmark":{"test":"rpc-throughput","iteration":1547,"payload_bytes":15360,"compress_ratio":0.42,"latency_ms":23.7,"path":"compressed","timestamp":"2025-06-20T10:15:33.891Z","caller":"bench-caller-01","receiver":"bench-receiver-01","room":"benchmark-room-8f3a"}}', - '{"user_id":"u_7k3m9p","session":{"id":"sess_abc123","started":"2025-01-15T09:00:00Z","duration_minutes":47,"pages_viewed":12,"actions":[{"type":"click","target":"#start-call","ts":1705308120},{"type":"input","target":"#chat-message","ts":1705308245},{"type":"click","target":"#share-screen","ts":1705308390}],"device":{"browser":"Chrome 121","os":"macOS 14.2","screen":"2560x1440"}}}', - '{"pipeline_id":"pipe_rtc_042","stages":[{"name":"capture","codec":"VP8","resolution":"1920x1080","fps":30,"bitrate_kbps":2500},{"name":"encode","profile":"constrained-baseline","hardware_accel":true,"latency_ms":4.2},{"name":"packetize","mtu":1200,"fec_enabled":true,"nack_enabled":true},{"name":"transport","protocol":"UDP","ice_candidates":3,"dtls_setup":"actpass"}]}', - '{"notification":{"id":"notif_x8k2m","type":"room_recording_ready","recipient":"user_j4n7p","channel":"webhook","payload":{"room_name":"team-standup-2025-01-15","recording_url":"https://storage.example.com/recordings/rec_abc123.mp4","duration_seconds":1847,"file_size_bytes":245760000,"format":"mp4","resolution":"1920x1080"},"created_at":"2025-01-15T10:35:00Z"}}', - '{"cluster":{"id":"lk-us-east-1","region":"us-east-1","nodes":[{"id":"node-01","type":"media","status":"healthy","load":0.67,"rooms":42,"participants":318,"cpu_pct":54.2,"mem_pct":71.8},{"id":"node-02","type":"media","status":"healthy","load":0.43,"rooms":31,"participants":201,"cpu_pct":38.1,"mem_pct":55.4}],"total_rooms":73,"total_participants":519}}', - '{"invoice":{"number":"INV-2025-003847","date":"2025-01-15","due_date":"2025-02-14","vendor":{"name":"Cloud Services Inc.","address":"100 Tech Blvd, Austin, TX 78701","tax_id":"US-847291035"},"line_items":[{"description":"Media Server Instances (720 hrs)","quantity":720,"unit_price":0.085,"amount":61.20},{"description":"Bandwidth (2.4 TB)","quantity":2400,"unit_price":0.02,"amount":48.00},{"description":"TURN Relay (180 hrs)","quantity":180,"unit_price":0.04,"amount":7.20}],"subtotal":116.40,"tax":9.31,"total":125.71}}', - '{"experiment":{"id":"exp_codec_comparison_042","hypothesis":"AV1 reduces bandwidth by 30% vs VP8 at equivalent quality","groups":[{"name":"control","codec":"VP8","participants":500,"avg_bitrate_kbps":2100,"avg_psnr":38.2,"avg_latency_ms":45},{"name":"treatment","codec":"AV1","participants":500,"avg_bitrate_kbps":1470,"avg_psnr":38.5,"avg_latency_ms":52}],"p_value":0.003,"significant":true,"start_date":"2025-01-01","end_date":"2025-01-14"}}', - '{"deployment":{"id":"deploy_20250115_003","service":"livekit-server","version":"1.8.2","environment":"production","region":"eu-west-1","status":"completed","started_at":"2025-01-15T03:00:00Z","completed_at":"2025-01-15T03:12:47Z","changes":["fix: ice restart race condition","feat: improved simulcast layer selection","perf: reduce memory allocation in media forwarding"],"rollback_available":true,"health_check":"passing"}}', - '{"analytics":{"room_id":"RM_daily_standup_042","period":"2025-01-15T09:00:00Z/2025-01-15T09:30:00Z","participants":{"total":8,"max_concurrent":7,"avg_duration_minutes":22.4},"media":{"audio":{"total_minutes":156.8,"avg_bitrate_kbps":32,"packet_loss_pct":0.02},"video":{"total_minutes":134.2,"avg_bitrate_kbps":1850,"packet_loss_pct":0.08,"avg_fps":28.7},"screen_share":{"total_minutes":15.3,"avg_bitrate_kbps":3200}},"quality_score":4.7}}', - '{"ticket":{"id":"TICKET-8472","title":"Intermittent audio dropout in large rooms","priority":"high","status":"in_progress","assignee":"eng-media-team","reporter":"support-agent-12","created":"2025-01-14T16:30:00Z","updated":"2025-01-15T11:22:00Z","labels":["audio","production","p1"],"comments_count":7,"related_incidents":["INC-2025-0042","INC-2025-0039"],"description":"Users in rooms with 50+ participants report intermittent audio dropouts lasting 2-5 seconds"}}', - '{"schema":{"table":"participants","columns":[{"name":"id","type":"uuid","primary_key":true},{"name":"room_id","type":"uuid","foreign_key":"rooms.id","index":true},{"name":"identity","type":"varchar(255)","not_null":true},{"name":"name","type":"varchar(500)"},{"name":"metadata","type":"jsonb"},{"name":"joined_at","type":"timestamptz","not_null":true,"default":"now()"},{"name":"left_at","type":"timestamptz"},{"name":"state","type":"enum(active,disconnected,migrating)","not_null":true,"default":"active"}],"indexes":["idx_participants_room_id","idx_participants_identity"]}}', - '{"weather":{"station":"SFO-042","timestamp":"2025-01-15T12:00:00Z","temperature_c":14.2,"humidity_pct":72,"wind_speed_kmh":18.5,"wind_direction":"NW","pressure_hpa":1013.2,"conditions":"partly_cloudy","forecast":[{"hour":13,"temp_c":14.8,"precip_pct":10},{"hour":14,"temp_c":15.1,"precip_pct":5},{"hour":15,"temp_c":14.9,"precip_pct":15},{"hour":16,"temp_c":14.3,"precip_pct":25}]}}', - '{"translation":{"request_id":"tr_9f8e7d6c","source_lang":"en","target_lang":"ja","model":"nmt-v4","segments":[{"source":"The meeting will start in 5 minutes.","target":"\u4f1a\u8b70\u306f5\u5206\u5f8c\u306b\u59cb\u307e\u308a\u307e\u3059\u3002","confidence":0.97},{"source":"Please enable your camera.","target":"\u30ab\u30e1\u30e9\u3092\u6709\u52b9\u306b\u3057\u3066\u304f\u3060\u3055\u3044\u3002","confidence":0.95}],"total_chars":78,"latency_ms":142,"cached":false}}', -]; - -/** - * Generate a payload of the specified byte size by cycling through and - * concatenating random lines from the test data set, then slicing to exact - * size on a valid character boundary. - */ -export function generatePayload(targetBytes: number): string { - if (targetBytes <= 0) return ''; - - const encoder = new TextEncoder(); - let result = ''; - - // Shuffle indices for realistic randomness - const indices = Array.from({ length: TEST_DATA_LINES.length }, (_, i) => i); - for (let i = indices.length - 1; i > 0; i -= 1) { - const j = Math.floor(Math.random() * (i + 1)); - [indices[i], indices[j]] = [indices[j], indices[i]]; - } - - let idx = 0; - while (encoder.encode(result).length < targetBytes) { - if (result.length > 0) { - result += '\n'; - } - result += TEST_DATA_LINES[indices[idx % indices.length]]; - idx += 1; - // Re-shuffle when we've gone through all lines - if (idx % indices.length === 0) { - for (let i = indices.length - 1; i > 0; i -= 1) { - const j = Math.floor(Math.random() * (i + 1)); - [indices[i], indices[j]] = [indices[j], indices[i]]; - } - } - } - - // Trim to exact byte size on a valid character boundary - const encoded = encoder.encode(result); - if (encoded.length <= targetBytes) { - // Pad with spaces if under target - const padding = targetBytes - encoded.length; - return result + ' '.repeat(padding); - } - - // Binary search for the right character count that fits in targetBytes - let low = 0; - let high = result.length; - while (low < high) { - const mid = Math.floor((low + high + 1) / 2); - if (encoder.encode(result.slice(0, mid)).length <= targetBytes) { - low = mid; - } else { - high = mid - 1; - } - } - - const trimmed = result.slice(0, low); - const trimmedLen = encoder.encode(trimmed).length; - // Pad remainder with spaces to hit exact target - if (trimmedLen < targetBytes) { - return trimmed + ' '.repeat(targetBytes - trimmedLen); - } - return trimmed; -} diff --git a/examples/rpc-benchmark/tsconfig.json b/examples/rpc-benchmark/tsconfig.json deleted file mode 100644 index 299d17f4d5..0000000000 --- a/examples/rpc-benchmark/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "outDir": "build", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noUnusedLocals": true, - "forceConsistentCasingInFileNames": true, - "moduleResolution": "node", - "resolveJsonModule": true - }, - "include": ["../../src/**/*", "rpc-benchmark.ts", "api.ts", "test-data.ts"], - "exclude": ["**/*.test.ts", "build/**/*"] -} diff --git a/examples/rpc-benchmark/vite.config.js b/examples/rpc-benchmark/vite.config.js deleted file mode 100644 index 8f82d19f31..0000000000 --- a/examples/rpc-benchmark/vite.config.js +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vite'; -import mix from 'vite-plugin-mix'; - -export default defineConfig({ - plugins: [ - mix.default({ - handler: './api.ts', - }), - ], -}); From f2b194f493e782f9d699785baf3f87e832f48b5e Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 3 Apr 2026 14:13:13 -0400 Subject: [PATCH 53/54] docs: check in draft spec --- RPC_SPEC.md | 349 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 RPC_SPEC.md diff --git a/RPC_SPEC.md b/RPC_SPEC.md new file mode 100644 index 0000000000..857bbaf57d --- /dev/null +++ b/RPC_SPEC.md @@ -0,0 +1,349 @@ +# RPC v2 Specification + +## Overview + +RPC (Remote Procedure Call) allows participants in a LiveKit room to invoke methods on each other +and receive responses. RPC v1 used inline protobuf packets (`RpcRequest` / `RpcResponse`) with a +hard 15 KB payload limit. + +RPC v2 lifts this limit by transporting request and response payloads over **data streams**, while +retaining v1 as a fallback for legacy clients. This removes the previously set 15kb request / +response payload size limitation, making both effectively limitless. + +A v2 client should seamlessly communicate with v1 clients by detecting the remote participant's +client protocol version and falling back to v1 packets if it doesn't support rpc v2. + +--- + +## Part 1: Client protocol + +### What is client protocol? + +`clientProtocol` is a new integer that each participant advertises in their `ParticipantInfo` via +the LiveKit signaling channel. It tells other participants what client-to-client features this SDK +supports. It is distinct from the existing `protocol` field (which tracks signaling protocol +version) - `clientProtocol` specifically governs peer-to-peer feature negotiation. + +| Value | Constant name | Meaning | +|-------|---------------|---------| +| `0` | `CLIENT_PROTOCOL_DEFAULT` | Legacy client. Only supports RPC v1. | +| `1` | `CLIENT_PROTOCOL_DATA_STREAM_RPC` | Supports RPC v2 (data stream-based payloads). | + +### What SDKs need to do + +1. **Advertise**: Set your SDK's `clientProtocol` to `1` (`CLIENT_PROTOCOL_DATA_STREAM_RPC`) in the + `ParticipantInfo` sent during the join handshake. + +2. **Read**: When a remote participant joins or updates, store their `clientProtocol` value. This is + available on the `ParticipantInfo` protobuf. If the field is absent or unrecognized, treat it as + `0` (`CLIENT_PROTOCOL_DEFAULT`). + +3. **Use**: Before sending an RPC request or response, look up the remote participant's + `clientProtocol` to decide whether to use the v1 (packet) or v2 (data stream) transport. + +--- + +## Part 2: RPC protocol updates + +As a review, here is how RPC v1 works today: + +``` +Caller Handler + | | + |--- RpcRequest (DataPacket) ------->| + | | (looks up handler, invokes it) + |<-- RpcAck (DataPacket) -----------| + |<-- RpcResponse (DataPacket) ------| + | | +``` + +1. The **caller** sends a `DataPacket` containing a `RpcRequest` protobuf: + - `id`: a UUID identifying this request + - `method`: the method name + - `payload`: the request payload string (must be <= 15 KB) + - `responseTimeoutMs`: the effective timeout in milliseconds + - `version`: `1` + - Packet kind: `RELIABLE` + - Destination: the handler's identity + +2. The **handler** receives the `RpcRequest` packet and immediately sends an **ack** - a + `DataPacket` containing a `RpcAck` protobuf with the `requestId`. This tells the caller that + the handler is alive and processing. + +3. The handler invokes the registered method handler. When it completes, the handler sends a + `DataPacket` containing a `RpcResponse` protobuf: + - `requestId`: matches the original request + - `value`: either `{ case: 'payload', value: responseString }` for success, or + `{ case: 'error', value: RpcError protobuf }` for failure + - The response payload is also subject to the 15 KB limit. + +4. The **caller** receives the `RpcResponse` and resolves or rejects the pending promise. + +### RPC v2 Example + +v2 replaces the `RpcRequest` and `RpcResponse` protobuf packets with **text data streams** for +carrying payloads. The ack mechanism is unchanged. This removes the payload size limit while +remaining backward-compatible with v1 clients. + +``` +Caller Handler + | | + |--- Text data stream (request) --->| + | topic: "lk.rpc_request" | + | attrs: request_id, method, | + | timeout, version=2 | + | body: | + | | (reads stream, looks up handler, invokes it) + |<-- RpcAck (DataPacket) -----------| + |<-- Text data stream (response) ---| + | topic: "lk.rpc_response" | + | attrs: request_id | + | body: | + | | +``` + +1. The **caller** opens a text data stream with: + - **Topic**: `lk.rpc_request` + - **Destination identities**: `[destinationIdentity]` + - **Attributes**: + - `lk.rpc_request_id`: a newly generated UUID + - `lk.rpc_request_method`: the method name + - `lk.rpc_request_response_timeout_ms`: the effective timeout in milliseconds, as a string + - `lk.rpc_request_version`: `"2"` + - Writes the payload string to the stream, then closes it. + +2. The **handler** receives the data stream on topic `lk.rpc_request`. It parses the attributes + to extract the request ID, method, timeout, and version. It sends an **ack** (same `RpcAck` + packet as v1), then reads the full stream payload. + +3. The handler invokes the registered method handler. On success, it sends the response as a + text data stream: + - **Topic**: `lk.rpc_response` + - **Destination identities**: `[callerIdentity]` + - **Attributes**: `{ "lk.rpc_request_id": requestId }` + - Writes the response payload, then closes the stream. + +4. The **caller** receives the data stream on topic `lk.rpc_response`. It reads the + `lk.rpc_request_id` attribute to match it to the pending request, reads the full stream, + and resolves the pending promise with the payload. + +The user-facing API should be identical for v1 and v2. + +The protocol version negotiation is invisible to the user. The only visible difference that a user +should see is that if they send a rpc request or receive a rpc response from a participant +supporting rpc v2 with a length greater than 15kb, they will NOT receive a +`REQUEST_PAYLOAD_TOO_LARGE` / `RESPONSE_PAYLOAD_TOO_LARGE` error - it will "just work". With rpc v2, +these errors are effectively deprecated. + +#### Error responses in v2 + +**Error responses are always sent as v1 `RpcResponse` packets**, even when both sides are v2. This +is because error payloads tend to be small (code + message + optional data) and using packets keeps +the error path simple and uniform. This means: + +- Success responses between two v2 clients: **data stream** +- Error responses between two v2 clients: **packet** (`RpcResponse` with `error` case) +- All responses to v1 clients: **packet** + +#### Data stream topic routing + +RPC requests and responses use separate data stream topics: + +- **`lk.rpc_request`**: Register a text stream handler for this topic. Incoming streams are RPC + requests. Route to the handler-side logic, passing the sender identity and the stream attributes. +- **`lk.rpc_response`**: Register a text stream handler for this topic. Incoming streams are RPC + responses. Read the `lk.rpc_request_id` attribute to match the response to a pending request, + then route to the caller-side logic. + +### Version negotiation and backward compatibility + +The transport used for a given RPC call depends on what both sides support. The caller decides the +request transport; the handler decides the response transport. + +| Caller | Handler | Request transport | Response transport | +|--------|---------|------------------|--------------------| +| v2 | v2 | Data stream | Data stream (success) / Packet (error) | +| v2 | v1 | Packet (`RpcRequest`) | Packet (`RpcResponse`) | +| v1 | v2 | Packet (`RpcRequest`) | Packet (`RpcResponse`) | +| v1 | v1 | Packet (`RpcRequest`) | Packet (`RpcResponse`) | + +**Data streams are only used when both sides are v2.** Cross-version interactions always fall back +to v1 packets. This is because: + +- The **caller** checks the remote participant's `clientProtocol` before sending. If the remote is + v1, the caller sends a v1 `RpcRequest` packet. +- The **handler** checks the caller's `clientProtocol` before responding. If the caller is v1, the + handler sends a v1 `RpcResponse` packet. (The handler knows the caller is v1 because the request + arrived as a v1 packet, and it can also check the caller's `clientProtocol`.) + +### Timeout and ack behavior + +These behaviors are the same for v1 and v2. + +## Minimum required test cases + +The following tests represent the minimum set that must pass for a conforming implementation. They +are organized by the version interaction being tested. Since this spec describes implementing a v2 +SDK, at least one side of every interaction is always v2. Each test describes the full lifecycle +from both the caller and handler perspectives. + +### v2 -> v2 (both sides support data streams) + +1. **Caller happy path (short payload)** + - The caller opens a text data stream on topic `lk.rpc_request` with attributes + `lk.rpc_request_id`, `lk.rpc_request_method`, `lk.rpc_request_response_timeout_ms`, and + `lk.rpc_request_version: "2"`. + - The caller writes the payload string to the stream and closes it. + - Verify no `RpcRequest` packet is produced. + - Simulate the handler sending a `RpcAck` packet and a successful response. + - Verify the caller resolves with the response payload. + +2. **Caller happy path (large payload > 15 KB)** + - The caller opens a text data stream on topic `lk.rpc_request` with the same attributes as + above, but with a payload exceeding 15 KB (e.g., 20,000 bytes). + - The caller writes the large payload to the stream and closes it. + - Verify no `REQUEST_PAYLOAD_TOO_LARGE` error is raised - the data stream path has no size + limit. + - Simulate the handler sending a `RpcAck` packet and a successful response. + - Verify the caller resolves with the response payload. + +3. **Handler happy path** + - The handler receives a text data stream on topic `lk.rpc_request` with valid attributes. + - The handler sends a `RpcAck` packet with the request ID. + - The handler reads the full stream payload and invokes the registered method handler with + `{ requestId, callerIdentity, payload, responseTimeout }`. + - The method handler returns a response string. + - The handler sends the response as a text data stream on topic `lk.rpc_response` with + attribute `lk.rpc_request_id` set to the request ID. + - Verify no `RpcResponse` packet is produced - successful v2 responses use data streams. + +4. **Unhandled error in handler** + - The handler receives a v2 data stream request. + - The handler sends a `RpcAck` packet. + - The registered method handler throws a non-RpcError exception (e.g., a generic `Error`). + - The handler sends a `RpcResponse` **packet** (not a data stream) with error code + `APPLICATION_ERROR` (1500). + - Verify error responses always use packets, even between two v2 clients. + +5. **RpcError passthrough in handler** + - The handler receives a v2 data stream request. + - The handler sends a `RpcAck` packet. + - The registered method handler throws a `RpcError` with a custom code (e.g., 101) and + message. + - The handler sends a `RpcResponse` packet preserving the original error code and message. + +6. **Response timeout** + - The caller sends a v2 data stream request with a short response timeout (e.g., 50ms). + - No `RpcAck` or response arrives. + - After the timeout elapses, the caller rejects with `RESPONSE_TIMEOUT` (code 1502). + +7. **Error response** + - The caller sends a v2 data stream request. + - Simulate the handler sending a `RpcAck` packet, then a `RpcResponse` packet with an error + (e.g., code 101, message "Test error message"). + - Verify the caller rejects with the correct error code and message. + +8. **Participant disconnection** + - The caller sends a v2 data stream request. + - Before any ack or response arrives, the remote participant disconnects. + - Verify the caller rejects with `RECIPIENT_DISCONNECTED` (code 1503). + +### v2 -> v1 (v2 caller, v1 handler) + +10. **Caller happy path (request fallback)** + - The caller detects the remote's `clientProtocol` is 0. + - The caller sends a v1 `RpcRequest` packet (not a data stream) with correct `id`, `method`, + `payload`, `responseTimeoutMs`, and `version: 1`. + - Verify no data stream is opened. + - Simulate the handler sending a `RpcAck` packet, then a `RpcResponse` packet with a + success payload. + - Verify the caller resolves with the response payload. + +11. **Handler happy path (v1 request)** + - The handler receives a v1 `RpcRequest` packet with `version: 1`. + - The handler sends a `RpcAck` packet with the request ID. + - The handler invokes the registered method handler with `{ requestId, callerIdentity, + payload, responseTimeout }`. + - The method handler returns a response string. + - The handler detects the caller's `clientProtocol` is 0 and sends the response as a v1 + `RpcResponse` packet (not a data stream). + +12. **Payload too large** + - The caller detects the remote's `clientProtocol` is 0. + - The caller attempts to send a payload exceeding 15 KB. + - Verify it rejects immediately with `REQUEST_PAYLOAD_TOO_LARGE` (code 1402) without producing + any packet or data stream. + +13. **Response timeout** + - The caller detects the remote's `clientProtocol` is 0. + - The caller sends a v1 `RpcRequest` packet with a short response timeout (e.g., 50ms). + - No `RpcAck` or response arrives. + - After the timeout elapses, the caller rejects with `RESPONSE_TIMEOUT` (code 1502). + +14. **Error response** + - The caller detects the remote's `clientProtocol` is 0. + - The caller sends a v1 `RpcRequest` packet. + - Simulate the handler sending a `RpcAck` packet, then a `RpcResponse` packet with an + error (e.g., code 101, message "Test error message"). + - Verify the caller rejects with the correct error code and message. + +15. **Participant disconnection** + - The caller detects the remote's `clientProtocol` is 0. + - The caller sends a v1 `RpcRequest` packet. + - Before any ack or response arrives, the remote participant disconnects. + - Verify the caller rejects with `RECIPIENT_DISCONNECTED` (code 1503). + +### v1 -> v2 (v1 caller, v2 handler) + +16. **Handler happy path (response fallback)** + - A v1 caller sends a v1 `RpcRequest` packet with `version: 1`. + - The v2-capable handler receives it and sends a `RpcAck` packet. + - The handler invokes the registered method handler, which returns a response string. + - The handler detects the caller's `clientProtocol` is 0 and sends the response as a v1 + `RpcResponse` packet (not a data stream), even though it supports v2. + - Verify no data stream is opened for the response. + +17. **Unhandled error in handler (v1 caller)** + - A v1 caller sends a v1 `RpcRequest` packet. + - The handler sends a `RpcAck` packet. + - The registered method handler throws a non-RpcError exception (e.g., a generic `Error`). + - The handler sends a `RpcResponse` packet with error code `APPLICATION_ERROR` (1500). + +18. **RpcError passthrough (v1 caller)** + - A v1 caller sends a v1 `RpcRequest` packet. + - The handler sends a `RpcAck` packet. + - The registered method handler throws a `RpcError` with a custom code (e.g., 101) and + message. + - The handler sends a `RpcResponse` packet preserving the original error code and message. + +--- + +## Benchmarking + +Implementing a benchmark is not required, but could be useful for validating correctness and +performance. The below outlines the test criteria used for `client-sdk-cpp` and `client-sdk-js`. + +For an exact reference implementation, see https://github.com/livekit/client-sdk-js/commit/da26fa022197326a8f31db5421f175fad2fe4651. + +### Approach + +The benchmark connects two participants to the same room in a single process: + +1. **Setup**: A "caller" and "receiver" join the same room. +2. **Echo handler**: The receiver registers an RPC method (`benchmark-echo`) that returns the + received payload unchanged. +3. **Payload**: Pre-generate a payload of the desired size. Compute a checksum (e.g., sum of + character codes) for integrity verification. +4. **Caller loop**: N concurrent workers each loop for a configured duration, calling the + echo method and verifying the response matches the original payload (length + checksum). +5. **Metrics**: Track success/failure counts, latency percentiles (p50, p95, p99), throughput + (calls/sec), and error breakdown. + +### Suggested parameters + +| Parameter | Suggested default | Description | +|-----------|-------------------|-------------| +| Payload size | 15360 bytes | Size of the RPC payload in bytes | +| Duration | 30 seconds | How long the benchmark runs | +| Concurrency | 3 | Number of parallel caller loops | +| Delay between calls | 10ms | Pause between consecutive calls per thread | From f69d8a05a5e6db5bdbe984766be1faa5ade6911e Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 9 Apr 2026 15:47:26 -0400 Subject: [PATCH 54/54] refactor: use named constants for rpc versions --- src/room/rpc/client/RpcClientManager.ts | 6 ++++-- src/room/rpc/server/RpcServerManager.ts | 3 ++- src/room/rpc/utils.ts | 10 ++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index 1a15c04f24..55df050651 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -15,6 +15,8 @@ import { RPC_REQUEST_METHOD_ATTR, RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR, RPC_REQUEST_VERSION_ATTR, + RPC_VERSION_V1, + RPC_VERSION_V2, RpcError, byteLength, } from '../utils'; @@ -147,7 +149,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm [RPC_REQUEST_ID_ATTR]: requestId, [RPC_REQUEST_METHOD_ATTR]: method, [RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR]: `${responseTimeout}`, - [RPC_REQUEST_VERSION_ATTR]: '2', // Latest rpc request version + [RPC_REQUEST_VERSION_ATTR]: `${RPC_VERSION_V2}`, }, }); @@ -168,7 +170,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm method, payload, responseTimeoutMs: responseTimeout, - version: 1, + version: RPC_VERSION_V1, }), }, }), diff --git a/src/room/rpc/server/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts index 166b8e696a..a0e38d5b9a 100644 --- a/src/room/rpc/server/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -13,6 +13,7 @@ import { RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR, RPC_REQUEST_VERSION_ATTR, RPC_RESPONSE_DATA_STREAM_TOPIC, + RPC_VERSION_V2, RpcError, type RpcInvocationData, byteLength, @@ -144,7 +145,7 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm this.publishRpcAck(callerIdentity, requestId); - if (version !== 2) { + if (version !== RPC_VERSION_V2) { this.publishRpcResponsePacket( callerIdentity, requestId, diff --git a/src/room/rpc/utils.ts b/src/room/rpc/utils.ts index e817f4612a..b01d94a5c9 100644 --- a/src/room/rpc/utils.ts +++ b/src/room/rpc/utils.ts @@ -168,6 +168,16 @@ export const RPC_REQUEST_RESPONSE_TIMEOUT_MS_ATTR = 'lk.rpc_request_response_tim /** @internal */ export const RPC_REQUEST_VERSION_ATTR = 'lk.rpc_request_version'; +/** Initial version of rpc which uses RpcRequest / RpcResponse messages. + * @internal + **/ +export const RPC_VERSION_V1 = 1; + +/** Rpc version backed by data streams instead of RpcRequest / RpcResponse. + * @internal + **/ +export const RPC_VERSION_V2 = 2; + /** * @internal */