From 551569903aa349998f9bbd76b64a97ffffe94ad7 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 2 Feb 2026 09:23:47 -0500 Subject: [PATCH 01/43] fix: get rid of DataTrackHandle wrapping class and make it just a number --- src/room/data-track/handle.ts | 19 +++++++------------ src/room/data-track/packet/index.ts | 4 ++-- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/room/data-track/handle.ts b/src/room/data-track/handle.ts index c02c6bc24c..f89ab0c878 100644 --- a/src/room/data-track/handle.ts +++ b/src/room/data-track/handle.ts @@ -41,10 +41,9 @@ export class DataTrackHandleError< } } -export class DataTrackHandle { - public value: number; - - static fromNumber( +export type DataTrackHandle = number; +export const DataTrackHandle = { + fromNumber( raw: number, ): Throws< DataTrackHandle, @@ -57,24 +56,20 @@ export class DataTrackHandle { if (raw > U16_MAX_SIZE) { throw DataTrackHandleError.tooLarge(); } - return new DataTrackHandle(raw); - } - - constructor(raw: number) { - this.value = raw; + return raw; } } /** Manage allocating new handles which don't conflict over the lifetime of the client. */ export class DataTrackHandleAllocator { - static value = 0; + value = 0; /** Returns a unique track handle for the next publication, if one can be obtained. */ - static get(): DataTrackHandle | null { + get(): DataTrackHandle | null { this.value += 1; if (this.value > U16_MAX_SIZE) { return null; } - return new DataTrackHandle(this.value); + return this.value; } } diff --git a/src/room/data-track/packet/index.ts b/src/room/data-track/packet/index.ts index c3b59a551f..74e4d89a19 100644 --- a/src/room/data-track/packet/index.ts +++ b/src/room/data-track/packet/index.ts @@ -126,7 +126,7 @@ export class DataTrackPacketHeader extends Serializable { dataView.setUint8(byteIndex, 0); // Reserved byteIndex += U8_LENGTH_BYTES; - dataView.setUint16(byteIndex, this.trackHandle.value); + dataView.setUint16(byteIndex, this.trackHandle); byteIndex += U16_LENGTH_BYTES; dataView.setUint16(byteIndex, this.sequence.value); byteIndex += U16_LENGTH_BYTES; @@ -277,7 +277,7 @@ export class DataTrackPacketHeader extends Serializable { toJSON() { return { marker: this.marker, - trackHandle: this.trackHandle.value, + trackHandle: this.trackHandle, sequence: this.sequence.value, frameNumber: this.frameNumber.value, timestamp: this.timestamp.asTicks(), From 80d9044d12ee6312667a975f32426f104837c7dd Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 2 Feb 2026 09:24:25 -0500 Subject: [PATCH 02/43] fix: make Future promise property use Throws --- src/room/utils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/room/utils.ts b/src/room/utils.ts index 42e071ccbc..4c00f86e91 100644 --- a/src/room/utils.ts +++ b/src/room/utils.ts @@ -27,6 +27,7 @@ import type { TrackPublication } from './track/TrackPublication'; import { type AudioCodec, type VideoCodec, audioCodecs, videoCodecs } from './track/options'; import { getNewAudioContext } from './track/utils'; import type { ChatMessage, LiveKitReactNativeInfo, TranscriptionSegment } from './types'; +import { type Throws } from '../utils/throws'; const separator = '|'; export const ddExtensionURI = @@ -458,7 +459,7 @@ export function getStereoAudioStreamTrack() { } export class Future { - promise: Promise; + promise: Promise>; resolve?: (arg: T) => void; @@ -486,7 +487,7 @@ export class Future { }).finally(() => { this._isResolved = true; this.onFinally?.(); - }); + }) as Promise>; } } From 9443cefb09351aa3cae97b4c83eae9c2df8b798b Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 6 Feb 2026 14:06:06 -0500 Subject: [PATCH 03/43] docs: add Future docs comment --- src/room/utils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/room/utils.ts b/src/room/utils.ts index 4c00f86e91..2fbf6548ec 100644 --- a/src/room/utils.ts +++ b/src/room/utils.ts @@ -458,6 +458,10 @@ export function getStereoAudioStreamTrack() { return stereoTrack; } +/** An object that represents a serialized version of a `new Promise((resolve, reject) => {})` + * constructor. Wait for a promise resolution with `await future.promise` and explicitly resolve or + * reject the inner promise with `future.resolve(...)` or `future.reject(...)`. + */ export class Future { promise: Promise>; From 6ef565fec6ecba36e688c6826ec9c608d508f368 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 6 Feb 2026 14:15:32 -0500 Subject: [PATCH 04/43] feat: add initial incoming data tracks manager draft --- src/room/data-track/e2ee.ts | 13 ++ src/room/data-track/incoming/manager.ts | 294 ++++++++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 src/room/data-track/e2ee.ts create mode 100644 src/room/data-track/incoming/manager.ts diff --git a/src/room/data-track/e2ee.ts b/src/room/data-track/e2ee.ts new file mode 100644 index 0000000000..efe1ac749e --- /dev/null +++ b/src/room/data-track/e2ee.ts @@ -0,0 +1,13 @@ +export type EncryptedPayload = { + payload: Uint8Array; + iv: Uint8Array; // NOTE: should be 12 bytes long + keyIndex: number; +}; + +export type EncryptionProvider = { + encrypt(payload: Uint8Array): EncryptedPayload; +}; + +export type DecryptionProvider = { + decrypt(payload: Uint8Array, senderIdentity: string): Uint8Array; +}; diff --git a/src/room/data-track/incoming/manager.ts b/src/room/data-track/incoming/manager.ts new file mode 100644 index 0000000000..b97db63735 --- /dev/null +++ b/src/room/data-track/incoming/manager.ts @@ -0,0 +1,294 @@ +import { EventEmitter } from 'events'; +import type TypedEmitter from 'typed-emitter'; +import { LivekitReasonedError } from '../../errors'; +import { LoggerNames, getLogger } from '../../../logger'; +import { Future } from '../../utils'; +import { DataTrackHandle, DataTrackHandleAllocator } from '../handle'; +import { type EncryptionProvider } from '../e2ee'; + +const log = getLogger(LoggerNames.DataTracks); + +type LocalDataTrack = { info: DataTrackInfo }; + +type PendingDescriptor = { + type: 'pending'; + completionFuture: Future< + LocalDataTrack, + | DataTrackPublishError + | DataTrackPublishError + | DataTrackPublishError + | DataTrackPublishError + >; +}; +type ActiveDescriptor = { + type: 'active'; + info: DataTrackInfo; + // FIXME: add track task fields here. +}; +type Descriptor = PendingDescriptor | ActiveDescriptor; + +type DataTrackIncomingManagerCallbacks = { + /** Request sent to the SFU to publish a track. */ + sfuPublishRequest: (event: {handle: DataTrackHandle, name: string, usesE2ee: boolean}) => void; + /** Request sent to the SFU to unpublish a track. */ + sfuUnpublishRequest: (event: {handle: DataTrackHandle}) => void; +}; + +/** Options for publishing a data track. */ +type DataTrackOptions = { + name: string, +}; + +type DataTrackSid = string; + +/** Information about a published data track. */ +type DataTrackInfo = { + sid: DataTrackSid, + pubHandle: DataTrackHandle, + name: String, + usesE2ee: boolean, +}; + +type InputEventPublishRequest = { + type: 'publishRequest'; + options: DataTrackOptions; + signal?: AbortSignal; +}; + +type InputEventQueryPublished = { + type: 'queryPublished', + // FIXME: use onehsot future vs sending corresponding "-Response" event? + future: Future, never>; +}; +type InputEventUnpublishRequest = { type: 'unpublishRequest', handle: DataTrackHandle }; +type InputEventSfuPublishResponse = { + type: 'sfuPublishResponse'; + handle: DataTrackHandle; + result: ( + | { type: 'ok', data: DataTrackInfo } + | { type: 'error', error: DataTrackPublishError } + ); +}; +type InputEventSfuUnPublishResponse = { type: 'sfuUnpublishResponse', handle: DataTrackHandle }; +/** Shutdown the manager and all associated tracks. */ +type InputEventShutdown = { type: 'shutdown' }; + +type InputEvent = + | InputEventPublishRequest + // FIXME: no cancelled event + // | { type: 'publishCancelled', handle: DataTrackHandle } + | InputEventQueryPublished + | InputEventUnpublishRequest + | InputEventSfuPublishResponse + | InputEventSfuUnPublishResponse + | InputEventShutdown; + +type DataTrackLocalManagerOptions = { + /** + * Provider to use for encrypting outgoing frame payloads. + * + * If none, end-to-end encryption will be disabled for all published tracks. + */ + decryptionProvider?: EncryptionProvider; +}; + +enum DataTrackPublishErrorReason { + LimitReached, + Internal, + Disconnected, + + // FIXME: this was introduced by web / there isn't a corresponding case in the rust version. + Cancelled, +} + +class DataTrackPublishError< + Reason extends DataTrackPublishErrorReason, +> extends LivekitReasonedError { + readonly name = 'DataTrackPublishError'; + + reason: Reason; + + reasonName: string; + + constructor(message: string, reason: Reason, options?: { cause?: unknown }) { + super(21, message, options); + this.reason = reason; + this.reasonName = DataTrackPublishErrorReason[reason]; + } + + static limitReached() { + return new DataTrackPublishError('FIXME', DataTrackPublishErrorReason.LimitReached); + } + + // FIXME: is this internal thing a good idea? + static internal(cause: Error) { + return new DataTrackPublishError('FIXME', DataTrackPublishErrorReason.Internal, { cause }); + } + + // FIXME: this was introduced by web / there isn't a corresponding case i nthe rust version. + static cancelled() { + return new DataTrackPublishError('FIXME', DataTrackPublishErrorReason.Cancelled); + } + + static disconnected() { + return new DataTrackPublishError('FIXME', DataTrackPublishErrorReason.Disconnected); + } +} + +export class DataTrackIncomingManager extends (EventEmitter as new () => TypedEmitter) { + private encryptionProvider: EncryptionProvider | null; + private handleAllocator = new DataTrackHandleAllocator(); + // FIXME: key of this map is the same as the value Descriptor["info"]["pubHandle"] + private descriptors = new Map(); + + constructor(options: DataTrackLocalManagerOptions) { + super(); + this.encryptionProvider = options.decryptionProvider ?? null; + } + + async handle(event: InputEvent) { + switch (event.type) { + case 'publishRequest': + return this.handlePublishRequest(event); + case 'queryPublished': + return this.handleQueryPublished(event); + case 'unpublishRequest': + return this.handleUnpublishRequest(event); + case 'sfuPublishResponse': + return this.handleSfuPublishResponse(event); + case 'sfuUnpublishResponse': + return this.handleSfuUnpublishResponse(event); + case 'shutdown': + return this.handleShutdown(event); + default: + // Make sure there is a typescript error if not all the input events are handled above. + event satisfies never; + + // @throws-transformer ignore - this should be treated as a "panic" and not be caught + throw new Error(`DataTrackLocalManager.handle: Unknown event type ${(event as InputEvent)?.type} found.`); + } + } + + /** Client requested to publish a track. */ + private async handlePublishRequest(event: InputEventPublishRequest) { + const handle = this.handleAllocator.get(); + if (!handle) { + throw DataTrackPublishError.limitReached(); + } + + if (this.descriptors.has(handle)) { + throw DataTrackPublishError.internal(new Error('Descriptor for handle already exists')); + } + + const descriptor: PendingDescriptor = { type: 'pending', completionFuture: new Future() }; + this.descriptors.set(handle, descriptor); + + const onAbort = () => { + const existingDescriptor = this.descriptors.get(handle); + if (!existingDescriptor) { + // FIXME: should this be an internal error? + log.warn(`No descriptor for ${handle}`); + return; + } + this.descriptors.delete(handle); + + // FIXME: this was introduced by web / there isn't a corresponding case in the rust version. + if (existingDescriptor.type === 'pending') { + existingDescriptor.completionFuture.reject?.(DataTrackPublishError.cancelled()); + } + }; + event.signal?.addEventListener('abort', onAbort); + + this.emit('sfuPublishRequest', { + handle, + name: event.options.name, + usesE2ee: this.encryptionProvider === null, + }); + + const localDataTrack = await descriptor.completionFuture.promise; + event.signal?.removeEventListener('abort', onAbort); + return localDataTrack; + } + + private handleQueryPublished(event: InputEventQueryPublished) { + const descriptorInfos = Array.from(this.descriptors.values()) + .filter((descriptor): descriptor is ActiveDescriptor => descriptor.type === "active") + .map(descriptor => descriptor.info); + + event.future.resolve?.(descriptorInfos); + } + + /** Client request to unpublish a track. */ + private handleUnpublishRequest(event: InputEventUnpublishRequest) { + this.removeDescriptorIfExists(event.handle); + + this.emit('sfuUnpublishRequest', { handle: event.handle }); + } + + /** SFU responded to a request to publish a data track. */ + private handleSfuPublishResponse(event: InputEventSfuPublishResponse) { + const descriptor = this.descriptors.get(event.handle); + if (!descriptor) { + // FIXME: should this be an internal error? + log.warn(`No descriptor for ${event.handle}`); + return; + } + this.descriptors.delete(event.handle); + + if (descriptor.type !== 'pending') { + log.warn(`Track ${event.handle} already active`); + return; + } + + if (event.result.type === 'ok') { + descriptor.completionFuture.resolve?.(this.createLocalTrack(event.result.data)); + } else { + descriptor.completionFuture.reject?.(event.result.error); + } + } + + /** SFU notification that a track has been unpublished. */ + private handleSfuUnpublishResponse(event: InputEventSfuUnPublishResponse) { + this.removeDescriptorIfExists(event.handle); + } + + /** Shuts down the manager and all associated tracks. */ + private handleShutdown(event: InputEventShutdown) { + for (const descriptor of this.descriptors.values()) { + switch (descriptor.type) { + case 'pending': + descriptor.completionFuture.reject?.(DataTrackPublishError.disconnected()) + break; + case 'active': + // FIXME: cleanup active descriptor + break; + } + } + this.descriptors.clear(); + } + + private createLocalTrack(info: DataTrackInfo) { + // FIXME: initialize track task in here! + + this.descriptors.set( + info.pubHandle, + { + type: 'active', + info, + // FIXME: add track task metadata in here! + // published_tx, + // task_handle, + }, + ); + + // FIXME: create local data track + // let inner = LocalTrackInner { frame_tx, published_tx }; + // return LocalDataTrack::new(info, inner) + return { info } as LocalDataTrack; + } + + private removeDescriptorIfExists(handle: DataTrackHandle) { + // FIXME: cleanup active descriptors, stop track task, etc + this.descriptors.delete(handle); + } +} From 712c77984a9d15b3ca614c219c0a16e50144a545 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 6 Feb 2026 16:29:39 -0500 Subject: [PATCH 05/43] feat: add initial data track pipeline for local data track (named incoming, which is incorrect and needs to be fixed!) --- src/room/data-track/depacketizer.ts | 2 +- src/room/data-track/e2ee.ts | 1 + src/room/data-track/frame.ts | 2 +- src/room/data-track/incoming/manager.ts | 79 ++++++++++++++------- src/room/data-track/incoming/pipeline.ts | 90 ++++++++++++++++++++++++ src/room/data-track/packetizer.ts | 2 +- src/room/data-track/track.ts | 12 ++++ 7 files changed, 158 insertions(+), 30 deletions(-) create mode 100644 src/room/data-track/incoming/pipeline.ts create mode 100644 src/room/data-track/track.ts diff --git a/src/room/data-track/depacketizer.ts b/src/room/data-track/depacketizer.ts index 9f88908b6b..730193afe0 100644 --- a/src/room/data-track/depacketizer.ts +++ b/src/room/data-track/depacketizer.ts @@ -85,7 +85,7 @@ type PushOptions = { errorOnPartialFrames: boolean; }; -export class DataTrackDepacketizer { +export default class DataTrackDepacketizer { /** Maximum number of packets to buffer per frame before dropping. */ static MAX_BUFFER_PACKETS = 128; diff --git a/src/room/data-track/e2ee.ts b/src/room/data-track/e2ee.ts index efe1ac749e..29064787ae 100644 --- a/src/room/data-track/e2ee.ts +++ b/src/room/data-track/e2ee.ts @@ -5,6 +5,7 @@ export type EncryptedPayload = { }; export type EncryptionProvider = { + // FIXME: add in explicit `Throws<..., EncryptionError>`? encrypt(payload: Uint8Array): EncryptedPayload; }; diff --git a/src/room/data-track/frame.ts b/src/room/data-track/frame.ts index 0e3e2ed689..56dbee9b56 100644 --- a/src/room/data-track/frame.ts +++ b/src/room/data-track/frame.ts @@ -1,5 +1,5 @@ import { DataTrackExtensions } from './packet/extensions'; -import { DataTrackPacketizer } from './packetizer'; +import DataTrackPacketizer from './packetizer'; /** A pair of payload bytes and packet extensions which can be fed into a {@link DataTrackPacketizer}. */ export type DataTrackFrame = { diff --git a/src/room/data-track/incoming/manager.ts b/src/room/data-track/incoming/manager.ts index b97db63735..f5b7a97309 100644 --- a/src/room/data-track/incoming/manager.ts +++ b/src/room/data-track/incoming/manager.ts @@ -5,10 +5,12 @@ import { LoggerNames, getLogger } from '../../../logger'; import { Future } from '../../utils'; import { DataTrackHandle, DataTrackHandleAllocator } from '../handle'; import { type EncryptionProvider } from '../e2ee'; +import { type DataTrackInfo } from '../track'; +import DataTrackIncomingPipeline from './pipeline'; const log = getLogger(LoggerNames.DataTracks); -type LocalDataTrack = { info: DataTrackInfo }; +type LocalDataTrack = { info: DataTrackInfo; pipeline: DataTrackIncomingPipeline }; type PendingDescriptor = { type: 'pending'; @@ -24,6 +26,8 @@ type ActiveDescriptor = { type: 'active'; info: DataTrackInfo; // FIXME: add track task fields here. + + pipeline: DataTrackIncomingPipeline, }; type Descriptor = PendingDescriptor | ActiveDescriptor; @@ -39,16 +43,6 @@ type DataTrackOptions = { name: string, }; -type DataTrackSid = string; - -/** Information about a published data track. */ -type DataTrackInfo = { - sid: DataTrackSid, - pubHandle: DataTrackHandle, - name: String, - usesE2ee: boolean, -}; - type InputEventPublishRequest = { type: 'publishRequest'; options: DataTrackOptions; @@ -93,17 +87,35 @@ type DataTrackLocalManagerOptions = { }; enum DataTrackPublishErrorReason { - LimitReached, - Internal, - Disconnected, + /** + * Local participant does not have permission to publish data tracks. + * + * Ensure the participant's token contains the `canPublishData` grant. + */ + NotAllowed = 0, + + /** A track with the same name is already published by the local participant. */ + DuplicateName = 1, + + /** Request to publish the track took long to complete. */ + Timeout = 2, + + /** No additional data tracks can be published by the local participant. */ + LimitReached = 3, + + /** Cannot publish data track when the room is disconnected. */ + Disconnected = 4, + + /** Internal error, please report on GitHub. */ + Internal = 5, // FIXME: this was introduced by web / there isn't a corresponding case in the rust version. - Cancelled, + Cancelled = 6, } class DataTrackPublishError< Reason extends DataTrackPublishErrorReason, -> extends LivekitReasonedError { +> extends LivekitReasonedError { readonly name = 'DataTrackPublishError'; reason: Reason; @@ -116,8 +128,24 @@ class DataTrackPublishError< this.reasonName = DataTrackPublishErrorReason[reason]; } + static notAllowed() { + return new DataTrackPublishError("Data track publishing unauthorized", DataTrackPublishErrorReason.NotAllowed); + } + + static duplicateName() { + return new DataTrackPublishError("Track name already taken", DataTrackPublishErrorReason.DuplicateName); + } + + static timeout() { + return new DataTrackPublishError("Publish data track timed-out", DataTrackPublishErrorReason.Timeout); + } + static limitReached() { - return new DataTrackPublishError('FIXME', DataTrackPublishErrorReason.LimitReached); + return new DataTrackPublishError("Data track publication limit reached", DataTrackPublishErrorReason.LimitReached); + } + + static disconnected() { + return new DataTrackPublishError("Room disconnected", DataTrackPublishErrorReason.Disconnected); } // FIXME: is this internal thing a good idea? @@ -125,14 +153,10 @@ class DataTrackPublishError< return new DataTrackPublishError('FIXME', DataTrackPublishErrorReason.Internal, { cause }); } - // FIXME: this was introduced by web / there isn't a corresponding case i nthe rust version. + // FIXME: this was introduced by web / there isn't a corresponding case in the rust version. static cancelled() { return new DataTrackPublishError('FIXME', DataTrackPublishErrorReason.Cancelled); } - - static disconnected() { - return new DataTrackPublishError('FIXME', DataTrackPublishErrorReason.Disconnected); - } } export class DataTrackIncomingManager extends (EventEmitter as new () => TypedEmitter) { @@ -253,7 +277,7 @@ export class DataTrackIncomingManager extends (EventEmitter as new () => TypedEm } /** Shuts down the manager and all associated tracks. */ - private handleShutdown(event: InputEventShutdown) { + private handleShutdown(_event: InputEventShutdown) { for (const descriptor of this.descriptors.values()) { switch (descriptor.type) { case 'pending': @@ -269,22 +293,23 @@ export class DataTrackIncomingManager extends (EventEmitter as new () => TypedEm private createLocalTrack(info: DataTrackInfo) { // FIXME: initialize track task in here! + const encryptionProvider = info.usesE2ee ? this.encryptionProvider : null; + + const pipeline = new DataTrackIncomingPipeline({ info, encryptionProvider }); this.descriptors.set( info.pubHandle, { type: 'active', info, - // FIXME: add track task metadata in here! - // published_tx, - // task_handle, + pipeline, }, ); // FIXME: create local data track // let inner = LocalTrackInner { frame_tx, published_tx }; // return LocalDataTrack::new(info, inner) - return { info } as LocalDataTrack; + return { info, pipeline } as LocalDataTrack; } private removeDescriptorIfExists(handle: DataTrackHandle) { diff --git a/src/room/data-track/incoming/pipeline.ts b/src/room/data-track/incoming/pipeline.ts new file mode 100644 index 0000000000..5fe08b3700 --- /dev/null +++ b/src/room/data-track/incoming/pipeline.ts @@ -0,0 +1,90 @@ +import { LivekitReasonedError } from '../../errors'; +import { EncryptedPayload, type EncryptionProvider } from '../e2ee'; +import type { DataTrackInfo } from '../track'; +import DataTrackPacketizer, { DataTrackPacketizerError, DataTrackPacketizerReason } from '../packetizer'; +import { DataTrackFrame } from '../frame'; +import { Throws } from '../../../utils/throws'; +import { DataTrackPacket } from '../packet'; +import { DataTrackE2eeExtension } from '../packet/extensions'; + +enum DataTrackIncomingPipelineErrorReason { + Packetizer = 0, + Encryption = 1, +} + +class DataTrackIncomingPipelineError< + Reason extends DataTrackIncomingPipelineErrorReason, +> extends LivekitReasonedError { + readonly name = 'DataTrackIncomingPipelineError'; + + reason: Reason; + + reasonName: string; + + constructor(message: string, reason: Reason, options?: { cause?: unknown }) { + super(21, message, options); + this.reason = reason; + this.reasonName = DataTrackIncomingPipelineErrorReason[reason]; + } + + static packetizer(cause: DataTrackPacketizerError) { + return new DataTrackIncomingPipelineError("Error packetizing frame", DataTrackIncomingPipelineErrorReason.Packetizer, { cause }); + } + + static encryption(cause: unknown) { + return new DataTrackIncomingPipelineError("Error encrypting frame", DataTrackIncomingPipelineErrorReason.Encryption, { cause }); + } +} + +type Options = { + info: DataTrackInfo; + encryptionProvider: EncryptionProvider | null; +}; + +export default class DataTrackIncomingPipeline { + private encryptionProvider: EncryptionProvider | null; + private packetizer: DataTrackPacketizer; + + /** Maximum transmission unit (MTU) of the transport. */ + private static TRANSPORT_MTU_BYTES = 16_000; + + constructor(options: Options) { + this.encryptionProvider = options.encryptionProvider; + this.packetizer = new DataTrackPacketizer(options.info.pubHandle, DataTrackIncomingPipeline.TRANSPORT_MTU_BYTES); + } + + *processFrame(frame: DataTrackFrame): Throws< + Generator, + | DataTrackIncomingPipelineError + | DataTrackIncomingPipelineError + > { + let encryptedFrame = this.encryptIfNeeded(frame); + + try { + yield* this.packetizer.packetize(encryptedFrame); + } catch (error) { + if (error instanceof DataTrackPacketizerError) { + throw DataTrackIncomingPipelineError.packetizer(error); + } + throw error; + } + } + + encryptIfNeeded(frame: DataTrackFrame): Throws> { + if (!this.encryptionProvider) { + return frame; + } + + let encryptedResult: EncryptedPayload; + try { + encryptedResult = this.encryptionProvider.encrypt(frame.payload); + } catch (err) { + throw DataTrackIncomingPipelineError.encryption(err); + } + + frame.payload = encryptedResult.payload; + frame.extensions.e2ee = new DataTrackE2eeExtension(encryptedResult.keyIndex, encryptedResult.iv); + + return frame; + } +} diff --git a/src/room/data-track/packetizer.ts b/src/room/data-track/packetizer.ts index eba9346fcc..671dc04c34 100644 --- a/src/room/data-track/packetizer.ts +++ b/src/room/data-track/packetizer.ts @@ -40,7 +40,7 @@ export enum DataTrackPacketizerReason { /** A packetizer takes a {@link DataTrackFrame} as input and generates a series * of {@link DataTrackPacket}s for transmission to other clients over webrtc. */ -export class DataTrackPacketizer { +export default class DataTrackPacketizer { private handle: DataTrackHandle; private mtuSizeBytes: number; diff --git a/src/room/data-track/track.ts b/src/room/data-track/track.ts new file mode 100644 index 0000000000..4440ac5442 --- /dev/null +++ b/src/room/data-track/track.ts @@ -0,0 +1,12 @@ +import type { DataTrackHandle } from "./handle"; + +export type DataTrackSid = string; + +/** Information about a published data track. */ +export type DataTrackInfo = { + sid: DataTrackSid, + pubHandle: DataTrackHandle, + name: String, + usesE2ee: boolean, +}; + From ec5330d9108bd8fe2e17121c92e777ae7a840aff Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 6 Feb 2026 16:32:10 -0500 Subject: [PATCH 06/43] refactor: rename from incoming -> outgoing which I think is actually correct, oops --- .../{incoming => outgoing}/manager.ts | 12 +++---- .../{incoming => outgoing}/pipeline.ts | 34 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) rename src/room/data-track/{incoming => outgoing}/manager.ts (96%) rename src/room/data-track/{incoming => outgoing}/pipeline.ts (64%) diff --git a/src/room/data-track/incoming/manager.ts b/src/room/data-track/outgoing/manager.ts similarity index 96% rename from src/room/data-track/incoming/manager.ts rename to src/room/data-track/outgoing/manager.ts index f5b7a97309..2897b27035 100644 --- a/src/room/data-track/incoming/manager.ts +++ b/src/room/data-track/outgoing/manager.ts @@ -6,11 +6,11 @@ import { Future } from '../../utils'; import { DataTrackHandle, DataTrackHandleAllocator } from '../handle'; import { type EncryptionProvider } from '../e2ee'; import { type DataTrackInfo } from '../track'; -import DataTrackIncomingPipeline from './pipeline'; +import DataTrackOutgoingPipeline from './pipeline'; const log = getLogger(LoggerNames.DataTracks); -type LocalDataTrack = { info: DataTrackInfo; pipeline: DataTrackIncomingPipeline }; +type LocalDataTrack = { info: DataTrackInfo; pipeline: DataTrackOutgoingPipeline }; type PendingDescriptor = { type: 'pending'; @@ -27,11 +27,11 @@ type ActiveDescriptor = { info: DataTrackInfo; // FIXME: add track task fields here. - pipeline: DataTrackIncomingPipeline, + pipeline: DataTrackOutgoingPipeline, }; type Descriptor = PendingDescriptor | ActiveDescriptor; -type DataTrackIncomingManagerCallbacks = { +type DataTrackOutgoingManagerCallbacks = { /** Request sent to the SFU to publish a track. */ sfuPublishRequest: (event: {handle: DataTrackHandle, name: string, usesE2ee: boolean}) => void; /** Request sent to the SFU to unpublish a track. */ @@ -159,7 +159,7 @@ class DataTrackPublishError< } } -export class DataTrackIncomingManager extends (EventEmitter as new () => TypedEmitter) { +export class DataTrackOutgoingManager extends (EventEmitter as new () => TypedEmitter) { private encryptionProvider: EncryptionProvider | null; private handleAllocator = new DataTrackHandleAllocator(); // FIXME: key of this map is the same as the value Descriptor["info"]["pubHandle"] @@ -295,7 +295,7 @@ export class DataTrackIncomingManager extends (EventEmitter as new () => TypedEm // FIXME: initialize track task in here! const encryptionProvider = info.usesE2ee ? this.encryptionProvider : null; - const pipeline = new DataTrackIncomingPipeline({ info, encryptionProvider }); + const pipeline = new DataTrackOutgoingPipeline({ info, encryptionProvider }); this.descriptors.set( info.pubHandle, diff --git a/src/room/data-track/incoming/pipeline.ts b/src/room/data-track/outgoing/pipeline.ts similarity index 64% rename from src/room/data-track/incoming/pipeline.ts rename to src/room/data-track/outgoing/pipeline.ts index 5fe08b3700..561134bd3a 100644 --- a/src/room/data-track/incoming/pipeline.ts +++ b/src/room/data-track/outgoing/pipeline.ts @@ -1,21 +1,21 @@ import { LivekitReasonedError } from '../../errors'; -import { EncryptedPayload, type EncryptionProvider } from '../e2ee'; +import { type EncryptedPayload, type EncryptionProvider } from '../e2ee'; import type { DataTrackInfo } from '../track'; import DataTrackPacketizer, { DataTrackPacketizerError, DataTrackPacketizerReason } from '../packetizer'; -import { DataTrackFrame } from '../frame'; -import { Throws } from '../../../utils/throws'; +import { type DataTrackFrame } from '../frame'; +import { type Throws } from '../../../utils/throws'; import { DataTrackPacket } from '../packet'; import { DataTrackE2eeExtension } from '../packet/extensions'; -enum DataTrackIncomingPipelineErrorReason { +enum DataTrackOutgoingPipelineErrorReason { Packetizer = 0, Encryption = 1, } -class DataTrackIncomingPipelineError< - Reason extends DataTrackIncomingPipelineErrorReason, +class DataTrackOutgoingPipelineError< + Reason extends DataTrackOutgoingPipelineErrorReason, > extends LivekitReasonedError { - readonly name = 'DataTrackIncomingPipelineError'; + readonly name = 'DataTrackOutgoingPipelineError'; reason: Reason; @@ -24,15 +24,15 @@ class DataTrackIncomingPipelineError< constructor(message: string, reason: Reason, options?: { cause?: unknown }) { super(21, message, options); this.reason = reason; - this.reasonName = DataTrackIncomingPipelineErrorReason[reason]; + this.reasonName = DataTrackOutgoingPipelineErrorReason[reason]; } static packetizer(cause: DataTrackPacketizerError) { - return new DataTrackIncomingPipelineError("Error packetizing frame", DataTrackIncomingPipelineErrorReason.Packetizer, { cause }); + return new DataTrackOutgoingPipelineError("Error packetizing frame", DataTrackOutgoingPipelineErrorReason.Packetizer, { cause }); } static encryption(cause: unknown) { - return new DataTrackIncomingPipelineError("Error encrypting frame", DataTrackIncomingPipelineErrorReason.Encryption, { cause }); + return new DataTrackOutgoingPipelineError("Error encrypting frame", DataTrackOutgoingPipelineErrorReason.Encryption, { cause }); } } @@ -41,7 +41,7 @@ type Options = { encryptionProvider: EncryptionProvider | null; }; -export default class DataTrackIncomingPipeline { +export default class DataTrackOutgoingPipeline { private encryptionProvider: EncryptionProvider | null; private packetizer: DataTrackPacketizer; @@ -50,13 +50,13 @@ export default class DataTrackIncomingPipeline { constructor(options: Options) { this.encryptionProvider = options.encryptionProvider; - this.packetizer = new DataTrackPacketizer(options.info.pubHandle, DataTrackIncomingPipeline.TRANSPORT_MTU_BYTES); + this.packetizer = new DataTrackPacketizer(options.info.pubHandle, DataTrackOutgoingPipeline.TRANSPORT_MTU_BYTES); } *processFrame(frame: DataTrackFrame): Throws< Generator, - | DataTrackIncomingPipelineError - | DataTrackIncomingPipelineError + | DataTrackOutgoingPipelineError + | DataTrackOutgoingPipelineError > { let encryptedFrame = this.encryptIfNeeded(frame); @@ -64,13 +64,13 @@ export default class DataTrackIncomingPipeline { yield* this.packetizer.packetize(encryptedFrame); } catch (error) { if (error instanceof DataTrackPacketizerError) { - throw DataTrackIncomingPipelineError.packetizer(error); + throw DataTrackOutgoingPipelineError.packetizer(error); } throw error; } } - encryptIfNeeded(frame: DataTrackFrame): Throws> { + encryptIfNeeded(frame: DataTrackFrame): Throws> { if (!this.encryptionProvider) { return frame; } @@ -79,7 +79,7 @@ export default class DataTrackIncomingPipeline { try { encryptedResult = this.encryptionProvider.encrypt(frame.payload); } catch (err) { - throw DataTrackIncomingPipelineError.encryption(err); + throw DataTrackOutgoingPipelineError.encryption(err); } frame.payload = encryptedResult.payload; From 762706b2a291549bcc44e513b11d72b3b33cda53 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Feb 2026 11:59:21 -0500 Subject: [PATCH 07/43] feat: wire together local data track <-> outgoing manager --- src/room/data-track/outgoing/manager.ts | 41 +++++++++++++++++++++---- src/room/data-track/track.ts | 37 +++++++++++++++++++++- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/room/data-track/outgoing/manager.ts b/src/room/data-track/outgoing/manager.ts index 2897b27035..12906dc674 100644 --- a/src/room/data-track/outgoing/manager.ts +++ b/src/room/data-track/outgoing/manager.ts @@ -5,14 +5,13 @@ import { LoggerNames, getLogger } from '../../../logger'; import { Future } from '../../utils'; import { DataTrackHandle, DataTrackHandleAllocator } from '../handle'; import { type EncryptionProvider } from '../e2ee'; -import { type DataTrackInfo } from '../track'; +import { LocalDataTrack, type DataTrackInfo } from '../track'; import DataTrackOutgoingPipeline from './pipeline'; +import type { DataTrackFrame } from '../frame'; const log = getLogger(LoggerNames.DataTracks); -type LocalDataTrack = { info: DataTrackInfo; pipeline: DataTrackOutgoingPipeline }; - -type PendingDescriptor = { +export type PendingDescriptor = { type: 'pending'; completionFuture: Future< LocalDataTrack, @@ -22,7 +21,7 @@ type PendingDescriptor = { | DataTrackPublishError >; }; -type ActiveDescriptor = { +export type ActiveDescriptor = { type: 'active'; info: DataTrackInfo; // FIXME: add track task fields here. @@ -36,6 +35,8 @@ type DataTrackOutgoingManagerCallbacks = { sfuPublishRequest: (event: {handle: DataTrackHandle, name: string, usesE2ee: boolean}) => void; /** Request sent to the SFU to unpublish a track. */ sfuUnpublishRequest: (event: {handle: DataTrackHandle}) => void; + /** Serialized packets are ready to be sent over the transport. */ + packetsAvailable: (event: { bytes: Uint8Array, signal?: AbortSignal }) => void; }; /** Options for publishing a data track. */ @@ -170,6 +171,34 @@ export class DataTrackOutgoingManager extends (EventEmitter as new () => TypedEm this.encryptionProvider = options.decryptionProvider ?? null; } + /** + * Used by attached {@link LocalDataTrack} instances to query their associated descriptor info. + * @internal + */ + getDescriptor(handle: DataTrackHandle) { + return this.descriptors.get(handle) ?? null; + } + + /** Used by attached {@link LocalDataTrack} instances to broadcast data track packets to other + * subscribers. + * @internal + */ + trySend(handle: DataTrackHandle, frame: DataTrackFrame, options: { signal?: AbortSignal }) { + const descriptor = this.getDescriptor(handle); + if (descriptor?.type !== 'active') { + // return Err(PushFrameError::new(frame, PushFrameErrorReason::TrackUnpublished)); + throw new Error("Pipeline not created, local data track not yet published."); + } + + // FIXME: catch and drop processFrame error? That is what the rust implementation is doing. + // .inspect_err(|err| log::debug!("Process failed: {}", err)) + for (const packet of descriptor.pipeline.processFrame(frame)) { + // .inspect_err(|err| log::debug!("Cannot send packet to transport: {}", err)); + this.emit("packetsAvailable", { bytes: packet.toBinary(), signal }); + } + } + + /** Process an incoming command and control event ({@link InputEvent}). */ async handle(event: InputEvent) { switch (event.type) { case 'publishRequest': @@ -309,7 +338,7 @@ export class DataTrackOutgoingManager extends (EventEmitter as new () => TypedEm // FIXME: create local data track // let inner = LocalTrackInner { frame_tx, published_tx }; // return LocalDataTrack::new(info, inner) - return { info, pipeline } as LocalDataTrack; + return new LocalDataTrack(info, this); } private removeDescriptorIfExists(handle: DataTrackHandle) { diff --git a/src/room/data-track/track.ts b/src/room/data-track/track.ts index 4440ac5442..84920daf9c 100644 --- a/src/room/data-track/track.ts +++ b/src/room/data-track/track.ts @@ -1,4 +1,6 @@ -import type { DataTrackHandle } from "./handle"; +import type { DataTrackFrame } from "./frame"; +import { type DataTrackHandle } from "./handle"; +import { type DataTrackOutgoingManager } from "./outgoing/manager"; export type DataTrackSid = string; @@ -10,3 +12,36 @@ export type DataTrackInfo = { usesE2ee: boolean, }; +export class LocalDataTrack { + info: DataTrackInfo; + + protected manager: DataTrackOutgoingManager; + + constructor(info: DataTrackInfo, manager: DataTrackOutgoingManager) { + this.info = info; + this.manager = manager; + } + + /** The raw descriptor from the manager containing the internal state for this local track. */ + protected get descriptor() { + return this.manager.getDescriptor(this.info.pubHandle); + } + + isPublished() { + return this.descriptor?.type === "active"; + } + + /** Try pushing a frame to subscribers of the track. + * + * Pushing a frame can fail for several reasons: + * + * - The track has been unpublished by the local participant or SFU + * - The room is no longer connected + * - Frames are being pushed too fast (FIXME: this isn't the case in the js implementation?) + */ + tryPush(frame: DataTrackFrame, options: { signal?: AbortSignal }) { + // FIXME: rust implementation maps errors to dropped here? + // .map_err(|err| PushFrameError::new(err.into_inner(), PushFrameErrorReason::Dropped)) + return this.manager.trySend(this.info.pubHandle, frame, options); + } +} From 098101a25e279843f5c9f32db15928443125dd91 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Feb 2026 12:38:00 -0500 Subject: [PATCH 08/43] fix: update existing tests to get them to pass again Biggest fixed isssue: packetizer implementation payload seeking needed some updates because DataTrackFrame["payload"] is now Uint8Array, not ArrayBuffer. --- src/room/data-track/depacketizer.test.ts | 2 +- src/room/data-track/handle.test.ts | 2 +- src/room/data-track/packetizer.test.ts | 6 +++--- src/room/data-track/packetizer.ts | 6 +++++- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/room/data-track/depacketizer.test.ts b/src/room/data-track/depacketizer.test.ts index 9cf74123a8..9a35ec9069 100644 --- a/src/room/data-track/depacketizer.test.ts +++ b/src/room/data-track/depacketizer.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DataTrackDepacketizer } from './depacketizer'; +import DataTrackDepacketizer from './depacketizer'; import { DataTrackHandle } from './handle'; import { DataTrackPacket, DataTrackPacketHeader, FrameMarker } from './packet'; import { DataTrackTimestamp, WrapAroundUnsignedInt } from './utils'; diff --git a/src/room/data-track/handle.test.ts b/src/room/data-track/handle.test.ts index 81592e46e3..c486c4a8c0 100644 --- a/src/room/data-track/handle.test.ts +++ b/src/room/data-track/handle.test.ts @@ -4,7 +4,7 @@ import { DataTrackHandle } from './handle'; describe('DataTrackHandle', () => { it('should parse handle raw inputs', () => { - expect(DataTrackHandle.fromNumber(3).value).toEqual(3); + expect(DataTrackHandle.fromNumber(3)).toEqual(3); expect(() => DataTrackHandle.fromNumber(0)).toThrow('0x0 is a reserved value'); expect(() => DataTrackHandle.fromNumber(9999999)).toThrow( 'Value too large to be a valid track handle', diff --git a/src/room/data-track/packetizer.test.ts b/src/room/data-track/packetizer.test.ts index 6912a5dba7..bbd224cb24 100644 --- a/src/room/data-track/packetizer.test.ts +++ b/src/room/data-track/packetizer.test.ts @@ -4,7 +4,7 @@ import { DataTrackFrame } from './frame'; import { DataTrackHandle } from './handle'; import { FrameMarker } from './packet'; import { DataTrackExtensions } from './packet/extensions'; -import { DataTrackPacketizer } from './packetizer'; +import DataTrackPacketizer from './packetizer'; import { DataTrackTimestamp } from './utils'; describe('DataTrackPacketizer', () => { @@ -13,7 +13,7 @@ describe('DataTrackPacketizer', () => { const packets = Array.from( packetizer.packetize( { - payload: new Uint8Array(300).fill(0xbe).buffer, + payload: new Uint8Array(300).fill(0xbe), extensions: new DataTrackExtensions(), }, { now: DataTrackTimestamp.fromRtpTicks(1804548298) }, @@ -91,7 +91,7 @@ describe('DataTrackPacketizer', () => { const packetizer = new DataTrackPacketizer(DataTrackHandle.fromNumber(1), mtuSizeBytes); const frame: DataTrackFrame = { - payload: new Uint8Array(payloadSizeBytes).fill(0xab).buffer, + payload: new Uint8Array(payloadSizeBytes).fill(0xab), extensions: new DataTrackExtensions(), }; const packets = Array.from( diff --git a/src/room/data-track/packetizer.ts b/src/room/data-track/packetizer.ts index 671dc04c34..afa35c673f 100644 --- a/src/room/data-track/packetizer.ts +++ b/src/room/data-track/packetizer.ts @@ -120,7 +120,11 @@ export default class DataTrackPacketizer { // ... and the last packet will be as long as it needs to be to finish out the buffer. frame.payload.byteLength - indexBytes, ); - const packetPayload = new Uint8Array(frame.payload, indexBytes, packetPayloadLengthBytes); + const packetPayload = new Uint8Array( + frame.payload.buffer, + frame.payload.byteOffset + indexBytes, + packetPayloadLengthBytes + ); yield new DataTrackPacket(packetHeader, packetPayload); } From e0946e30048352ffa664ff9b94805d577465fd8e Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Feb 2026 15:17:14 -0500 Subject: [PATCH 09/43] feat: add initial outgoing data track manager tests --- src/room/data-track/outgoing/manager.test.ts | 113 ++++++++++++++++++ src/room/data-track/outgoing/manager.ts | 116 ++++++++++--------- src/room/data-track/track.ts | 6 +- 3 files changed, 176 insertions(+), 59 deletions(-) create mode 100644 src/room/data-track/outgoing/manager.test.ts diff --git a/src/room/data-track/outgoing/manager.test.ts b/src/room/data-track/outgoing/manager.test.ts new file mode 100644 index 0000000000..dbc81c4e72 --- /dev/null +++ b/src/room/data-track/outgoing/manager.test.ts @@ -0,0 +1,113 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import DataTrackOutgoingManager from './manager'; +import type TypedEventEmitter from 'typed-emitter'; +import { type EventMap } from 'typed-emitter'; +import { Future } from '../../utils'; + +/** A test helper to listen to events received by an event emitter and allow them to be imperatively + * queried after the fact. */ +function subscribeToEvents< + EventNames extends keyof Callbacks, + Callbacks extends EventMap, +>(eventEmitter: TypedEventEmitter, eventNames: Array) { + const nextEventListeners = new Map>>( + eventNames.map(eventName => [eventName, []]) + ); + const buffers = new Map>( + eventNames.map(eventName => [eventName, []]) + ); + + const eventHandlers = eventNames.map((eventName) => { + const onEvent = ((event: unknown) => { + const listeners = nextEventListeners.get(eventName)!; + if (listeners.length > 0) { + for (const listener of listeners) { + listener.resolve?.(event); + } + nextEventListeners.set(eventName, []); + } else { + buffers.get(eventName)!.push(event); + } + }) as Callbacks[keyof Callbacks]; + return [eventName, onEvent] as [keyof Callbacks, Callbacks[keyof Callbacks]]; + }); + for (const [eventName, onEvent] of eventHandlers) { + eventEmitter.on(eventName, onEvent); + } + + return { + async waitFor< + EventName extends EventNames, + EventPayload extends Parameters[0], + >(eventName: EventName): Promise { + // If an event is already buffered which hasn't been processed yet, pull that off the buffer + // and use it. + const earliestBufferedEvent = buffers.get(eventName)!.shift(); + if (earliestBufferedEvent) { + return earliestBufferedEvent as EventPayload; + } + + // Otherwise wait for the next event to come in. + const future = new Future(); + nextEventListeners.get(eventName)!.push(future); + const nextEvent = await future.promise; + return nextEvent as EventPayload; + }, + unsubscribe: () => { + for (const [eventName, onEvent] of eventHandlers) { + eventEmitter.off(eventName, onEvent); + } + }, + }; +} + +describe('DataTrackOutgoingManager', () => { + it('should test track publishing', async () => { + const manager = new DataTrackOutgoingManager(); + const managerEvents = subscribeToEvents(manager, [ + "sfuPublishRequest", + "sfuUnpublishRequest", + ]); + + // 1. Publish a data track + const publishRequestPromise = manager.handlePublishRequest({ + type: "publishRequest", + options: { name: "test" }, + }); + + // 2. This publish request should be sent along to the SFU + const sfuPublishEvent = await managerEvents.waitFor("sfuPublishRequest"); + expect(sfuPublishEvent.name).toStrictEqual("test"); + expect(sfuPublishEvent.usesE2ee).toStrictEqual(false); + const handle = sfuPublishEvent.handle; + + // 3. Respond to the SFU publish request with a response + manager.handleSfuPublishResponse({ + type: "sfuPublishResponse", + handle, + result: { + type: 'ok', + data: { + sid: 'bogus-sid', + pubHandle: sfuPublishEvent.handle, + name: "test", + usesE2ee: false, + }, + }, + }); + + // Make sure that the original input event resolves. + const localDataTrack = await publishRequestPromise; + expect(localDataTrack.isPublished()).toStrictEqual(true); + + // Unpublish data track + const unpublishRequestPromise = manager.handleUnpublishRequest({ type: "unpublishRequest", handle }); + const sfuUnpublishEvent = await managerEvents.waitFor("sfuUnpublishRequest"); + + await unpublishRequestPromise; + + // Make sure data track is no longer + expect(manager.getDescriptor(1)).toStrictEqual(null); + }); +}); diff --git a/src/room/data-track/outgoing/manager.ts b/src/room/data-track/outgoing/manager.ts index 12906dc674..e4c7e04188 100644 --- a/src/room/data-track/outgoing/manager.ts +++ b/src/room/data-track/outgoing/manager.ts @@ -8,6 +8,7 @@ import { type EncryptionProvider } from '../e2ee'; import { LocalDataTrack, type DataTrackInfo } from '../track'; import DataTrackOutgoingPipeline from './pipeline'; import type { DataTrackFrame } from '../frame'; +import { DataTrackExtensions } from '../packet/extensions'; const log = getLogger(LoggerNames.DataTracks); @@ -28,13 +29,18 @@ export type ActiveDescriptor = { pipeline: DataTrackOutgoingPipeline, }; -type Descriptor = PendingDescriptor | ActiveDescriptor; +// FIXME: rust doesn't have this unpublishing descriptor, is it a good idea? +export type UnpublishingDescriptor = { + type: 'unpublishing'; + completionFuture: Future; +}; +type Descriptor = PendingDescriptor | ActiveDescriptor | UnpublishingDescriptor; -type DataTrackOutgoingManagerCallbacks = { +export type DataTrackOutgoingManagerCallbacks = { /** Request sent to the SFU to publish a track. */ - sfuPublishRequest: (event: {handle: DataTrackHandle, name: string, usesE2ee: boolean}) => void; + sfuPublishRequest: (event: { handle: DataTrackHandle, name: string, usesE2ee: boolean }) => void; /** Request sent to the SFU to unpublish a track. */ - sfuUnpublishRequest: (event: {handle: DataTrackHandle}) => void; + sfuUnpublishRequest: (event: { handle: DataTrackHandle }) => void; /** Serialized packets are ready to be sent over the transport. */ packetsAvailable: (event: { bytes: Uint8Array, signal?: AbortSignal }) => void; }; @@ -68,15 +74,15 @@ type InputEventSfuUnPublishResponse = { type: 'sfuUnpublishResponse', handle: Da /** Shutdown the manager and all associated tracks. */ type InputEventShutdown = { type: 'shutdown' }; -type InputEvent = - | InputEventPublishRequest - // FIXME: no cancelled event - // | { type: 'publishCancelled', handle: DataTrackHandle } - | InputEventQueryPublished - | InputEventUnpublishRequest - | InputEventSfuPublishResponse - | InputEventSfuUnPublishResponse - | InputEventShutdown; +// type InputEvent = +// | InputEventPublishRequest +// // FIXME: no cancelled event +// // | { type: 'publishCancelled', handle: DataTrackHandle } +// | InputEventQueryPublished +// | InputEventUnpublishRequest +// | InputEventSfuPublishResponse +// | InputEventSfuUnPublishResponse +// | InputEventShutdown; type DataTrackLocalManagerOptions = { /** @@ -160,15 +166,18 @@ class DataTrackPublishError< } } -export class DataTrackOutgoingManager extends (EventEmitter as new () => TypedEmitter) { +// FIXME: use this value in the publish +const PUBLISH_TIMEOUT_SECONDS = 10; + +export default class DataTrackOutgoingManager extends (EventEmitter as new () => TypedEmitter) { private encryptionProvider: EncryptionProvider | null; private handleAllocator = new DataTrackHandleAllocator(); // FIXME: key of this map is the same as the value Descriptor["info"]["pubHandle"] private descriptors = new Map(); - constructor(options: DataTrackLocalManagerOptions) { + constructor(options?: DataTrackLocalManagerOptions) { super(); - this.encryptionProvider = options.decryptionProvider ?? null; + this.encryptionProvider = options?.decryptionProvider ?? null; } /** @@ -183,47 +192,28 @@ export class DataTrackOutgoingManager extends (EventEmitter as new () => TypedEm * subscribers. * @internal */ - trySend(handle: DataTrackHandle, frame: DataTrackFrame, options: { signal?: AbortSignal }) { + tryProcessAndSend(handle: DataTrackHandle, payload: Uint8Array, options: { signal?: AbortSignal }) { const descriptor = this.getDescriptor(handle); if (descriptor?.type !== 'active') { // return Err(PushFrameError::new(frame, PushFrameErrorReason::TrackUnpublished)); throw new Error("Pipeline not created, local data track not yet published."); } + const frame: DataTrackFrame = { + payload, + extensions: new DataTrackExtensions(), + }; + // FIXME: catch and drop processFrame error? That is what the rust implementation is doing. // .inspect_err(|err| log::debug!("Process failed: {}", err)) for (const packet of descriptor.pipeline.processFrame(frame)) { // .inspect_err(|err| log::debug!("Cannot send packet to transport: {}", err)); - this.emit("packetsAvailable", { bytes: packet.toBinary(), signal }); - } - } - - /** Process an incoming command and control event ({@link InputEvent}). */ - async handle(event: InputEvent) { - switch (event.type) { - case 'publishRequest': - return this.handlePublishRequest(event); - case 'queryPublished': - return this.handleQueryPublished(event); - case 'unpublishRequest': - return this.handleUnpublishRequest(event); - case 'sfuPublishResponse': - return this.handleSfuPublishResponse(event); - case 'sfuUnpublishResponse': - return this.handleSfuUnpublishResponse(event); - case 'shutdown': - return this.handleShutdown(event); - default: - // Make sure there is a typescript error if not all the input events are handled above. - event satisfies never; - - // @throws-transformer ignore - this should be treated as a "panic" and not be caught - throw new Error(`DataTrackLocalManager.handle: Unknown event type ${(event as InputEvent)?.type} found.`); + this.emit("packetsAvailable", { bytes: packet.toBinary(), signal: options?.signal }); } } /** Client requested to publish a track. */ - private async handlePublishRequest(event: InputEventPublishRequest) { + async handlePublishRequest(event: InputEventPublishRequest) { const handle = this.handleAllocator.get(); if (!handle) { throw DataTrackPublishError.limitReached(); @@ -255,7 +245,7 @@ export class DataTrackOutgoingManager extends (EventEmitter as new () => TypedEm this.emit('sfuPublishRequest', { handle, name: event.options.name, - usesE2ee: this.encryptionProvider === null, + usesE2ee: this.encryptionProvider !== null, }); const localDataTrack = await descriptor.completionFuture.promise; @@ -263,7 +253,7 @@ export class DataTrackOutgoingManager extends (EventEmitter as new () => TypedEm return localDataTrack; } - private handleQueryPublished(event: InputEventQueryPublished) { + handleQueryPublished(event: InputEventQueryPublished) { const descriptorInfos = Array.from(this.descriptors.values()) .filter((descriptor): descriptor is ActiveDescriptor => descriptor.type === "active") .map(descriptor => descriptor.info); @@ -272,14 +262,20 @@ export class DataTrackOutgoingManager extends (EventEmitter as new () => TypedEm } /** Client request to unpublish a track. */ - private handleUnpublishRequest(event: InputEventUnpublishRequest) { - this.removeDescriptorIfExists(event.handle); + async handleUnpublishRequest(event: InputEventUnpublishRequest) { + const descriptor: UnpublishingDescriptor = { + type: 'unpublishing', + completionFuture: new Future(), + }; + this.descriptors.set(event.handle, descriptor); this.emit('sfuUnpublishRequest', { handle: event.handle }); + + await descriptor.completionFuture.promise; } /** SFU responded to a request to publish a data track. */ - private handleSfuPublishResponse(event: InputEventSfuPublishResponse) { + handleSfuPublishResponse(event: InputEventSfuPublishResponse) { const descriptor = this.descriptors.get(event.handle); if (!descriptor) { // FIXME: should this be an internal error? @@ -301,12 +297,25 @@ export class DataTrackOutgoingManager extends (EventEmitter as new () => TypedEm } /** SFU notification that a track has been unpublished. */ - private handleSfuUnpublishResponse(event: InputEventSfuUnPublishResponse) { - this.removeDescriptorIfExists(event.handle); + handleSfuUnpublishResponse(event: InputEventSfuUnPublishResponse) { + const descriptor = this.descriptors.get(event.handle); + if (!descriptor) { + // FIXME: should this be an internal error? + log.warn(`No descriptor for ${event.handle}`); + return; + } + this.descriptors.delete(event.handle); + + if (descriptor.type !== 'unpublishing') { + log.warn(`Track ${event.handle} hasn't been put into unpublishing status`); + return; + } + + descriptor.completionFuture.resolve?.(); } /** Shuts down the manager and all associated tracks. */ - private handleShutdown(_event: InputEventShutdown) { + handleShutdown(_event: InputEventShutdown) { for (const descriptor of this.descriptors.values()) { switch (descriptor.type) { case 'pending': @@ -340,9 +349,4 @@ export class DataTrackOutgoingManager extends (EventEmitter as new () => TypedEm // return LocalDataTrack::new(info, inner) return new LocalDataTrack(info, this); } - - private removeDescriptorIfExists(handle: DataTrackHandle) { - // FIXME: cleanup active descriptors, stop track task, etc - this.descriptors.delete(handle); - } } diff --git a/src/room/data-track/track.ts b/src/room/data-track/track.ts index 84920daf9c..9cc01a99bd 100644 --- a/src/room/data-track/track.ts +++ b/src/room/data-track/track.ts @@ -1,6 +1,6 @@ import type { DataTrackFrame } from "./frame"; import { type DataTrackHandle } from "./handle"; -import { type DataTrackOutgoingManager } from "./outgoing/manager"; +import type DataTrackOutgoingManager from "./outgoing/manager"; export type DataTrackSid = string; @@ -39,9 +39,9 @@ export class LocalDataTrack { * - The room is no longer connected * - Frames are being pushed too fast (FIXME: this isn't the case in the js implementation?) */ - tryPush(frame: DataTrackFrame, options: { signal?: AbortSignal }) { + tryPush(payload: DataTrackFrame["payload"], options: { signal?: AbortSignal }) { // FIXME: rust implementation maps errors to dropped here? // .map_err(|err| PushFrameError::new(err.into_inner(), PushFrameErrorReason::Dropped)) - return this.manager.trySend(this.info.pubHandle, frame, options); + return this.manager.tryProcessAndSend(this.info.pubHandle, payload, options); } } From d7fec15a95a6202b128ec396729710ba72507ec8 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Feb 2026 15:17:41 -0500 Subject: [PATCH 10/43] refactor: separate out outgoing events --- src/room/data-track/outgoing/manager.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/room/data-track/outgoing/manager.ts b/src/room/data-track/outgoing/manager.ts index e4c7e04188..b300a53133 100644 --- a/src/room/data-track/outgoing/manager.ts +++ b/src/room/data-track/outgoing/manager.ts @@ -36,13 +36,28 @@ export type UnpublishingDescriptor = { }; type Descriptor = PendingDescriptor | ActiveDescriptor | UnpublishingDescriptor; +export type OutputEventSfuPublishRequest = { + handle: DataTrackHandle; + name: string; + usesE2ee: boolean; +}; + +export type OutputEventSfuUnpublishRequest = { + handle: DataTrackHandle; +}; + +export type OutputEventPacketsAvailable = { + bytes: Uint8Array; + signal?: AbortSignal; +}; + export type DataTrackOutgoingManagerCallbacks = { /** Request sent to the SFU to publish a track. */ - sfuPublishRequest: (event: { handle: DataTrackHandle, name: string, usesE2ee: boolean }) => void; + sfuPublishRequest: (event: OutputEventSfuPublishRequest) => void; /** Request sent to the SFU to unpublish a track. */ - sfuUnpublishRequest: (event: { handle: DataTrackHandle }) => void; + sfuUnpublishRequest: (event: OutputEventSfuUnpublishRequest) => void; /** Serialized packets are ready to be sent over the transport. */ - packetsAvailable: (event: { bytes: Uint8Array, signal?: AbortSignal }) => void; + packetsAvailable: (event: OutputEventPacketsAvailable) => void; }; /** Options for publishing a data track. */ From 2c001a0d8b4bca0fd7c6fec8a80ce3896a8cef6d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Feb 2026 16:46:14 -0500 Subject: [PATCH 11/43] feat: get outgoing data tracks manager mostly tested --- src/room/data-track/outgoing/manager.test.ts | 181 +++++++++++++++++-- src/room/data-track/outgoing/manager.ts | 82 +++++---- 2 files changed, 221 insertions(+), 42 deletions(-) diff --git a/src/room/data-track/outgoing/manager.test.ts b/src/room/data-track/outgoing/manager.test.ts index dbc81c4e72..3844fd57db 100644 --- a/src/room/data-track/outgoing/manager.test.ts +++ b/src/room/data-track/outgoing/manager.test.ts @@ -1,15 +1,18 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import DataTrackOutgoingManager from './manager'; +import DataTrackOutgoingManager, { DataTrackOutgoingManagerCallbacks, DataTrackPublishError, Descriptor, InputEventQueryPublished, OutputEventSfuPublishRequest } from './manager'; import type TypedEventEmitter from 'typed-emitter'; import { type EventMap } from 'typed-emitter'; import { Future } from '../../utils'; +import { DataTrackHandle } from '../handle'; +import DataTrackOutgoingPipeline from './pipeline'; +import { DataTrackPacket, FrameMarker } from '../packet'; /** A test helper to listen to events received by an event emitter and allow them to be imperatively * queried after the fact. */ function subscribeToEvents< - EventNames extends keyof Callbacks, Callbacks extends EventMap, + EventNames extends keyof Callbacks = keyof Callbacks, >(eventEmitter: TypedEventEmitter, eventNames: Array) { const nextEventListeners = new Map>>( eventNames.map(eventName => [eventName, []]) @@ -38,8 +41,8 @@ function subscribeToEvents< return { async waitFor< - EventName extends EventNames, EventPayload extends Parameters[0], + EventName extends EventNames = EventNames, >(eventName: EventName): Promise { // If an event is already buffered which hasn't been processed yet, pull that off the buffer // and use it. @@ -63,12 +66,9 @@ function subscribeToEvents< } describe('DataTrackOutgoingManager', () => { - it('should test track publishing', async () => { + it('should test track publishing (ok case)', async () => { const manager = new DataTrackOutgoingManager(); - const managerEvents = subscribeToEvents(manager, [ - "sfuPublishRequest", - "sfuUnpublishRequest", - ]); + const managerEvents = subscribeToEvents(manager, ["sfuPublishRequest"]); // 1. Publish a data track const publishRequestPromise = manager.handlePublishRequest({ @@ -82,7 +82,7 @@ describe('DataTrackOutgoingManager', () => { expect(sfuPublishEvent.usesE2ee).toStrictEqual(false); const handle = sfuPublishEvent.handle; - // 3. Respond to the SFU publish request with a response + // 3. Respond to the SFU publish request with an OK response manager.handleSfuPublishResponse({ type: "sfuPublishResponse", handle, @@ -100,14 +100,173 @@ describe('DataTrackOutgoingManager', () => { // Make sure that the original input event resolves. const localDataTrack = await publishRequestPromise; expect(localDataTrack.isPublished()).toStrictEqual(true); + }); + + it('should test track publishing (error case)', async () => { + const manager = new DataTrackOutgoingManager(); + const managerEvents = subscribeToEvents(manager, ["sfuPublishRequest"]); + + // 1. Publish a data track + const publishRequestPromise = manager.handlePublishRequest({ + type: "publishRequest", + options: { name: "test" }, + }); + + // 2. This publish request should be sent along to the SFU + const sfuPublishEvent = await managerEvents.waitFor("sfuPublishRequest"); + + // 3. Respond to the SFU publish request with an ERROR response + manager.handleSfuPublishResponse({ + type: "sfuPublishResponse", + handle: sfuPublishEvent.handle, + result: { + type: 'error', + error: DataTrackPublishError.limitReached(), + }, + }); + + // Make sure that the rejection bubbles back to the caller + expect(publishRequestPromise).rejects.toThrowError("Data track publication limit reached"); + }); + + it.each([ + // Single packet payload case + [ + new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]), + [{ + "header": { + extensions: { + e2ee: null, + userTimestamp: null, + }, + frameNumber: 0, + marker: FrameMarker.Single, + sequence: 0, + timestamp: 0, // (zeroed out in the test, since this isn't mocked) + trackHandle: 5, + }, + "payload": new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]), + }], + ], + + // Multi packet payload case + [ + new Uint8Array(24_000).fill(0xbe), + [ + { + header: { + extensions: { + e2ee: null, + userTimestamp: null, + }, + frameNumber: 0, + marker: FrameMarker.Start, + sequence: 0, + timestamp: 0, // (zeroed out in the test, since this isn't mocked) + trackHandle: 5, + }, + payload: new Uint8Array(15988 /* 16k mtu - 12 header bytes */).fill(0xbe), + }, + { + header: { + extensions: { + e2ee: null, + userTimestamp: null, + }, + frameNumber: 0, + marker: FrameMarker.Final, + sequence: 1, + timestamp: 0, // (zeroed out in the test, since this isn't mocked) + trackHandle: 5, + }, + payload: new Uint8Array(8012 /* 24k payload - (16k mtu - 12 header bytes) */).fill(0xbe), + }, + ], + ], + ])('should test track payload sending', async (inputBytes: Uint8Array, outputPacketsJson: Array) => { + // Create a manager prefilled with a descriptor + const manager = DataTrackOutgoingManager.withDescriptors(new Map([ + [DataTrackHandle.fromNumber(5), Descriptor.active({ + sid: 'bogus-sid', + pubHandle: 5, + name: "test", + usesE2ee: false, + }, null)] + ])); + const managerEvents = subscribeToEvents(manager, ["packetsAvailable"]); + + const localDataTrack = manager.createLocalDataTrack(5)!; + expect(localDataTrack).not.toStrictEqual(null); + + // Kick off sending the bytes... + localDataTrack.tryPush(inputBytes); + + // ... and make sure the corresponding events are emitted to tell the SFU to send the packets + for (const outputPacketJson of outputPacketsJson) { + const packetBytes = await managerEvents.waitFor("packetsAvailable"); + const [packet] = DataTrackPacket.fromBinary(packetBytes.bytes); + + const packetJson = packet.toJSON(); + // (note: zero out the header timestamp because the date "now" isn't being mocked) + packetJson.header.timestamp = 0; + + expect(packetJson).toStrictEqual(outputPacketJson); + } + }); + + it('should test track unpublishing', async () => { + // Create a manager prefilled with a descriptor + const manager = DataTrackOutgoingManager.withDescriptors(new Map([ + [DataTrackHandle.fromNumber(5), Descriptor.active({ + sid: 'bogus-sid', + pubHandle: 5, + name: "test", + usesE2ee: false, + }, null)] + ])); + const managerEvents = subscribeToEvents(manager, ["sfuUnpublishRequest"]); + + // Make sure the descriptor is in there + expect(manager.getDescriptor(5)?.type).toStrictEqual("active"); // Unpublish data track - const unpublishRequestPromise = manager.handleUnpublishRequest({ type: "unpublishRequest", handle }); + const unpublishRequestPromise = manager.handleUnpublishRequest({ type: "unpublishRequest", handle: 5 }); + const sfuUnpublishEvent = await managerEvents.waitFor("sfuUnpublishRequest"); + expect(sfuUnpublishEvent.handle).toStrictEqual(5); + + manager.handleSfuUnpublishResponse({ type: "sfuUnpublishResponse", handle: 5 }); await unpublishRequestPromise; // Make sure data track is no longer - expect(manager.getDescriptor(1)).toStrictEqual(null); + expect(manager.getDescriptor(5)).toStrictEqual(null); + }); + + it('should query currently active descriptors', async () => { + // Create a manager prefilled with a descriptor + const manager = DataTrackOutgoingManager.withDescriptors(new Map([ + [DataTrackHandle.fromNumber(2), Descriptor.active({ + sid: 'bogus-sid-2', + pubHandle: 2, + name: "twotwotwo", + usesE2ee: false, + }, null)], + [DataTrackHandle.fromNumber(6), Descriptor.active({ + sid: 'bogus-sid-6', + pubHandle: 6, + name: "sixsixsix", + usesE2ee: false, + }, null)] + ])); + + const event: InputEventQueryPublished = { type: 'queryPublished', future: new Future() }; + manager.handleQueryPublished(event); + const result = await event.future.promise; + + expect(result).toStrictEqual([ + { sid: 'bogus-sid-2', pubHandle: 2, name: "twotwotwo", usesE2ee: false, }, + { sid: 'bogus-sid-6', pubHandle: 6, name: "sixsixsix", usesE2ee: false, }, + ]); }); }); diff --git a/src/room/data-track/outgoing/manager.ts b/src/room/data-track/outgoing/manager.ts index b300a53133..2230d91505 100644 --- a/src/room/data-track/outgoing/manager.ts +++ b/src/room/data-track/outgoing/manager.ts @@ -34,7 +34,26 @@ export type UnpublishingDescriptor = { type: 'unpublishing'; completionFuture: Future; }; -type Descriptor = PendingDescriptor | ActiveDescriptor | UnpublishingDescriptor; +export type Descriptor = PendingDescriptor | ActiveDescriptor | UnpublishingDescriptor; + +export const Descriptor = { + pending(): PendingDescriptor { + return { + type: 'pending', + completionFuture: new Future(), + }; + }, + active(info: DataTrackInfo, encryptionProvider: EncryptionProvider | null): ActiveDescriptor { + return { + type: 'active', + info, + pipeline: new DataTrackOutgoingPipeline({ info, encryptionProvider }), + }; + }, + unpublishing(): UnpublishingDescriptor { + return { type: 'unpublishing', completionFuture: new Future() }; + }, +}; export type OutputEventSfuPublishRequest = { handle: DataTrackHandle; @@ -71,7 +90,7 @@ type InputEventPublishRequest = { signal?: AbortSignal; }; -type InputEventQueryPublished = { +export type InputEventQueryPublished = { type: 'queryPublished', // FIXME: use onehsot future vs sending corresponding "-Response" event? future: Future, never>; @@ -135,7 +154,7 @@ enum DataTrackPublishErrorReason { Cancelled = 6, } -class DataTrackPublishError< +export class DataTrackPublishError< Reason extends DataTrackPublishErrorReason, > extends LivekitReasonedError { readonly name = 'DataTrackPublishError'; @@ -195,6 +214,12 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => this.encryptionProvider = options?.decryptionProvider ?? null; } + static withDescriptors(descriptors: Map) { + const manager = new DataTrackOutgoingManager(); + manager.descriptors = descriptors; + return manager; + } + /** * Used by attached {@link LocalDataTrack} instances to query their associated descriptor info. * @internal @@ -203,11 +228,19 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => return this.descriptors.get(handle) ?? null; } + createLocalDataTrack(handle: DataTrackHandle) { + const descriptor = this.getDescriptor(handle); + if (descriptor?.type !== 'active') { + return null; + } + return new LocalDataTrack(descriptor.info, this); + } + /** Used by attached {@link LocalDataTrack} instances to broadcast data track packets to other * subscribers. * @internal */ - tryProcessAndSend(handle: DataTrackHandle, payload: Uint8Array, options: { signal?: AbortSignal }) { + tryProcessAndSend(handle: DataTrackHandle, payload: Uint8Array, options?: { signal?: AbortSignal }) { const descriptor = this.getDescriptor(handle); if (descriptor?.type !== 'active') { // return Err(PushFrameError::new(frame, PushFrameErrorReason::TrackUnpublished)); @@ -238,7 +271,7 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => throw DataTrackPublishError.internal(new Error('Descriptor for handle already exists')); } - const descriptor: PendingDescriptor = { type: 'pending', completionFuture: new Future() }; + const descriptor = Descriptor.pending(); this.descriptors.set(handle, descriptor); const onAbort = () => { @@ -278,10 +311,7 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => /** Client request to unpublish a track. */ async handleUnpublishRequest(event: InputEventUnpublishRequest) { - const descriptor: UnpublishingDescriptor = { - type: 'unpublishing', - completionFuture: new Future(), - }; + const descriptor = Descriptor.unpublishing(); this.descriptors.set(event.handle, descriptor); this.emit('sfuUnpublishRequest', { handle: event.handle }); @@ -305,7 +335,18 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => } if (event.result.type === 'ok') { - descriptor.completionFuture.resolve?.(this.createLocalTrack(event.result.data)); + const info = event.result.data; + + const encryptionProvider = info.usesE2ee ? this.encryptionProvider : null; + this.descriptors.set(info.pubHandle, Descriptor.active(info, encryptionProvider)); + + const localDataTrack = this.createLocalDataTrack(info.pubHandle); + if (!localDataTrack) { + // @throws-transformer ignore - this should be treated as a "panic" and not be caught + throw new Error("DataTrackOutgoingManager.handleSfuPublishResponse: localDataTrack was not created after active descriptor stored."); + } + + descriptor.completionFuture.resolve?.(localDataTrack); } else { descriptor.completionFuture.reject?.(event.result.error); } @@ -343,25 +384,4 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => } this.descriptors.clear(); } - - private createLocalTrack(info: DataTrackInfo) { - // FIXME: initialize track task in here! - const encryptionProvider = info.usesE2ee ? this.encryptionProvider : null; - - const pipeline = new DataTrackOutgoingPipeline({ info, encryptionProvider }); - - this.descriptors.set( - info.pubHandle, - { - type: 'active', - info, - pipeline, - }, - ); - - // FIXME: create local data track - // let inner = LocalTrackInner { frame_tx, published_tx }; - // return LocalDataTrack::new(info, inner) - return new LocalDataTrack(info, this); - } } From 7d140942f75ca77af6051fbf425a58e3cc063b57 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Feb 2026 16:55:20 -0500 Subject: [PATCH 12/43] refactor: move events into separate file --- src/room/data-track/outgoing/events.ts | 72 +++++++++++++++++++++++++ src/room/data-track/outgoing/manager.ts | 57 +------------------- 2 files changed, 74 insertions(+), 55 deletions(-) create mode 100644 src/room/data-track/outgoing/events.ts diff --git a/src/room/data-track/outgoing/events.ts b/src/room/data-track/outgoing/events.ts new file mode 100644 index 0000000000..2349925898 --- /dev/null +++ b/src/room/data-track/outgoing/events.ts @@ -0,0 +1,72 @@ +import { type Future } from "../../utils"; +import { type DataTrackHandle } from "../handle"; +import { type DataTrackInfo } from "../track"; +import { type DataTrackPublishError, type DataTrackPublishErrorReason } from "./manager"; + +/** Options for publishing a data track. */ +type DataTrackOptions = { + name: string, +}; + +/** Client requested to publish a track. */ +export type InputEventPublishRequest = { + type: 'publishRequest'; + options: DataTrackOptions; + signal?: AbortSignal; +}; + +/** Get information about all currently published tracks. */ +export type InputEventQueryPublished = { + type: 'queryPublished', + // FIXME: use onehsot future vs sending corresponding "-Response" event? + future: Future, never>; +}; + +/** Client request to unpublish a track (internal). */ +export type InputEventUnpublishRequest = { type: 'unpublishRequest', handle: DataTrackHandle }; + +/** SFU responded to a request to publish a data track. */ +export type InputEventSfuPublishResponse = { + type: 'sfuPublishResponse'; + handle: DataTrackHandle; + result: ( + | { type: 'ok', data: DataTrackInfo } + | { type: 'error', error: DataTrackPublishError } + ); +}; + +/** SFU notification that a track has been unpublished. */ +export type InputEventSfuUnPublishResponse = { type: 'sfuUnpublishResponse', handle: DataTrackHandle }; + +/** Shutdown the manager and all associated tracks. */ +export type InputEventShutdown = { type: 'shutdown' }; + +// type InputEvent = +// | InputEventPublishRequest +// // FIXME: no cancelled event +// // | { type: 'publishCancelled', handle: DataTrackHandle } +// | InputEventQueryPublished +// | InputEventUnpublishRequest +// | InputEventSfuPublishResponse +// | InputEventSfuUnPublishResponse +// | InputEventShutdown; + + +/** Request sent to the SFU to publish a track. */ +export type OutputEventSfuPublishRequest = { + handle: DataTrackHandle; + name: string; + usesE2ee: boolean; +}; + +/** Request sent to the SFU to unpublish a track. */ +export type OutputEventSfuUnpublishRequest = { + handle: DataTrackHandle; +}; + +/** Serialized packets are ready to be sent over the transport. */ +export type OutputEventPacketsAvailable = { + bytes: Uint8Array; + signal?: AbortSignal; +}; + diff --git a/src/room/data-track/outgoing/manager.ts b/src/room/data-track/outgoing/manager.ts index 2230d91505..47037965d3 100644 --- a/src/room/data-track/outgoing/manager.ts +++ b/src/room/data-track/outgoing/manager.ts @@ -9,6 +9,7 @@ import { LocalDataTrack, type DataTrackInfo } from '../track'; import DataTrackOutgoingPipeline from './pipeline'; import type { DataTrackFrame } from '../frame'; import { DataTrackExtensions } from '../packet/extensions'; +import { type InputEventPublishRequest, type InputEventQueryPublished, type InputEventSfuPublishResponse, type InputEventSfuUnPublishResponse, type InputEventShutdown, type InputEventUnpublishRequest, type OutputEventPacketsAvailable, type OutputEventSfuPublishRequest, type OutputEventSfuUnpublishRequest } from './events'; const log = getLogger(LoggerNames.DataTracks); @@ -55,21 +56,6 @@ export const Descriptor = { }, }; -export type OutputEventSfuPublishRequest = { - handle: DataTrackHandle; - name: string; - usesE2ee: boolean; -}; - -export type OutputEventSfuUnpublishRequest = { - handle: DataTrackHandle; -}; - -export type OutputEventPacketsAvailable = { - bytes: Uint8Array; - signal?: AbortSignal; -}; - export type DataTrackOutgoingManagerCallbacks = { /** Request sent to the SFU to publish a track. */ sfuPublishRequest: (event: OutputEventSfuPublishRequest) => void; @@ -79,45 +65,6 @@ export type DataTrackOutgoingManagerCallbacks = { packetsAvailable: (event: OutputEventPacketsAvailable) => void; }; -/** Options for publishing a data track. */ -type DataTrackOptions = { - name: string, -}; - -type InputEventPublishRequest = { - type: 'publishRequest'; - options: DataTrackOptions; - signal?: AbortSignal; -}; - -export type InputEventQueryPublished = { - type: 'queryPublished', - // FIXME: use onehsot future vs sending corresponding "-Response" event? - future: Future, never>; -}; -type InputEventUnpublishRequest = { type: 'unpublishRequest', handle: DataTrackHandle }; -type InputEventSfuPublishResponse = { - type: 'sfuPublishResponse'; - handle: DataTrackHandle; - result: ( - | { type: 'ok', data: DataTrackInfo } - | { type: 'error', error: DataTrackPublishError } - ); -}; -type InputEventSfuUnPublishResponse = { type: 'sfuUnpublishResponse', handle: DataTrackHandle }; -/** Shutdown the manager and all associated tracks. */ -type InputEventShutdown = { type: 'shutdown' }; - -// type InputEvent = -// | InputEventPublishRequest -// // FIXME: no cancelled event -// // | { type: 'publishCancelled', handle: DataTrackHandle } -// | InputEventQueryPublished -// | InputEventUnpublishRequest -// | InputEventSfuPublishResponse -// | InputEventSfuUnPublishResponse -// | InputEventShutdown; - type DataTrackLocalManagerOptions = { /** * Provider to use for encrypting outgoing frame payloads. @@ -127,7 +74,7 @@ type DataTrackLocalManagerOptions = { decryptionProvider?: EncryptionProvider; }; -enum DataTrackPublishErrorReason { +export enum DataTrackPublishErrorReason { /** * Local participant does not have permission to publish data tracks. * From d8a787fb549a28a52c66ece06ec0d498f11b8f3b Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Feb 2026 16:56:15 -0500 Subject: [PATCH 13/43] fix: run npm run format --- src/room/data-track/handle.ts | 4 +- src/room/data-track/outgoing/events.ts | 28 +-- src/room/data-track/outgoing/manager.test.ts | 222 ++++++++++++------- src/room/data-track/outgoing/manager.ts | 84 ++++--- src/room/data-track/outgoing/pipeline.ts | 42 +++- src/room/data-track/packetizer.ts | 2 +- src/room/data-track/track.ts | 18 +- src/room/utils.ts | 8 +- 8 files changed, 256 insertions(+), 152 deletions(-) diff --git a/src/room/data-track/handle.ts b/src/room/data-track/handle.ts index f89ab0c878..58f7688026 100644 --- a/src/room/data-track/handle.ts +++ b/src/room/data-track/handle.ts @@ -57,8 +57,8 @@ export const DataTrackHandle = { throw DataTrackHandleError.tooLarge(); } return raw; - } -} + }, +}; /** Manage allocating new handles which don't conflict over the lifetime of the client. */ export class DataTrackHandleAllocator { diff --git a/src/room/data-track/outgoing/events.ts b/src/room/data-track/outgoing/events.ts index 2349925898..30bbcabf80 100644 --- a/src/room/data-track/outgoing/events.ts +++ b/src/room/data-track/outgoing/events.ts @@ -1,11 +1,11 @@ -import { type Future } from "../../utils"; -import { type DataTrackHandle } from "../handle"; -import { type DataTrackInfo } from "../track"; -import { type DataTrackPublishError, type DataTrackPublishErrorReason } from "./manager"; +import { type Future } from '../../utils'; +import { type DataTrackHandle } from '../handle'; +import { type DataTrackInfo } from '../track'; +import { type DataTrackPublishError, type DataTrackPublishErrorReason } from './manager'; /** Options for publishing a data track. */ type DataTrackOptions = { - name: string, + name: string; }; /** Client requested to publish a track. */ @@ -17,26 +17,28 @@ export type InputEventPublishRequest = { /** Get information about all currently published tracks. */ export type InputEventQueryPublished = { - type: 'queryPublished', + type: 'queryPublished'; // FIXME: use onehsot future vs sending corresponding "-Response" event? future: Future, never>; }; /** Client request to unpublish a track (internal). */ -export type InputEventUnpublishRequest = { type: 'unpublishRequest', handle: DataTrackHandle }; +export type InputEventUnpublishRequest = { type: 'unpublishRequest'; handle: DataTrackHandle }; /** SFU responded to a request to publish a data track. */ export type InputEventSfuPublishResponse = { type: 'sfuPublishResponse'; handle: DataTrackHandle; - result: ( - | { type: 'ok', data: DataTrackInfo } - | { type: 'error', error: DataTrackPublishError } - ); + result: + | { type: 'ok'; data: DataTrackInfo } + | { type: 'error'; error: DataTrackPublishError }; }; /** SFU notification that a track has been unpublished. */ -export type InputEventSfuUnPublishResponse = { type: 'sfuUnpublishResponse', handle: DataTrackHandle }; +export type InputEventSfuUnPublishResponse = { + type: 'sfuUnpublishResponse'; + handle: DataTrackHandle; +}; /** Shutdown the manager and all associated tracks. */ export type InputEventShutdown = { type: 'shutdown' }; @@ -51,7 +53,6 @@ export type InputEventShutdown = { type: 'shutdown' }; // | InputEventSfuUnPublishResponse // | InputEventShutdown; - /** Request sent to the SFU to publish a track. */ export type OutputEventSfuPublishRequest = { handle: DataTrackHandle; @@ -69,4 +70,3 @@ export type OutputEventPacketsAvailable = { bytes: Uint8Array; signal?: AbortSignal; }; - diff --git a/src/room/data-track/outgoing/manager.test.ts b/src/room/data-track/outgoing/manager.test.ts index 3844fd57db..eb7c9b2950 100644 --- a/src/room/data-track/outgoing/manager.test.ts +++ b/src/room/data-track/outgoing/manager.test.ts @@ -1,24 +1,30 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import { type EventMap } from 'typed-emitter'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import DataTrackOutgoingManager, { DataTrackOutgoingManagerCallbacks, DataTrackPublishError, Descriptor, InputEventQueryPublished, OutputEventSfuPublishRequest } from './manager'; import type TypedEventEmitter from 'typed-emitter'; -import { type EventMap } from 'typed-emitter'; import { Future } from '../../utils'; import { DataTrackHandle } from '../handle'; -import DataTrackOutgoingPipeline from './pipeline'; import { DataTrackPacket, FrameMarker } from '../packet'; +import DataTrackOutgoingManager, { + DataTrackOutgoingManagerCallbacks, + DataTrackPublishError, + Descriptor, + InputEventQueryPublished, + OutputEventSfuPublishRequest, +} from './manager'; +import DataTrackOutgoingPipeline from './pipeline'; /** A test helper to listen to events received by an event emitter and allow them to be imperatively - * queried after the fact. */ + * queried after the fact. */ function subscribeToEvents< Callbacks extends EventMap, EventNames extends keyof Callbacks = keyof Callbacks, >(eventEmitter: TypedEventEmitter, eventNames: Array) { const nextEventListeners = new Map>>( - eventNames.map(eventName => [eventName, []]) + eventNames.map((eventName) => [eventName, []]), ); const buffers = new Map>( - eventNames.map(eventName => [eventName, []]) + eventNames.map((eventName) => [eventName, []]), ); const eventHandlers = eventNames.map((eventName) => { @@ -68,30 +74,32 @@ function subscribeToEvents< describe('DataTrackOutgoingManager', () => { it('should test track publishing (ok case)', async () => { const manager = new DataTrackOutgoingManager(); - const managerEvents = subscribeToEvents(manager, ["sfuPublishRequest"]); + const managerEvents = subscribeToEvents(manager, [ + 'sfuPublishRequest', + ]); // 1. Publish a data track const publishRequestPromise = manager.handlePublishRequest({ - type: "publishRequest", - options: { name: "test" }, + type: 'publishRequest', + options: { name: 'test' }, }); // 2. This publish request should be sent along to the SFU - const sfuPublishEvent = await managerEvents.waitFor("sfuPublishRequest"); - expect(sfuPublishEvent.name).toStrictEqual("test"); + const sfuPublishEvent = await managerEvents.waitFor('sfuPublishRequest'); + expect(sfuPublishEvent.name).toStrictEqual('test'); expect(sfuPublishEvent.usesE2ee).toStrictEqual(false); const handle = sfuPublishEvent.handle; // 3. Respond to the SFU publish request with an OK response manager.handleSfuPublishResponse({ - type: "sfuPublishResponse", + type: 'sfuPublishResponse', handle, result: { type: 'ok', data: { sid: 'bogus-sid', pubHandle: sfuPublishEvent.handle, - name: "test", + name: 'test', usesE2ee: false, }, }, @@ -104,20 +112,22 @@ describe('DataTrackOutgoingManager', () => { it('should test track publishing (error case)', async () => { const manager = new DataTrackOutgoingManager(); - const managerEvents = subscribeToEvents(manager, ["sfuPublishRequest"]); + const managerEvents = subscribeToEvents(manager, [ + 'sfuPublishRequest', + ]); // 1. Publish a data track const publishRequestPromise = manager.handlePublishRequest({ - type: "publishRequest", - options: { name: "test" }, + type: 'publishRequest', + options: { name: 'test' }, }); // 2. This publish request should be sent along to the SFU - const sfuPublishEvent = await managerEvents.waitFor("sfuPublishRequest"); + const sfuPublishEvent = await managerEvents.waitFor('sfuPublishRequest'); // 3. Respond to the SFU publish request with an ERROR response manager.handleSfuPublishResponse({ - type: "sfuPublishResponse", + type: 'sfuPublishResponse', handle: sfuPublishEvent.handle, result: { type: 'error', @@ -126,27 +136,29 @@ describe('DataTrackOutgoingManager', () => { }); // Make sure that the rejection bubbles back to the caller - expect(publishRequestPromise).rejects.toThrowError("Data track publication limit reached"); + expect(publishRequestPromise).rejects.toThrowError('Data track publication limit reached'); }); it.each([ // Single packet payload case [ new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]), - [{ - "header": { - extensions: { - e2ee: null, - userTimestamp: null, + [ + { + header: { + extensions: { + e2ee: null, + userTimestamp: null, + }, + frameNumber: 0, + marker: FrameMarker.Single, + sequence: 0, + timestamp: 0, // (zeroed out in the test, since this isn't mocked) + trackHandle: 5, }, - frameNumber: 0, - marker: FrameMarker.Single, - sequence: 0, - timestamp: 0, // (zeroed out in the test, since this isn't mocked) - trackHandle: 5, + payload: new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]), }, - "payload": new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]), - }], + ], ], // Multi packet payload case @@ -183,59 +195,85 @@ describe('DataTrackOutgoingManager', () => { }, ], ], - ])('should test track payload sending', async (inputBytes: Uint8Array, outputPacketsJson: Array) => { - // Create a manager prefilled with a descriptor - const manager = DataTrackOutgoingManager.withDescriptors(new Map([ - [DataTrackHandle.fromNumber(5), Descriptor.active({ - sid: 'bogus-sid', - pubHandle: 5, - name: "test", - usesE2ee: false, - }, null)] - ])); - const managerEvents = subscribeToEvents(manager, ["packetsAvailable"]); + ])( + 'should test track payload sending', + async (inputBytes: Uint8Array, outputPacketsJson: Array) => { + // Create a manager prefilled with a descriptor + const manager = DataTrackOutgoingManager.withDescriptors( + new Map([ + [ + DataTrackHandle.fromNumber(5), + Descriptor.active( + { + sid: 'bogus-sid', + pubHandle: 5, + name: 'test', + usesE2ee: false, + }, + null, + ), + ], + ]), + ); + const managerEvents = subscribeToEvents(manager, [ + 'packetsAvailable', + ]); - const localDataTrack = manager.createLocalDataTrack(5)!; - expect(localDataTrack).not.toStrictEqual(null); + const localDataTrack = manager.createLocalDataTrack(5)!; + expect(localDataTrack).not.toStrictEqual(null); - // Kick off sending the bytes... - localDataTrack.tryPush(inputBytes); + // Kick off sending the bytes... + localDataTrack.tryPush(inputBytes); - // ... and make sure the corresponding events are emitted to tell the SFU to send the packets - for (const outputPacketJson of outputPacketsJson) { - const packetBytes = await managerEvents.waitFor("packetsAvailable"); - const [packet] = DataTrackPacket.fromBinary(packetBytes.bytes); + // ... and make sure the corresponding events are emitted to tell the SFU to send the packets + for (const outputPacketJson of outputPacketsJson) { + const packetBytes = await managerEvents.waitFor('packetsAvailable'); + const [packet] = DataTrackPacket.fromBinary(packetBytes.bytes); - const packetJson = packet.toJSON(); - // (note: zero out the header timestamp because the date "now" isn't being mocked) - packetJson.header.timestamp = 0; + const packetJson = packet.toJSON(); + // (note: zero out the header timestamp because the date "now" isn't being mocked) + packetJson.header.timestamp = 0; - expect(packetJson).toStrictEqual(outputPacketJson); - } - }); + expect(packetJson).toStrictEqual(outputPacketJson); + } + }, + ); it('should test track unpublishing', async () => { // Create a manager prefilled with a descriptor - const manager = DataTrackOutgoingManager.withDescriptors(new Map([ - [DataTrackHandle.fromNumber(5), Descriptor.active({ - sid: 'bogus-sid', - pubHandle: 5, - name: "test", - usesE2ee: false, - }, null)] - ])); - const managerEvents = subscribeToEvents(manager, ["sfuUnpublishRequest"]); + const manager = DataTrackOutgoingManager.withDescriptors( + new Map([ + [ + DataTrackHandle.fromNumber(5), + Descriptor.active( + { + sid: 'bogus-sid', + pubHandle: 5, + name: 'test', + usesE2ee: false, + }, + null, + ), + ], + ]), + ); + const managerEvents = subscribeToEvents(manager, [ + 'sfuUnpublishRequest', + ]); // Make sure the descriptor is in there - expect(manager.getDescriptor(5)?.type).toStrictEqual("active"); + expect(manager.getDescriptor(5)?.type).toStrictEqual('active'); // Unpublish data track - const unpublishRequestPromise = manager.handleUnpublishRequest({ type: "unpublishRequest", handle: 5 }); + const unpublishRequestPromise = manager.handleUnpublishRequest({ + type: 'unpublishRequest', + handle: 5, + }); - const sfuUnpublishEvent = await managerEvents.waitFor("sfuUnpublishRequest"); + const sfuUnpublishEvent = await managerEvents.waitFor('sfuUnpublishRequest'); expect(sfuUnpublishEvent.handle).toStrictEqual(5); - manager.handleSfuUnpublishResponse({ type: "sfuUnpublishResponse", handle: 5 }); + manager.handleSfuUnpublishResponse({ type: 'sfuUnpublishResponse', handle: 5 }); await unpublishRequestPromise; @@ -245,28 +283,42 @@ describe('DataTrackOutgoingManager', () => { it('should query currently active descriptors', async () => { // Create a manager prefilled with a descriptor - const manager = DataTrackOutgoingManager.withDescriptors(new Map([ - [DataTrackHandle.fromNumber(2), Descriptor.active({ - sid: 'bogus-sid-2', - pubHandle: 2, - name: "twotwotwo", - usesE2ee: false, - }, null)], - [DataTrackHandle.fromNumber(6), Descriptor.active({ - sid: 'bogus-sid-6', - pubHandle: 6, - name: "sixsixsix", - usesE2ee: false, - }, null)] - ])); + const manager = DataTrackOutgoingManager.withDescriptors( + new Map([ + [ + DataTrackHandle.fromNumber(2), + Descriptor.active( + { + sid: 'bogus-sid-2', + pubHandle: 2, + name: 'twotwotwo', + usesE2ee: false, + }, + null, + ), + ], + [ + DataTrackHandle.fromNumber(6), + Descriptor.active( + { + sid: 'bogus-sid-6', + pubHandle: 6, + name: 'sixsixsix', + usesE2ee: false, + }, + null, + ), + ], + ]), + ); const event: InputEventQueryPublished = { type: 'queryPublished', future: new Future() }; manager.handleQueryPublished(event); const result = await event.future.promise; expect(result).toStrictEqual([ - { sid: 'bogus-sid-2', pubHandle: 2, name: "twotwotwo", usesE2ee: false, }, - { sid: 'bogus-sid-6', pubHandle: 6, name: "sixsixsix", usesE2ee: false, }, + { sid: 'bogus-sid-2', pubHandle: 2, name: 'twotwotwo', usesE2ee: false }, + { sid: 'bogus-sid-6', pubHandle: 6, name: 'sixsixsix', usesE2ee: false }, ]); }); }); diff --git a/src/room/data-track/outgoing/manager.ts b/src/room/data-track/outgoing/manager.ts index 47037965d3..041714dc3b 100644 --- a/src/room/data-track/outgoing/manager.ts +++ b/src/room/data-track/outgoing/manager.ts @@ -1,15 +1,25 @@ import { EventEmitter } from 'events'; import type TypedEmitter from 'typed-emitter'; -import { LivekitReasonedError } from '../../errors'; import { LoggerNames, getLogger } from '../../../logger'; +import { LivekitReasonedError } from '../../errors'; import { Future } from '../../utils'; -import { DataTrackHandle, DataTrackHandleAllocator } from '../handle'; import { type EncryptionProvider } from '../e2ee'; -import { LocalDataTrack, type DataTrackInfo } from '../track'; -import DataTrackOutgoingPipeline from './pipeline'; import type { DataTrackFrame } from '../frame'; +import { DataTrackHandle, DataTrackHandleAllocator } from '../handle'; import { DataTrackExtensions } from '../packet/extensions'; -import { type InputEventPublishRequest, type InputEventQueryPublished, type InputEventSfuPublishResponse, type InputEventSfuUnPublishResponse, type InputEventShutdown, type InputEventUnpublishRequest, type OutputEventPacketsAvailable, type OutputEventSfuPublishRequest, type OutputEventSfuUnpublishRequest } from './events'; +import { type DataTrackInfo, LocalDataTrack } from '../track'; +import { + type InputEventPublishRequest, + type InputEventQueryPublished, + type InputEventSfuPublishResponse, + type InputEventSfuUnPublishResponse, + type InputEventShutdown, + type InputEventUnpublishRequest, + type OutputEventPacketsAvailable, + type OutputEventSfuPublishRequest, + type OutputEventSfuUnpublishRequest, +} from './events'; +import DataTrackOutgoingPipeline from './pipeline'; const log = getLogger(LoggerNames.DataTracks); @@ -28,7 +38,7 @@ export type ActiveDescriptor = { info: DataTrackInfo; // FIXME: add track task fields here. - pipeline: DataTrackOutgoingPipeline, + pipeline: DataTrackOutgoingPipeline; }; // FIXME: rust doesn't have this unpublishing descriptor, is it a good idea? export type UnpublishingDescriptor = { @@ -76,10 +86,10 @@ type DataTrackLocalManagerOptions = { export enum DataTrackPublishErrorReason { /** - * Local participant does not have permission to publish data tracks. - * - * Ensure the participant's token contains the `canPublishData` grant. - */ + * Local participant does not have permission to publish data tracks. + * + * Ensure the participant's token contains the `canPublishData` grant. + */ NotAllowed = 0, /** A track with the same name is already published by the local participant. */ @@ -117,23 +127,35 @@ export class DataTrackPublishError< } static notAllowed() { - return new DataTrackPublishError("Data track publishing unauthorized", DataTrackPublishErrorReason.NotAllowed); + return new DataTrackPublishError( + 'Data track publishing unauthorized', + DataTrackPublishErrorReason.NotAllowed, + ); } static duplicateName() { - return new DataTrackPublishError("Track name already taken", DataTrackPublishErrorReason.DuplicateName); + return new DataTrackPublishError( + 'Track name already taken', + DataTrackPublishErrorReason.DuplicateName, + ); } static timeout() { - return new DataTrackPublishError("Publish data track timed-out", DataTrackPublishErrorReason.Timeout); + return new DataTrackPublishError( + 'Publish data track timed-out', + DataTrackPublishErrorReason.Timeout, + ); } static limitReached() { - return new DataTrackPublishError("Data track publication limit reached", DataTrackPublishErrorReason.LimitReached); + return new DataTrackPublishError( + 'Data track publication limit reached', + DataTrackPublishErrorReason.LimitReached, + ); } static disconnected() { - return new DataTrackPublishError("Room disconnected", DataTrackPublishErrorReason.Disconnected); + return new DataTrackPublishError('Room disconnected', DataTrackPublishErrorReason.Disconnected); } // FIXME: is this internal thing a good idea? @@ -168,9 +190,9 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => } /** - * Used by attached {@link LocalDataTrack} instances to query their associated descriptor info. - * @internal - */ + * Used by attached {@link LocalDataTrack} instances to query their associated descriptor info. + * @internal + */ getDescriptor(handle: DataTrackHandle) { return this.descriptors.get(handle) ?? null; } @@ -184,14 +206,18 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => } /** Used by attached {@link LocalDataTrack} instances to broadcast data track packets to other - * subscribers. - * @internal - */ - tryProcessAndSend(handle: DataTrackHandle, payload: Uint8Array, options?: { signal?: AbortSignal }) { + * subscribers. + * @internal + */ + tryProcessAndSend( + handle: DataTrackHandle, + payload: Uint8Array, + options?: { signal?: AbortSignal }, + ) { const descriptor = this.getDescriptor(handle); if (descriptor?.type !== 'active') { // return Err(PushFrameError::new(frame, PushFrameErrorReason::TrackUnpublished)); - throw new Error("Pipeline not created, local data track not yet published."); + throw new Error('Pipeline not created, local data track not yet published.'); } const frame: DataTrackFrame = { @@ -203,7 +229,7 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => // .inspect_err(|err| log::debug!("Process failed: {}", err)) for (const packet of descriptor.pipeline.processFrame(frame)) { // .inspect_err(|err| log::debug!("Cannot send packet to transport: {}", err)); - this.emit("packetsAvailable", { bytes: packet.toBinary(), signal: options?.signal }); + this.emit('packetsAvailable', { bytes: packet.toBinary(), signal: options?.signal }); } } @@ -250,8 +276,8 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => handleQueryPublished(event: InputEventQueryPublished) { const descriptorInfos = Array.from(this.descriptors.values()) - .filter((descriptor): descriptor is ActiveDescriptor => descriptor.type === "active") - .map(descriptor => descriptor.info); + .filter((descriptor): descriptor is ActiveDescriptor => descriptor.type === 'active') + .map((descriptor) => descriptor.info); event.future.resolve?.(descriptorInfos); } @@ -290,7 +316,9 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => const localDataTrack = this.createLocalDataTrack(info.pubHandle); if (!localDataTrack) { // @throws-transformer ignore - this should be treated as a "panic" and not be caught - throw new Error("DataTrackOutgoingManager.handleSfuPublishResponse: localDataTrack was not created after active descriptor stored."); + throw new Error( + 'DataTrackOutgoingManager.handleSfuPublishResponse: localDataTrack was not created after active descriptor stored.', + ); } descriptor.completionFuture.resolve?.(localDataTrack); @@ -322,7 +350,7 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => for (const descriptor of this.descriptors.values()) { switch (descriptor.type) { case 'pending': - descriptor.completionFuture.reject?.(DataTrackPublishError.disconnected()) + descriptor.completionFuture.reject?.(DataTrackPublishError.disconnected()); break; case 'active': // FIXME: cleanup active descriptor diff --git a/src/room/data-track/outgoing/pipeline.ts b/src/room/data-track/outgoing/pipeline.ts index 561134bd3a..ebf241646f 100644 --- a/src/room/data-track/outgoing/pipeline.ts +++ b/src/room/data-track/outgoing/pipeline.ts @@ -1,11 +1,14 @@ +import { type Throws } from '../../../utils/throws'; import { LivekitReasonedError } from '../../errors'; import { type EncryptedPayload, type EncryptionProvider } from '../e2ee'; -import type { DataTrackInfo } from '../track'; -import DataTrackPacketizer, { DataTrackPacketizerError, DataTrackPacketizerReason } from '../packetizer'; import { type DataTrackFrame } from '../frame'; -import { type Throws } from '../../../utils/throws'; import { DataTrackPacket } from '../packet'; import { DataTrackE2eeExtension } from '../packet/extensions'; +import DataTrackPacketizer, { + DataTrackPacketizerError, + DataTrackPacketizerReason, +} from '../packetizer'; +import type { DataTrackInfo } from '../track'; enum DataTrackOutgoingPipelineErrorReason { Packetizer = 0, @@ -28,11 +31,19 @@ class DataTrackOutgoingPipelineError< } static packetizer(cause: DataTrackPacketizerError) { - return new DataTrackOutgoingPipelineError("Error packetizing frame", DataTrackOutgoingPipelineErrorReason.Packetizer, { cause }); + return new DataTrackOutgoingPipelineError( + 'Error packetizing frame', + DataTrackOutgoingPipelineErrorReason.Packetizer, + { cause }, + ); } static encryption(cause: unknown) { - return new DataTrackOutgoingPipelineError("Error encrypting frame", DataTrackOutgoingPipelineErrorReason.Encryption, { cause }); + return new DataTrackOutgoingPipelineError( + 'Error encrypting frame', + DataTrackOutgoingPipelineErrorReason.Encryption, + { cause }, + ); } } @@ -50,10 +61,15 @@ export default class DataTrackOutgoingPipeline { constructor(options: Options) { this.encryptionProvider = options.encryptionProvider; - this.packetizer = new DataTrackPacketizer(options.info.pubHandle, DataTrackOutgoingPipeline.TRANSPORT_MTU_BYTES); + this.packetizer = new DataTrackPacketizer( + options.info.pubHandle, + DataTrackOutgoingPipeline.TRANSPORT_MTU_BYTES, + ); } - *processFrame(frame: DataTrackFrame): Throws< + *processFrame( + frame: DataTrackFrame, + ): Throws< Generator, | DataTrackOutgoingPipelineError | DataTrackOutgoingPipelineError @@ -70,7 +86,12 @@ export default class DataTrackOutgoingPipeline { } } - encryptIfNeeded(frame: DataTrackFrame): Throws> { + encryptIfNeeded( + frame: DataTrackFrame, + ): Throws< + DataTrackFrame, + DataTrackOutgoingPipelineError + > { if (!this.encryptionProvider) { return frame; } @@ -83,7 +104,10 @@ export default class DataTrackOutgoingPipeline { } frame.payload = encryptedResult.payload; - frame.extensions.e2ee = new DataTrackE2eeExtension(encryptedResult.keyIndex, encryptedResult.iv); + frame.extensions.e2ee = new DataTrackE2eeExtension( + encryptedResult.keyIndex, + encryptedResult.iv, + ); return frame; } diff --git a/src/room/data-track/packetizer.ts b/src/room/data-track/packetizer.ts index afa35c673f..0e6a8fe7cd 100644 --- a/src/room/data-track/packetizer.ts +++ b/src/room/data-track/packetizer.ts @@ -123,7 +123,7 @@ export default class DataTrackPacketizer { const packetPayload = new Uint8Array( frame.payload.buffer, frame.payload.byteOffset + indexBytes, - packetPayloadLengthBytes + packetPayloadLengthBytes, ); yield new DataTrackPacket(packetHeader, packetPayload); diff --git a/src/room/data-track/track.ts b/src/room/data-track/track.ts index 9cc01a99bd..76e4435023 100644 --- a/src/room/data-track/track.ts +++ b/src/room/data-track/track.ts @@ -1,15 +1,15 @@ -import type { DataTrackFrame } from "./frame"; -import { type DataTrackHandle } from "./handle"; -import type DataTrackOutgoingManager from "./outgoing/manager"; +import type { DataTrackFrame } from './frame'; +import { type DataTrackHandle } from './handle'; +import type DataTrackOutgoingManager from './outgoing/manager'; export type DataTrackSid = string; /** Information about a published data track. */ export type DataTrackInfo = { - sid: DataTrackSid, - pubHandle: DataTrackHandle, - name: String, - usesE2ee: boolean, + sid: DataTrackSid; + pubHandle: DataTrackHandle; + name: String; + usesE2ee: boolean; }; export class LocalDataTrack { @@ -28,7 +28,7 @@ export class LocalDataTrack { } isPublished() { - return this.descriptor?.type === "active"; + return this.descriptor?.type === 'active'; } /** Try pushing a frame to subscribers of the track. @@ -39,7 +39,7 @@ export class LocalDataTrack { * - The room is no longer connected * - Frames are being pushed too fast (FIXME: this isn't the case in the js implementation?) */ - tryPush(payload: DataTrackFrame["payload"], options: { signal?: AbortSignal }) { + tryPush(payload: DataTrackFrame['payload'], options: { signal?: AbortSignal }) { // FIXME: rust implementation maps errors to dropped here? // .map_err(|err| PushFrameError::new(err.into_inner(), PushFrameErrorReason::Dropped)) return this.manager.tryProcessAndSend(this.info.pubHandle, payload, options); diff --git a/src/room/utils.ts b/src/room/utils.ts index 2fbf6548ec..c7694b0e97 100644 --- a/src/room/utils.ts +++ b/src/room/utils.ts @@ -8,6 +8,7 @@ import { import TypedPromise from '../utils/TypedPromise'; import { getBrowser } from '../utils/browserParser'; import type { BrowserDetails } from '../utils/browserParser'; +import { type Throws } from '../utils/throws'; import { protocolVersion, version } from '../version'; import { type ConnectionError, ConnectionErrorReason } from './errors'; import type LocalParticipant from './participant/LocalParticipant'; @@ -27,7 +28,6 @@ import type { TrackPublication } from './track/TrackPublication'; import { type AudioCodec, type VideoCodec, audioCodecs, videoCodecs } from './track/options'; import { getNewAudioContext } from './track/utils'; import type { ChatMessage, LiveKitReactNativeInfo, TranscriptionSegment } from './types'; -import { type Throws } from '../utils/throws'; const separator = '|'; export const ddExtensionURI = @@ -459,9 +459,9 @@ export function getStereoAudioStreamTrack() { } /** An object that represents a serialized version of a `new Promise((resolve, reject) => {})` - * constructor. Wait for a promise resolution with `await future.promise` and explicitly resolve or - * reject the inner promise with `future.resolve(...)` or `future.reject(...)`. - */ + * constructor. Wait for a promise resolution with `await future.promise` and explicitly resolve or + * reject the inner promise with `future.resolve(...)` or `future.reject(...)`. + */ export class Future { promise: Promise>; From 2fd3eaa82d693ce7c6a577b7bee4e34d6005ef6d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Feb 2026 17:06:29 -0500 Subject: [PATCH 14/43] feat: add data track publish timeout after 10s --- src/room/data-track/outgoing/manager.ts | 28 ++++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/room/data-track/outgoing/manager.ts b/src/room/data-track/outgoing/manager.ts index 041714dc3b..99a4d4dd31 100644 --- a/src/room/data-track/outgoing/manager.ts +++ b/src/room/data-track/outgoing/manager.ts @@ -27,6 +27,7 @@ export type PendingDescriptor = { type: 'pending'; completionFuture: Future< LocalDataTrack, + | DataTrackPublishError | DataTrackPublishError | DataTrackPublishError | DataTrackPublishError @@ -36,7 +37,6 @@ export type PendingDescriptor = { export type ActiveDescriptor = { type: 'active'; info: DataTrackInfo; - // FIXME: add track task fields here. pipeline: DataTrackOutgoingPipeline; }; @@ -165,12 +165,15 @@ export class DataTrackPublishError< // FIXME: this was introduced by web / there isn't a corresponding case in the rust version. static cancelled() { - return new DataTrackPublishError('FIXME', DataTrackPublishErrorReason.Cancelled); + return new DataTrackPublishError( + 'Publish data track cancelled by caller', + DataTrackPublishErrorReason.Cancelled, + ); } } -// FIXME: use this value in the publish -const PUBLISH_TIMEOUT_SECONDS = 10; +/** How long to wait when attempting to publish before timing out. */ +const PUBLISH_TIMEOUT_MILLISECONDS = 10_000; export default class DataTrackOutgoingManager extends (EventEmitter as new () => TypedEmitter) { private encryptionProvider: EncryptionProvider | null; @@ -240,6 +243,11 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => throw DataTrackPublishError.limitReached(); } + const timeoutSignal = AbortSignal.timeout(PUBLISH_TIMEOUT_MILLISECONDS); + const combinedSignal = event.signal + ? AbortSignal.any([event.signal, timeoutSignal]) + : timeoutSignal; + if (this.descriptors.has(handle)) { throw DataTrackPublishError.internal(new Error('Descriptor for handle already exists')); } @@ -256,12 +264,16 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => } this.descriptors.delete(handle); - // FIXME: this was introduced by web / there isn't a corresponding case in the rust version. if (existingDescriptor.type === 'pending') { - existingDescriptor.completionFuture.reject?.(DataTrackPublishError.cancelled()); + existingDescriptor.completionFuture.reject?.( + timeoutSignal.aborted + ? DataTrackPublishError.timeout() + : // FIXME: the below cancelled case was introduced by web / there isn't a corresponding case in the rust version. + DataTrackPublishError.cancelled(), + ); } }; - event.signal?.addEventListener('abort', onAbort); + combinedSignal.addEventListener('abort', onAbort); this.emit('sfuPublishRequest', { handle, @@ -270,7 +282,7 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => }); const localDataTrack = await descriptor.completionFuture.promise; - event.signal?.removeEventListener('abort', onAbort); + combinedSignal.removeEventListener('abort', onAbort); return localDataTrack; } From 0129c66bc45cb3190699836a2f8bfd1fdfbdcf61 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Feb 2026 17:22:01 -0500 Subject: [PATCH 15/43] feat: add in outgoing manager shutdown test --- src/room/data-track/outgoing/manager.test.ts | 34 ++++++++++++++++++++ src/room/data-track/outgoing/manager.ts | 4 +-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/room/data-track/outgoing/manager.test.ts b/src/room/data-track/outgoing/manager.test.ts index eb7c9b2950..c20e6f79cf 100644 --- a/src/room/data-track/outgoing/manager.test.ts +++ b/src/room/data-track/outgoing/manager.test.ts @@ -321,4 +321,38 @@ describe('DataTrackOutgoingManager', () => { { sid: 'bogus-sid-6', pubHandle: 6, name: 'sixsixsix', usesE2ee: false }, ]); }); + + it('should shutdown cleanly', async () => { + // Create a manager prefilled with a descriptor + const pendingDescriptor = Descriptor.pending(); + const manager = DataTrackOutgoingManager.withDescriptors( + new Map([ + [DataTrackHandle.fromNumber(2), pendingDescriptor], + [DataTrackHandle.fromNumber(6), Descriptor.active({ + sid: 'bogus-sid-6', + pubHandle: 6, + name: 'sixsixsix', + usesE2ee: false, + }, null)], + ]), + ); + const managerEvents = subscribeToEvents(manager, [ + 'sfuUnpublishRequest', + ]); + + // Shut down the manager + const shutdownPromise = manager.handleShutdown({ type: 'shutdown' }); + + // The pending data track should be cancelled + expect(pendingDescriptor.completionFuture.promise).rejects.toThrowError('Room disconnected'); + + // And the active data track should be requested to be unpublished + const unpublishEvent = await managerEvents.waitFor("sfuUnpublishRequest"); + expect(unpublishEvent.handle).toStrictEqual(6); + + // Acknowledge that the unpublish has occurred + manager.handleSfuUnpublishResponse({ type: "sfuUnpublishResponse", handle: 6 }); + + await shutdownPromise; + }); }); diff --git a/src/room/data-track/outgoing/manager.ts b/src/room/data-track/outgoing/manager.ts index 99a4d4dd31..e064a95b1a 100644 --- a/src/room/data-track/outgoing/manager.ts +++ b/src/room/data-track/outgoing/manager.ts @@ -358,14 +358,14 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => } /** Shuts down the manager and all associated tracks. */ - handleShutdown(_event: InputEventShutdown) { + async handleShutdown(_event: InputEventShutdown) { for (const descriptor of this.descriptors.values()) { switch (descriptor.type) { case 'pending': descriptor.completionFuture.reject?.(DataTrackPublishError.disconnected()); break; case 'active': - // FIXME: cleanup active descriptor + await this.handleUnpublishRequest({ type: 'unpublishRequest', handle: descriptor.info.pubHandle }); break; } } From c923d7c580bd908ee7d04ce933c1eb398c0f08a4 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 9 Feb 2026 17:22:25 -0500 Subject: [PATCH 16/43] fix: add a few FIXME comments to further ponder --- src/room/data-track/outgoing/events.ts | 2 ++ src/room/data-track/outgoing/manager.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/room/data-track/outgoing/events.ts b/src/room/data-track/outgoing/events.ts index 30bbcabf80..8a70480f44 100644 --- a/src/room/data-track/outgoing/events.ts +++ b/src/room/data-track/outgoing/events.ts @@ -38,6 +38,8 @@ export type InputEventSfuPublishResponse = { export type InputEventSfuUnPublishResponse = { type: 'sfuUnpublishResponse'; handle: DataTrackHandle; + // FIXME: does there need to be an error case encoded in this event as well? Can unpublishing a + // data track fail? }; /** Shutdown the manager and all associated tracks. */ diff --git a/src/room/data-track/outgoing/manager.ts b/src/room/data-track/outgoing/manager.ts index e064a95b1a..27b287c10f 100644 --- a/src/room/data-track/outgoing/manager.ts +++ b/src/room/data-track/outgoing/manager.ts @@ -236,6 +236,9 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => } } + // FIXME: reintroduce bare handle? Or convert to completely seperate handle implementations for + // each event (and if so, drop the `type` params from `event`)? + /** Client requested to publish a track. */ async handlePublishRequest(event: InputEventPublishRequest) { const handle = this.handleAllocator.get(); From 6b3b653d6dca8dc89cd7d5f543303d033d7306e1 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 10:46:16 -0500 Subject: [PATCH 17/43] refactor: move away fully from the manager input events pattern --- src/room/data-track/outgoing/events.ts | 74 ---------- src/room/data-track/outgoing/manager.test.ts | 60 +++------ src/room/data-track/outgoing/manager.ts | 134 ++++++++++++------- src/room/data-track/outgoing/types.ts | 31 +++++ src/room/data-track/track.ts | 2 +- 5 files changed, 138 insertions(+), 163 deletions(-) delete mode 100644 src/room/data-track/outgoing/events.ts create mode 100644 src/room/data-track/outgoing/types.ts diff --git a/src/room/data-track/outgoing/events.ts b/src/room/data-track/outgoing/events.ts deleted file mode 100644 index 8a70480f44..0000000000 --- a/src/room/data-track/outgoing/events.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { type Future } from '../../utils'; -import { type DataTrackHandle } from '../handle'; -import { type DataTrackInfo } from '../track'; -import { type DataTrackPublishError, type DataTrackPublishErrorReason } from './manager'; - -/** Options for publishing a data track. */ -type DataTrackOptions = { - name: string; -}; - -/** Client requested to publish a track. */ -export type InputEventPublishRequest = { - type: 'publishRequest'; - options: DataTrackOptions; - signal?: AbortSignal; -}; - -/** Get information about all currently published tracks. */ -export type InputEventQueryPublished = { - type: 'queryPublished'; - // FIXME: use onehsot future vs sending corresponding "-Response" event? - future: Future, never>; -}; - -/** Client request to unpublish a track (internal). */ -export type InputEventUnpublishRequest = { type: 'unpublishRequest'; handle: DataTrackHandle }; - -/** SFU responded to a request to publish a data track. */ -export type InputEventSfuPublishResponse = { - type: 'sfuPublishResponse'; - handle: DataTrackHandle; - result: - | { type: 'ok'; data: DataTrackInfo } - | { type: 'error'; error: DataTrackPublishError }; -}; - -/** SFU notification that a track has been unpublished. */ -export type InputEventSfuUnPublishResponse = { - type: 'sfuUnpublishResponse'; - handle: DataTrackHandle; - // FIXME: does there need to be an error case encoded in this event as well? Can unpublishing a - // data track fail? -}; - -/** Shutdown the manager and all associated tracks. */ -export type InputEventShutdown = { type: 'shutdown' }; - -// type InputEvent = -// | InputEventPublishRequest -// // FIXME: no cancelled event -// // | { type: 'publishCancelled', handle: DataTrackHandle } -// | InputEventQueryPublished -// | InputEventUnpublishRequest -// | InputEventSfuPublishResponse -// | InputEventSfuUnPublishResponse -// | InputEventShutdown; - -/** Request sent to the SFU to publish a track. */ -export type OutputEventSfuPublishRequest = { - handle: DataTrackHandle; - name: string; - usesE2ee: boolean; -}; - -/** Request sent to the SFU to unpublish a track. */ -export type OutputEventSfuUnpublishRequest = { - handle: DataTrackHandle; -}; - -/** Serialized packets are ready to be sent over the transport. */ -export type OutputEventPacketsAvailable = { - bytes: Uint8Array; - signal?: AbortSignal; -}; diff --git a/src/room/data-track/outgoing/manager.test.ts b/src/room/data-track/outgoing/manager.test.ts index c20e6f79cf..60bf7f9268 100644 --- a/src/room/data-track/outgoing/manager.test.ts +++ b/src/room/data-track/outgoing/manager.test.ts @@ -9,10 +9,9 @@ import DataTrackOutgoingManager, { DataTrackOutgoingManagerCallbacks, DataTrackPublishError, Descriptor, - InputEventQueryPublished, - OutputEventSfuPublishRequest, } from './manager'; -import DataTrackOutgoingPipeline from './pipeline'; + +// FIXME: move this to some test utils file /** A test helper to listen to events received by an event emitter and allow them to be imperatively * queried after the fact. */ @@ -79,10 +78,7 @@ describe('DataTrackOutgoingManager', () => { ]); // 1. Publish a data track - const publishRequestPromise = manager.handlePublishRequest({ - type: 'publishRequest', - options: { name: 'test' }, - }); + const publishRequestPromise = manager.publishRequest({ name: 'test' }); // 2. This publish request should be sent along to the SFU const sfuPublishEvent = await managerEvents.waitFor('sfuPublishRequest'); @@ -91,17 +87,13 @@ describe('DataTrackOutgoingManager', () => { const handle = sfuPublishEvent.handle; // 3. Respond to the SFU publish request with an OK response - manager.handleSfuPublishResponse({ - type: 'sfuPublishResponse', - handle, - result: { - type: 'ok', - data: { - sid: 'bogus-sid', - pubHandle: sfuPublishEvent.handle, - name: 'test', - usesE2ee: false, - }, + manager.receivedSfuPublishResponse(handle, { + type: 'ok', + data: { + sid: 'bogus-sid', + pubHandle: sfuPublishEvent.handle, + name: 'test', + usesE2ee: false, }, }); @@ -117,22 +109,15 @@ describe('DataTrackOutgoingManager', () => { ]); // 1. Publish a data track - const publishRequestPromise = manager.handlePublishRequest({ - type: 'publishRequest', - options: { name: 'test' }, - }); + const publishRequestPromise = manager.publishRequest({ name: 'test' }); // 2. This publish request should be sent along to the SFU const sfuPublishEvent = await managerEvents.waitFor('sfuPublishRequest'); // 3. Respond to the SFU publish request with an ERROR response - manager.handleSfuPublishResponse({ - type: 'sfuPublishResponse', - handle: sfuPublishEvent.handle, - result: { - type: 'error', - error: DataTrackPublishError.limitReached(), - }, + manager.receivedSfuPublishResponse(sfuPublishEvent.handle, { + type: 'error', + error: DataTrackPublishError.limitReached(), }); // Make sure that the rejection bubbles back to the caller @@ -265,15 +250,12 @@ describe('DataTrackOutgoingManager', () => { expect(manager.getDescriptor(5)?.type).toStrictEqual('active'); // Unpublish data track - const unpublishRequestPromise = manager.handleUnpublishRequest({ - type: 'unpublishRequest', - handle: 5, - }); + const unpublishRequestPromise = manager.unpublishRequest(DataTrackHandle.fromNumber(5)); const sfuUnpublishEvent = await managerEvents.waitFor('sfuUnpublishRequest'); expect(sfuUnpublishEvent.handle).toStrictEqual(5); - manager.handleSfuUnpublishResponse({ type: 'sfuUnpublishResponse', handle: 5 }); + manager.receivedSfuUnpublishResponse(DataTrackHandle.fromNumber(5)); await unpublishRequestPromise; @@ -312,9 +294,7 @@ describe('DataTrackOutgoingManager', () => { ]), ); - const event: InputEventQueryPublished = { type: 'queryPublished', future: new Future() }; - manager.handleQueryPublished(event); - const result = await event.future.promise; + const result = await manager.queryPublished(); expect(result).toStrictEqual([ { sid: 'bogus-sid-2', pubHandle: 2, name: 'twotwotwo', usesE2ee: false }, @@ -341,7 +321,7 @@ describe('DataTrackOutgoingManager', () => { ]); // Shut down the manager - const shutdownPromise = manager.handleShutdown({ type: 'shutdown' }); + const shutdownPromise = manager.shutdown(); // The pending data track should be cancelled expect(pendingDescriptor.completionFuture.promise).rejects.toThrowError('Room disconnected'); @@ -351,8 +331,10 @@ describe('DataTrackOutgoingManager', () => { expect(unpublishEvent.handle).toStrictEqual(6); // Acknowledge that the unpublish has occurred - manager.handleSfuUnpublishResponse({ type: "sfuUnpublishResponse", handle: 6 }); + manager.receivedSfuUnpublishResponse(DataTrackHandle.fromNumber(6)); await shutdownPromise; }); + + // FIXME: add e2ee tests }); diff --git a/src/room/data-track/outgoing/manager.ts b/src/room/data-track/outgoing/manager.ts index 27b287c10f..3df2c8e600 100644 --- a/src/room/data-track/outgoing/manager.ts +++ b/src/room/data-track/outgoing/manager.ts @@ -9,16 +9,12 @@ import { DataTrackHandle, DataTrackHandleAllocator } from '../handle'; import { DataTrackExtensions } from '../packet/extensions'; import { type DataTrackInfo, LocalDataTrack } from '../track'; import { - type InputEventPublishRequest, - type InputEventQueryPublished, - type InputEventSfuPublishResponse, - type InputEventSfuUnPublishResponse, - type InputEventShutdown, - type InputEventUnpublishRequest, + type DataTrackOptions, type OutputEventPacketsAvailable, type OutputEventSfuPublishRequest, type OutputEventSfuUnpublishRequest, -} from './events'; + type SfuPublishResponseResult, +} from './types'; import DataTrackOutgoingPipeline from './pipeline'; const log = getLogger(LoggerNames.DataTracks); @@ -29,7 +25,6 @@ export type PendingDescriptor = { LocalDataTrack, | DataTrackPublishError | DataTrackPublishError - | DataTrackPublishError | DataTrackPublishError | DataTrackPublishError >; @@ -104,10 +99,11 @@ export enum DataTrackPublishErrorReason { /** Cannot publish data track when the room is disconnected. */ Disconnected = 4, - /** Internal error, please report on GitHub. */ - Internal = 5, + // FIXME: get rid of internal error concept, this is just represented as bare throws in js + // Internal = 5, // FIXME: this was introduced by web / there isn't a corresponding case in the rust version. + // Upon further reflection though I think this should exist in rust. Cancelled = 6, } @@ -158,11 +154,6 @@ export class DataTrackPublishError< return new DataTrackPublishError('Room disconnected', DataTrackPublishErrorReason.Disconnected); } - // FIXME: is this internal thing a good idea? - static internal(cause: Error) { - return new DataTrackPublishError('FIXME', DataTrackPublishErrorReason.Internal, { cause }); - } - // FIXME: this was introduced by web / there isn't a corresponding case in the rust version. static cancelled() { return new DataTrackPublishError( @@ -172,6 +163,44 @@ export class DataTrackPublishError< } } +export enum DataTrackPushFrameErrorReason { + /** Track is no longer published. */ + TrackUnpublished = 0, + /** Frame was dropped. */ + Dropped = 1, +} + +export class DataTrackPushFrameError< + Reason extends DataTrackPushFrameErrorReason, +> extends LivekitReasonedError { + readonly name = 'DataTrackPushFrameError'; + + reason: Reason; + + reasonName: string; + + constructor(message: string, reason: Reason, options?: { cause?: unknown }) { + super(22, message, options); + this.reason = reason; + this.reasonName = DataTrackPushFrameErrorReason[reason]; + } + + static trackUnpublished() { + return new DataTrackPushFrameError( + 'Track is no longer published', + DataTrackPushFrameErrorReason.TrackUnpublished, + ); + } + + static dropped(cause: unknown) { + return new DataTrackPushFrameError( + 'Frame was dropped', + DataTrackPushFrameErrorReason.Dropped, + { cause } + ); + } +} + /** How long to wait when attempting to publish before timing out. */ const PUBLISH_TIMEOUT_MILLISECONDS = 10_000; @@ -219,8 +248,7 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => ) { const descriptor = this.getDescriptor(handle); if (descriptor?.type !== 'active') { - // return Err(PushFrameError::new(frame, PushFrameErrorReason::TrackUnpublished)); - throw new Error('Pipeline not created, local data track not yet published.'); + throw DataTrackPushFrameError.trackUnpublished(); } const frame: DataTrackFrame = { @@ -228,31 +256,38 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => extensions: new DataTrackExtensions(), }; - // FIXME: catch and drop processFrame error? That is what the rust implementation is doing. - // .inspect_err(|err| log::debug!("Process failed: {}", err)) - for (const packet of descriptor.pipeline.processFrame(frame)) { - // .inspect_err(|err| log::debug!("Cannot send packet to transport: {}", err)); - this.emit('packetsAvailable', { bytes: packet.toBinary(), signal: options?.signal }); + try { + for (const packet of descriptor.pipeline.processFrame(frame)) { + this.emit('packetsAvailable', { bytes: packet.toBinary(), signal: options?.signal }); + } + } catch (err) { + // FIXME: catch and log errors instead of rethrowing? That is what the rust implementation + // is doing instead. + // process_frame(...).inspect_err(|err| log::debug!("Process failed: {}", err)) + // event_out_tx.try_send(...).inspect_err(|err| log::debug!("Cannot send packet to transport: {}", err)); + // + // In the rust implementation this "dropped" error means something different (not enough room + // in the track mpsc channel) + throw DataTrackPushFrameError.dropped(err); } } - // FIXME: reintroduce bare handle? Or convert to completely seperate handle implementations for - // each event (and if so, drop the `type` params from `event`)? /** Client requested to publish a track. */ - async handlePublishRequest(event: InputEventPublishRequest) { + async publishRequest(options: DataTrackOptions, signal?: AbortSignal) { const handle = this.handleAllocator.get(); if (!handle) { throw DataTrackPublishError.limitReached(); } const timeoutSignal = AbortSignal.timeout(PUBLISH_TIMEOUT_MILLISECONDS); - const combinedSignal = event.signal - ? AbortSignal.any([event.signal, timeoutSignal]) + const combinedSignal = signal + ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; if (this.descriptors.has(handle)) { - throw DataTrackPublishError.internal(new Error('Descriptor for handle already exists')); + // @throws-transformer ignore - this should be treated as a "panic" and not be caught + throw new Error('Descriptor for handle already exists'); } const descriptor = Descriptor.pending(); @@ -280,7 +315,7 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => this.emit('sfuPublishRequest', { handle, - name: event.options.name, + name: options.name, usesE2ee: this.encryptionProvider !== null, }); @@ -289,41 +324,42 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => return localDataTrack; } - handleQueryPublished(event: InputEventQueryPublished) { + /** Get information about all currently published tracks. */ + async queryPublished() { const descriptorInfos = Array.from(this.descriptors.values()) .filter((descriptor): descriptor is ActiveDescriptor => descriptor.type === 'active') .map((descriptor) => descriptor.info); - event.future.resolve?.(descriptorInfos); + return descriptorInfos; } /** Client request to unpublish a track. */ - async handleUnpublishRequest(event: InputEventUnpublishRequest) { + async unpublishRequest(handle: DataTrackHandle) { const descriptor = Descriptor.unpublishing(); - this.descriptors.set(event.handle, descriptor); + this.descriptors.set(handle, descriptor); - this.emit('sfuUnpublishRequest', { handle: event.handle }); + this.emit('sfuUnpublishRequest', { handle }); await descriptor.completionFuture.promise; } /** SFU responded to a request to publish a data track. */ - handleSfuPublishResponse(event: InputEventSfuPublishResponse) { - const descriptor = this.descriptors.get(event.handle); + receivedSfuPublishResponse(handle: DataTrackHandle, result: SfuPublishResponseResult) { + const descriptor = this.descriptors.get(handle); if (!descriptor) { // FIXME: should this be an internal error? - log.warn(`No descriptor for ${event.handle}`); + log.warn(`No descriptor for ${handle}`); return; } - this.descriptors.delete(event.handle); + this.descriptors.delete(handle); if (descriptor.type !== 'pending') { - log.warn(`Track ${event.handle} already active`); + log.warn(`Track ${handle} already active`); return; } - if (event.result.type === 'ok') { - const info = event.result.data; + if (result.type === 'ok') { + const info = result.data; const encryptionProvider = info.usesE2ee ? this.encryptionProvider : null; this.descriptors.set(info.pubHandle, Descriptor.active(info, encryptionProvider)); @@ -338,22 +374,22 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => descriptor.completionFuture.resolve?.(localDataTrack); } else { - descriptor.completionFuture.reject?.(event.result.error); + descriptor.completionFuture.reject?.(result.error); } } /** SFU notification that a track has been unpublished. */ - handleSfuUnpublishResponse(event: InputEventSfuUnPublishResponse) { - const descriptor = this.descriptors.get(event.handle); + receivedSfuUnpublishResponse(handle: DataTrackHandle) { + const descriptor = this.descriptors.get(handle); if (!descriptor) { // FIXME: should this be an internal error? - log.warn(`No descriptor for ${event.handle}`); + log.warn(`No descriptor for ${handle}`); return; } - this.descriptors.delete(event.handle); + this.descriptors.delete(handle); if (descriptor.type !== 'unpublishing') { - log.warn(`Track ${event.handle} hasn't been put into unpublishing status`); + log.warn(`Track ${handle} hasn't been put into unpublishing status`); return; } @@ -361,14 +397,14 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => } /** Shuts down the manager and all associated tracks. */ - async handleShutdown(_event: InputEventShutdown) { + async shutdown() { for (const descriptor of this.descriptors.values()) { switch (descriptor.type) { case 'pending': descriptor.completionFuture.reject?.(DataTrackPublishError.disconnected()); break; case 'active': - await this.handleUnpublishRequest({ type: 'unpublishRequest', handle: descriptor.info.pubHandle }); + await this.unpublishRequest(descriptor.info.pubHandle); break; } } diff --git a/src/room/data-track/outgoing/types.ts b/src/room/data-track/outgoing/types.ts new file mode 100644 index 0000000000..ed631abbb3 --- /dev/null +++ b/src/room/data-track/outgoing/types.ts @@ -0,0 +1,31 @@ +import { type DataTrackHandle } from '../handle'; +import { type DataTrackInfo } from '../track'; +import { type DataTrackPublishError, type DataTrackPublishErrorReason } from './manager'; + +/** Options for publishing a data track. */ +export type DataTrackOptions = { + name: string; +}; + +/** Encodes whether a data track publish request to the SFU has been successful or not. */ +export type SfuPublishResponseResult = + | { type: 'ok'; data: DataTrackInfo } + | { type: 'error'; error: DataTrackPublishError }; + +/** Request sent to the SFU to publish a track. */ +export type OutputEventSfuPublishRequest = { + handle: DataTrackHandle; + name: string; + usesE2ee: boolean; +}; + +/** Request sent to the SFU to unpublish a track. */ +export type OutputEventSfuUnpublishRequest = { + handle: DataTrackHandle; +}; + +/** Serialized packets are ready to be sent over the transport. */ +export type OutputEventPacketsAvailable = { + bytes: Uint8Array; + signal?: AbortSignal; +}; diff --git a/src/room/data-track/track.ts b/src/room/data-track/track.ts index 76e4435023..9a9c9602ef 100644 --- a/src/room/data-track/track.ts +++ b/src/room/data-track/track.ts @@ -39,7 +39,7 @@ export class LocalDataTrack { * - The room is no longer connected * - Frames are being pushed too fast (FIXME: this isn't the case in the js implementation?) */ - tryPush(payload: DataTrackFrame['payload'], options: { signal?: AbortSignal }) { + tryPush(payload: DataTrackFrame['payload'], options?: { signal?: AbortSignal }) { // FIXME: rust implementation maps errors to dropped here? // .map_err(|err| PushFrameError::new(err.into_inner(), PushFrameErrorReason::Dropped)) return this.manager.tryProcessAndSend(this.info.pubHandle, payload, options); From 899b0a8758ad73a34ef6c74cd58639abf3fbab75 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 10:51:31 -0500 Subject: [PATCH 18/43] refactor: move subscribeToEvents into its own file --- src/room/data-track/outgoing/manager.test.ts | 63 +------------------- src/utils/subscribeToEvents.ts | 63 ++++++++++++++++++++ 2 files changed, 64 insertions(+), 62 deletions(-) create mode 100644 src/utils/subscribeToEvents.ts diff --git a/src/room/data-track/outgoing/manager.test.ts b/src/room/data-track/outgoing/manager.test.ts index 60bf7f9268..f4e6f257fd 100644 --- a/src/room/data-track/outgoing/manager.test.ts +++ b/src/room/data-track/outgoing/manager.test.ts @@ -1,8 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { type EventMap } from 'typed-emitter'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type TypedEventEmitter from 'typed-emitter'; -import { Future } from '../../utils'; import { DataTrackHandle } from '../handle'; import { DataTrackPacket, FrameMarker } from '../packet'; import DataTrackOutgoingManager, { @@ -10,65 +7,7 @@ import DataTrackOutgoingManager, { DataTrackPublishError, Descriptor, } from './manager'; - -// FIXME: move this to some test utils file - -/** A test helper to listen to events received by an event emitter and allow them to be imperatively - * queried after the fact. */ -function subscribeToEvents< - Callbacks extends EventMap, - EventNames extends keyof Callbacks = keyof Callbacks, ->(eventEmitter: TypedEventEmitter, eventNames: Array) { - const nextEventListeners = new Map>>( - eventNames.map((eventName) => [eventName, []]), - ); - const buffers = new Map>( - eventNames.map((eventName) => [eventName, []]), - ); - - const eventHandlers = eventNames.map((eventName) => { - const onEvent = ((event: unknown) => { - const listeners = nextEventListeners.get(eventName)!; - if (listeners.length > 0) { - for (const listener of listeners) { - listener.resolve?.(event); - } - nextEventListeners.set(eventName, []); - } else { - buffers.get(eventName)!.push(event); - } - }) as Callbacks[keyof Callbacks]; - return [eventName, onEvent] as [keyof Callbacks, Callbacks[keyof Callbacks]]; - }); - for (const [eventName, onEvent] of eventHandlers) { - eventEmitter.on(eventName, onEvent); - } - - return { - async waitFor< - EventPayload extends Parameters[0], - EventName extends EventNames = EventNames, - >(eventName: EventName): Promise { - // If an event is already buffered which hasn't been processed yet, pull that off the buffer - // and use it. - const earliestBufferedEvent = buffers.get(eventName)!.shift(); - if (earliestBufferedEvent) { - return earliestBufferedEvent as EventPayload; - } - - // Otherwise wait for the next event to come in. - const future = new Future(); - nextEventListeners.get(eventName)!.push(future); - const nextEvent = await future.promise; - return nextEvent as EventPayload; - }, - unsubscribe: () => { - for (const [eventName, onEvent] of eventHandlers) { - eventEmitter.off(eventName, onEvent); - } - }, - }; -} +import { subscribeToEvents } from '../../../utils/subscribeToEvents'; describe('DataTrackOutgoingManager', () => { it('should test track publishing (ok case)', async () => { diff --git a/src/utils/subscribeToEvents.ts b/src/utils/subscribeToEvents.ts new file mode 100644 index 0000000000..6b6aef9bd4 --- /dev/null +++ b/src/utils/subscribeToEvents.ts @@ -0,0 +1,63 @@ +import { type EventMap } from 'typed-emitter'; +import type TypedEventEmitter from 'typed-emitter'; + +/** A test helper to listen to events received by an event emitter and allow them to be imperatively + * queried after the fact. */ +export function subscribeToEvents< + Callbacks extends EventMap, + EventNames extends keyof Callbacks = keyof Callbacks, +>(eventEmitter: TypedEventEmitter, eventNames: Array) { + const nextEventListeners = new Map>>( + eventNames.map((eventName) => [eventName, []]), + ); + const buffers = new Map>( + eventNames.map((eventName) => [eventName, []]), + ); + + const eventHandlers = eventNames.map((eventName) => { + const onEvent = ((event: unknown) => { + const listeners = nextEventListeners.get(eventName)!; + if (listeners.length > 0) { + for (const listener of listeners) { + listener.resolve?.(event); + } + nextEventListeners.set(eventName, []); + } else { + buffers.get(eventName)!.push(event); + } + }) as Callbacks[keyof Callbacks]; + return [eventName, onEvent] as [keyof Callbacks, Callbacks[keyof Callbacks]]; + }); + for (const [eventName, onEvent] of eventHandlers) { + eventEmitter.on(eventName, onEvent); + } + + return { + /** Listen for the next occurrance of an event to be emitted, or return the last event that was + * buffered (but hasn't been processed yet). */ + async waitFor< + EventPayload extends Parameters[0], + EventName extends EventNames = EventNames, + >(eventName: EventName): Promise { + // If an event is already buffered which hasn't been processed yet, pull that off the buffer + // and use it. + const earliestBufferedEvent = buffers.get(eventName)!.shift(); + if (earliestBufferedEvent) { + return earliestBufferedEvent as EventPayload; + } + + // Otherwise wait for the next event to come in. + const future = new Future(); + nextEventListeners.get(eventName)!.push(future); + const nextEvent = await future.promise; + return nextEvent as EventPayload; + }, + /** Cleanup any lingering subscriptions. */ + unsubscribe: () => { + for (const [eventName, onEvent] of eventHandlers) { + eventEmitter.off(eventName, onEvent); + } + }, + }; +} + From 44fd5cd3333fb10f2855c06055f1512ee19a7aad Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 10:56:50 -0500 Subject: [PATCH 19/43] refactor: move all errors into separate errors file --- src/room/data-track/outgoing/errors.ts | 161 +++++++++++++++++++ src/room/data-track/outgoing/manager.test.ts | 2 +- src/room/data-track/outgoing/manager.ts | 124 +------------- src/room/data-track/outgoing/pipeline.ts | 47 +----- 4 files changed, 167 insertions(+), 167 deletions(-) create mode 100644 src/room/data-track/outgoing/errors.ts diff --git a/src/room/data-track/outgoing/errors.ts b/src/room/data-track/outgoing/errors.ts new file mode 100644 index 0000000000..b408ab3d07 --- /dev/null +++ b/src/room/data-track/outgoing/errors.ts @@ -0,0 +1,161 @@ +import { LivekitReasonedError } from '../../errors'; +import { DataTrackPacketizerError, DataTrackPacketizerReason } from '../packetizer'; + +export enum DataTrackPublishErrorReason { + /** + * Local participant does not have permission to publish data tracks. + * + * Ensure the participant's token contains the `canPublishData` grant. + */ + NotAllowed = 0, + + /** A track with the same name is already published by the local participant. */ + DuplicateName = 1, + + /** Request to publish the track took long to complete. */ + Timeout = 2, + + /** No additional data tracks can be published by the local participant. */ + LimitReached = 3, + + /** Cannot publish data track when the room is disconnected. */ + Disconnected = 4, + + // FIXME: get rid of internal error concept, this is just represented as bare throws in js + // Internal = 5, + + // FIXME: this was introduced by web / there isn't a corresponding case in the rust version. + // Upon further reflection though I think this should exist in rust. + Cancelled = 6, +} + +export class DataTrackPublishError< + Reason extends DataTrackPublishErrorReason, +> extends LivekitReasonedError { + readonly name = 'DataTrackPublishError'; + + reason: Reason; + + reasonName: string; + + constructor(message: string, reason: Reason, options?: { cause?: unknown }) { + super(21, message, options); + this.reason = reason; + this.reasonName = DataTrackPublishErrorReason[reason]; + } + + static notAllowed() { + return new DataTrackPublishError( + 'Data track publishing unauthorized', + DataTrackPublishErrorReason.NotAllowed, + ); + } + + static duplicateName() { + return new DataTrackPublishError( + 'Track name already taken', + DataTrackPublishErrorReason.DuplicateName, + ); + } + + static timeout() { + return new DataTrackPublishError( + 'Publish data track timed-out', + DataTrackPublishErrorReason.Timeout, + ); + } + + static limitReached() { + return new DataTrackPublishError( + 'Data track publication limit reached', + DataTrackPublishErrorReason.LimitReached, + ); + } + + static disconnected() { + return new DataTrackPublishError('Room disconnected', DataTrackPublishErrorReason.Disconnected); + } + + // FIXME: this was introduced by web / there isn't a corresponding case in the rust version. + static cancelled() { + return new DataTrackPublishError( + 'Publish data track cancelled by caller', + DataTrackPublishErrorReason.Cancelled, + ); + } +} + +export enum DataTrackPushFrameErrorReason { + /** Track is no longer published. */ + TrackUnpublished = 0, + /** Frame was dropped. */ + Dropped = 1, +} + +export class DataTrackPushFrameError< + Reason extends DataTrackPushFrameErrorReason, +> extends LivekitReasonedError { + readonly name = 'DataTrackPushFrameError'; + + reason: Reason; + + reasonName: string; + + constructor(message: string, reason: Reason, options?: { cause?: unknown }) { + super(22, message, options); + this.reason = reason; + this.reasonName = DataTrackPushFrameErrorReason[reason]; + } + + static trackUnpublished() { + return new DataTrackPushFrameError( + 'Track is no longer published', + DataTrackPushFrameErrorReason.TrackUnpublished, + ); + } + + static dropped(cause: unknown) { + return new DataTrackPushFrameError( + 'Frame was dropped', + DataTrackPushFrameErrorReason.Dropped, + { cause } + ); + } +} + +export enum DataTrackOutgoingPipelineErrorReason { + Packetizer = 0, + Encryption = 1, +} + +export class DataTrackOutgoingPipelineError< + Reason extends DataTrackOutgoingPipelineErrorReason, +> extends LivekitReasonedError { + readonly name = 'DataTrackOutgoingPipelineError'; + + reason: Reason; + + reasonName: string; + + constructor(message: string, reason: Reason, options?: { cause?: unknown }) { + super(21, message, options); + this.reason = reason; + this.reasonName = DataTrackOutgoingPipelineErrorReason[reason]; + } + + static packetizer(cause: DataTrackPacketizerError) { + return new DataTrackOutgoingPipelineError( + 'Error packetizing frame', + DataTrackOutgoingPipelineErrorReason.Packetizer, + { cause }, + ); + } + + static encryption(cause: unknown) { + return new DataTrackOutgoingPipelineError( + 'Error encrypting frame', + DataTrackOutgoingPipelineErrorReason.Encryption, + { cause }, + ); + } +} diff --git a/src/room/data-track/outgoing/manager.test.ts b/src/room/data-track/outgoing/manager.test.ts index f4e6f257fd..ae1af4d55f 100644 --- a/src/room/data-track/outgoing/manager.test.ts +++ b/src/room/data-track/outgoing/manager.test.ts @@ -4,10 +4,10 @@ import { DataTrackHandle } from '../handle'; import { DataTrackPacket, FrameMarker } from '../packet'; import DataTrackOutgoingManager, { DataTrackOutgoingManagerCallbacks, - DataTrackPublishError, Descriptor, } from './manager'; import { subscribeToEvents } from '../../../utils/subscribeToEvents'; +import { DataTrackPublishError } from './errors'; describe('DataTrackOutgoingManager', () => { it('should test track publishing (ok case)', async () => { diff --git a/src/room/data-track/outgoing/manager.ts b/src/room/data-track/outgoing/manager.ts index 3df2c8e600..605d5d4f99 100644 --- a/src/room/data-track/outgoing/manager.ts +++ b/src/room/data-track/outgoing/manager.ts @@ -1,7 +1,6 @@ import { EventEmitter } from 'events'; import type TypedEmitter from 'typed-emitter'; import { LoggerNames, getLogger } from '../../../logger'; -import { LivekitReasonedError } from '../../errors'; import { Future } from '../../utils'; import { type EncryptionProvider } from '../e2ee'; import type { DataTrackFrame } from '../frame'; @@ -16,6 +15,7 @@ import { type SfuPublishResponseResult, } from './types'; import DataTrackOutgoingPipeline from './pipeline'; +import { DataTrackPushFrameError, DataTrackPublishError, DataTrackPublishErrorReason } from './errors'; const log = getLogger(LoggerNames.DataTracks); @@ -79,128 +79,6 @@ type DataTrackLocalManagerOptions = { decryptionProvider?: EncryptionProvider; }; -export enum DataTrackPublishErrorReason { - /** - * Local participant does not have permission to publish data tracks. - * - * Ensure the participant's token contains the `canPublishData` grant. - */ - NotAllowed = 0, - - /** A track with the same name is already published by the local participant. */ - DuplicateName = 1, - - /** Request to publish the track took long to complete. */ - Timeout = 2, - - /** No additional data tracks can be published by the local participant. */ - LimitReached = 3, - - /** Cannot publish data track when the room is disconnected. */ - Disconnected = 4, - - // FIXME: get rid of internal error concept, this is just represented as bare throws in js - // Internal = 5, - - // FIXME: this was introduced by web / there isn't a corresponding case in the rust version. - // Upon further reflection though I think this should exist in rust. - Cancelled = 6, -} - -export class DataTrackPublishError< - Reason extends DataTrackPublishErrorReason, -> extends LivekitReasonedError { - readonly name = 'DataTrackPublishError'; - - reason: Reason; - - reasonName: string; - - constructor(message: string, reason: Reason, options?: { cause?: unknown }) { - super(21, message, options); - this.reason = reason; - this.reasonName = DataTrackPublishErrorReason[reason]; - } - - static notAllowed() { - return new DataTrackPublishError( - 'Data track publishing unauthorized', - DataTrackPublishErrorReason.NotAllowed, - ); - } - - static duplicateName() { - return new DataTrackPublishError( - 'Track name already taken', - DataTrackPublishErrorReason.DuplicateName, - ); - } - - static timeout() { - return new DataTrackPublishError( - 'Publish data track timed-out', - DataTrackPublishErrorReason.Timeout, - ); - } - - static limitReached() { - return new DataTrackPublishError( - 'Data track publication limit reached', - DataTrackPublishErrorReason.LimitReached, - ); - } - - static disconnected() { - return new DataTrackPublishError('Room disconnected', DataTrackPublishErrorReason.Disconnected); - } - - // FIXME: this was introduced by web / there isn't a corresponding case in the rust version. - static cancelled() { - return new DataTrackPublishError( - 'Publish data track cancelled by caller', - DataTrackPublishErrorReason.Cancelled, - ); - } -} - -export enum DataTrackPushFrameErrorReason { - /** Track is no longer published. */ - TrackUnpublished = 0, - /** Frame was dropped. */ - Dropped = 1, -} - -export class DataTrackPushFrameError< - Reason extends DataTrackPushFrameErrorReason, -> extends LivekitReasonedError { - readonly name = 'DataTrackPushFrameError'; - - reason: Reason; - - reasonName: string; - - constructor(message: string, reason: Reason, options?: { cause?: unknown }) { - super(22, message, options); - this.reason = reason; - this.reasonName = DataTrackPushFrameErrorReason[reason]; - } - - static trackUnpublished() { - return new DataTrackPushFrameError( - 'Track is no longer published', - DataTrackPushFrameErrorReason.TrackUnpublished, - ); - } - - static dropped(cause: unknown) { - return new DataTrackPushFrameError( - 'Frame was dropped', - DataTrackPushFrameErrorReason.Dropped, - { cause } - ); - } -} - /** How long to wait when attempting to publish before timing out. */ const PUBLISH_TIMEOUT_MILLISECONDS = 10_000; diff --git a/src/room/data-track/outgoing/pipeline.ts b/src/room/data-track/outgoing/pipeline.ts index ebf241646f..90fd553736 100644 --- a/src/room/data-track/outgoing/pipeline.ts +++ b/src/room/data-track/outgoing/pipeline.ts @@ -1,57 +1,18 @@ import { type Throws } from '../../../utils/throws'; -import { LivekitReasonedError } from '../../errors'; import { type EncryptedPayload, type EncryptionProvider } from '../e2ee'; import { type DataTrackFrame } from '../frame'; import { DataTrackPacket } from '../packet'; import { DataTrackE2eeExtension } from '../packet/extensions'; -import DataTrackPacketizer, { - DataTrackPacketizerError, - DataTrackPacketizerReason, -} from '../packetizer'; +import DataTrackPacketizer, { DataTrackPacketizerError } from '../packetizer'; import type { DataTrackInfo } from '../track'; - -enum DataTrackOutgoingPipelineErrorReason { - Packetizer = 0, - Encryption = 1, -} - -class DataTrackOutgoingPipelineError< - Reason extends DataTrackOutgoingPipelineErrorReason, -> extends LivekitReasonedError { - readonly name = 'DataTrackOutgoingPipelineError'; - - reason: Reason; - - reasonName: string; - - constructor(message: string, reason: Reason, options?: { cause?: unknown }) { - super(21, message, options); - this.reason = reason; - this.reasonName = DataTrackOutgoingPipelineErrorReason[reason]; - } - - static packetizer(cause: DataTrackPacketizerError) { - return new DataTrackOutgoingPipelineError( - 'Error packetizing frame', - DataTrackOutgoingPipelineErrorReason.Packetizer, - { cause }, - ); - } - - static encryption(cause: unknown) { - return new DataTrackOutgoingPipelineError( - 'Error encrypting frame', - DataTrackOutgoingPipelineErrorReason.Encryption, - { cause }, - ); - } -} +import { DataTrackOutgoingPipelineError, DataTrackOutgoingPipelineErrorReason } from './errors'; type Options = { info: DataTrackInfo; encryptionProvider: EncryptionProvider | null; }; +/** Processes outgoing frames into final packets for distribution to the SFU. */ export default class DataTrackOutgoingPipeline { private encryptionProvider: EncryptionProvider | null; private packetizer: DataTrackPacketizer; @@ -74,7 +35,7 @@ export default class DataTrackOutgoingPipeline { | DataTrackOutgoingPipelineError | DataTrackOutgoingPipelineError > { - let encryptedFrame = this.encryptIfNeeded(frame); + const encryptedFrame = this.encryptIfNeeded(frame); try { yield* this.packetizer.packetize(encryptedFrame); From f582982dc313b3abb5a164c2a82a66a4d207253a Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 11:39:23 -0500 Subject: [PATCH 20/43] refactor: rename DataTrackOutgoingManager -> OutgoingDataTrackManager --- ....test.ts => OutgoingDataTrackManager.test.ts} | 16 ++++++++-------- .../{manager.ts => OutgoingDataTrackManager.ts} | 4 ++-- src/room/data-track/outgoing/types.ts | 2 +- src/room/data-track/track.ts | 6 +++--- 4 files changed, 14 insertions(+), 14 deletions(-) rename src/room/data-track/outgoing/{manager.test.ts => OutgoingDataTrackManager.test.ts} (95%) rename src/room/data-track/outgoing/{manager.ts => OutgoingDataTrackManager.ts} (98%) diff --git a/src/room/data-track/outgoing/manager.test.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts similarity index 95% rename from src/room/data-track/outgoing/manager.test.ts rename to src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts index ae1af4d55f..a4f212259e 100644 --- a/src/room/data-track/outgoing/manager.test.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts @@ -2,16 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DataTrackHandle } from '../handle'; import { DataTrackPacket, FrameMarker } from '../packet'; -import DataTrackOutgoingManager, { +import OutgoingDataTrackManager, { DataTrackOutgoingManagerCallbacks, Descriptor, -} from './manager'; +} from './OutgoingDataTrackManager'; import { subscribeToEvents } from '../../../utils/subscribeToEvents'; import { DataTrackPublishError } from './errors'; describe('DataTrackOutgoingManager', () => { it('should test track publishing (ok case)', async () => { - const manager = new DataTrackOutgoingManager(); + const manager = new OutgoingDataTrackManager(); const managerEvents = subscribeToEvents(manager, [ 'sfuPublishRequest', ]); @@ -42,7 +42,7 @@ describe('DataTrackOutgoingManager', () => { }); it('should test track publishing (error case)', async () => { - const manager = new DataTrackOutgoingManager(); + const manager = new OutgoingDataTrackManager(); const managerEvents = subscribeToEvents(manager, [ 'sfuPublishRequest', ]); @@ -123,7 +123,7 @@ describe('DataTrackOutgoingManager', () => { 'should test track payload sending', async (inputBytes: Uint8Array, outputPacketsJson: Array) => { // Create a manager prefilled with a descriptor - const manager = DataTrackOutgoingManager.withDescriptors( + const manager = OutgoingDataTrackManager.withDescriptors( new Map([ [ DataTrackHandle.fromNumber(5), @@ -165,7 +165,7 @@ describe('DataTrackOutgoingManager', () => { it('should test track unpublishing', async () => { // Create a manager prefilled with a descriptor - const manager = DataTrackOutgoingManager.withDescriptors( + const manager = OutgoingDataTrackManager.withDescriptors( new Map([ [ DataTrackHandle.fromNumber(5), @@ -204,7 +204,7 @@ describe('DataTrackOutgoingManager', () => { it('should query currently active descriptors', async () => { // Create a manager prefilled with a descriptor - const manager = DataTrackOutgoingManager.withDescriptors( + const manager = OutgoingDataTrackManager.withDescriptors( new Map([ [ DataTrackHandle.fromNumber(2), @@ -244,7 +244,7 @@ describe('DataTrackOutgoingManager', () => { it('should shutdown cleanly', async () => { // Create a manager prefilled with a descriptor const pendingDescriptor = Descriptor.pending(); - const manager = DataTrackOutgoingManager.withDescriptors( + const manager = OutgoingDataTrackManager.withDescriptors( new Map([ [DataTrackHandle.fromNumber(2), pendingDescriptor], [DataTrackHandle.fromNumber(6), Descriptor.active({ diff --git a/src/room/data-track/outgoing/manager.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts similarity index 98% rename from src/room/data-track/outgoing/manager.ts rename to src/room/data-track/outgoing/OutgoingDataTrackManager.ts index 605d5d4f99..92aad9964d 100644 --- a/src/room/data-track/outgoing/manager.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts @@ -82,7 +82,7 @@ type DataTrackLocalManagerOptions = { /** How long to wait when attempting to publish before timing out. */ const PUBLISH_TIMEOUT_MILLISECONDS = 10_000; -export default class DataTrackOutgoingManager extends (EventEmitter as new () => TypedEmitter) { +export default class OutgoingDataTrackManager extends (EventEmitter as new () => TypedEmitter) { private encryptionProvider: EncryptionProvider | null; private handleAllocator = new DataTrackHandleAllocator(); // FIXME: key of this map is the same as the value Descriptor["info"]["pubHandle"] @@ -94,7 +94,7 @@ export default class DataTrackOutgoingManager extends (EventEmitter as new () => } static withDescriptors(descriptors: Map) { - const manager = new DataTrackOutgoingManager(); + const manager = new OutgoingDataTrackManager(); manager.descriptors = descriptors; return manager; } diff --git a/src/room/data-track/outgoing/types.ts b/src/room/data-track/outgoing/types.ts index ed631abbb3..36e069f14b 100644 --- a/src/room/data-track/outgoing/types.ts +++ b/src/room/data-track/outgoing/types.ts @@ -1,6 +1,6 @@ import { type DataTrackHandle } from '../handle'; import { type DataTrackInfo } from '../track'; -import { type DataTrackPublishError, type DataTrackPublishErrorReason } from './manager'; +import { type DataTrackPublishError, type DataTrackPublishErrorReason } from './errors'; /** Options for publishing a data track. */ export type DataTrackOptions = { diff --git a/src/room/data-track/track.ts b/src/room/data-track/track.ts index 9a9c9602ef..bcfbff30eb 100644 --- a/src/room/data-track/track.ts +++ b/src/room/data-track/track.ts @@ -1,6 +1,6 @@ import type { DataTrackFrame } from './frame'; import { type DataTrackHandle } from './handle'; -import type DataTrackOutgoingManager from './outgoing/manager'; +import type OutgoingDataTrackManager from './outgoing/OutgoingDataTrackManager'; export type DataTrackSid = string; @@ -15,9 +15,9 @@ export type DataTrackInfo = { export class LocalDataTrack { info: DataTrackInfo; - protected manager: DataTrackOutgoingManager; + protected manager: OutgoingDataTrackManager; - constructor(info: DataTrackInfo, manager: DataTrackOutgoingManager) { + constructor(info: DataTrackInfo, manager: OutgoingDataTrackManager) { this.info = info; this.manager = manager; } From df8ba23f095be7a45619b6c8d09e1ff599c4a4ad Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 11:39:50 -0500 Subject: [PATCH 21/43] fix: add missing import --- src/utils/subscribeToEvents.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/subscribeToEvents.ts b/src/utils/subscribeToEvents.ts index 6b6aef9bd4..8dc8de38d3 100644 --- a/src/utils/subscribeToEvents.ts +++ b/src/utils/subscribeToEvents.ts @@ -1,5 +1,6 @@ import { type EventMap } from 'typed-emitter'; import type TypedEventEmitter from 'typed-emitter'; +import { Future } from '../room/utils'; /** A test helper to listen to events received by an event emitter and allow them to be imperatively * queried after the fact. */ From 35bf3d59b1202ea6ea59b3f6644b83be9bac4528 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 12:31:32 -0500 Subject: [PATCH 22/43] feat: add missing Throw error brands --- src/room/data-track/outgoing/OutgoingDataTrackManager.ts | 9 +++++++-- src/room/data-track/outgoing/errors.ts | 2 +- src/room/data-track/outgoing/pipeline.ts | 4 ++-- src/room/data-track/packetizer.ts | 4 ++++ 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts index 92aad9964d..87683997a2 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts @@ -15,7 +15,8 @@ import { type SfuPublishResponseResult, } from './types'; import DataTrackOutgoingPipeline from './pipeline'; -import { DataTrackPushFrameError, DataTrackPublishError, DataTrackPublishErrorReason } from './errors'; +import { DataTrackPushFrameError, DataTrackPublishError, DataTrackPublishErrorReason, DataTrackPushFrameErrorReason } from './errors'; +import type { Throws } from '../../../utils/throws'; const log = getLogger(LoggerNames.DataTracks); @@ -123,7 +124,11 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => handle: DataTrackHandle, payload: Uint8Array, options?: { signal?: AbortSignal }, - ) { + ): Throws< + void, + | DataTrackPushFrameError + | DataTrackPushFrameError + > { const descriptor = this.getDescriptor(handle); if (descriptor?.type !== 'active') { throw DataTrackPushFrameError.trackUnpublished(); diff --git a/src/room/data-track/outgoing/errors.ts b/src/room/data-track/outgoing/errors.ts index b408ab3d07..04176187ec 100644 --- a/src/room/data-track/outgoing/errors.ts +++ b/src/room/data-track/outgoing/errors.ts @@ -143,7 +143,7 @@ export class DataTrackOutgoingPipelineError< this.reasonName = DataTrackOutgoingPipelineErrorReason[reason]; } - static packetizer(cause: DataTrackPacketizerError) { + static packetizer(cause: DataTrackPacketizerError) { return new DataTrackOutgoingPipelineError( 'Error packetizing frame', DataTrackOutgoingPipelineErrorReason.Packetizer, diff --git a/src/room/data-track/outgoing/pipeline.ts b/src/room/data-track/outgoing/pipeline.ts index 90fd553736..4cff2beb4a 100644 --- a/src/room/data-track/outgoing/pipeline.ts +++ b/src/room/data-track/outgoing/pipeline.ts @@ -3,7 +3,7 @@ import { type EncryptedPayload, type EncryptionProvider } from '../e2ee'; import { type DataTrackFrame } from '../frame'; import { DataTrackPacket } from '../packet'; import { DataTrackE2eeExtension } from '../packet/extensions'; -import DataTrackPacketizer, { DataTrackPacketizerError } from '../packetizer'; +import DataTrackPacketizer, { DataTrackPacketizerError, DataTrackPacketizerReason } from '../packetizer'; import type { DataTrackInfo } from '../track'; import { DataTrackOutgoingPipelineError, DataTrackOutgoingPipelineErrorReason } from './errors'; @@ -40,7 +40,7 @@ export default class DataTrackOutgoingPipeline { try { yield* this.packetizer.packetize(encryptedFrame); } catch (error) { - if (error instanceof DataTrackPacketizerError) { + if (error instanceof DataTrackPacketizerError && error.isReason(DataTrackPacketizerReason.MtuTooShort)) { throw DataTrackOutgoingPipelineError.packetizer(error); } throw error; diff --git a/src/room/data-track/packetizer.ts b/src/room/data-track/packetizer.ts index 0e6a8fe7cd..19f9681bb3 100644 --- a/src/room/data-track/packetizer.ts +++ b/src/room/data-track/packetizer.ts @@ -26,6 +26,10 @@ export class DataTrackPacketizerError< this.reasonName = DataTrackPacketizerReason[reason]; } + isReason(reason: R): this is DataTrackPacketizerError { + return (this.reason as unknown as R) === reason; + } + static mtuTooShort() { return new DataTrackPacketizerError( 'MTU is too short to send frame', From fd64eb4da1b9df62b82f009ade5138647ff2982d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 12:32:27 -0500 Subject: [PATCH 23/43] feat: add support for try/catch bare rethrowing to throws-transformer ie, like: try { // ... } catch (e) { throw new WrapperErrorType(e); } --- throws-transformer/engine.ts | 100 +++++++++++++++++++++++++++-------- 1 file changed, 77 insertions(+), 23 deletions(-) diff --git a/throws-transformer/engine.ts b/throws-transformer/engine.ts index 8a68b66186..a083e745f3 100644 --- a/throws-transformer/engine.ts +++ b/throws-transformer/engine.ts @@ -8,8 +8,6 @@ */ import * as ts from "typescript"; -import * as path from "path"; -import { sync as globSync } from "glob"; // Symbol name for the Throws type brand const THROWS_BRAND = "__throws"; @@ -79,6 +77,26 @@ export function checkSourceFile( return results; } +function preceededByIgnoreComment(node: ts.Node, sourceFile: ts.SourceFile) { + const foundComments = ts.getLeadingCommentRanges(sourceFile.text, node.pos); + if (foundComments) { + const foundCommentsText = foundComments.map((info) => { + return sourceFile + .text + .slice(info.pos, info.end) + .replace(/^(\/\/|\/\*)\s*/ /* Remove leading comment prefix */, ''); + }); + + const isIgnoreComment = foundCommentsText.find((commentText) => { + return commentText.startsWith('@throws-transformer ignore'); + }); + + return isIgnoreComment; + } else { + return false; + } +} + function checkThrowStatement( node: ts.ThrowStatement, sourceFile: ts.SourceFile, @@ -106,22 +124,8 @@ function checkThrowStatement( // Check to see if there is a comment about the throw site starting with "@throws-transformer // ignore", and if so, disregard. - const foundComments = ts.getLeadingCommentRanges(sourceFile.text, node.pos); - if (foundComments) { - const foundCommentsText = foundComments.map((info) => { - return sourceFile - .text - .slice(info.pos, info.end) - .replace(/^(\/\/|\/\*)\s*/ /* Remove leading comment prefix */, ''); - }); - - const isIgnoreComment = foundCommentsText.find((commentText) => { - return commentText.startsWith('@throws-transformer ignore'); - }); - - if (isIgnoreComment) { - return null; - } + if (preceededByIgnoreComment(node, sourceFile)) { + return null; } const thrownType = checker.getTypeAtLocation(node.expression); @@ -331,8 +335,12 @@ function checkCallExpression( // Get the return type of the call const callType = checker.getTypeAtLocation(node); + const tryCatch = getContainingTryCatch(node); + // Extract error types - const errorTypes = extractThrowsErrorTypes(callType, checker); + const errorTypes = tryCatch ? ( + getTryCatchThrownErrors(tryCatch, sourceFile, checker) + ) : extractThrowsErrorTypes(callType, checker); if (errorTypes.length === 0) { return null; @@ -340,19 +348,19 @@ function checkCallExpression( // Check handling const containingFunction = getContainingFunction(node); - const tryCatch = getContainingTryCatch(node); const handledErrors = tryCatch ? getHandledErrorTypes(tryCatch, checker, node) : new Set(); - // If catch-all, everything is handled + // If the catch clause contains no throws all errors are being silenced + // TODO: maybe log a warning here, this is probably bad? if (handledErrors === "all") { return null; } const propagatedErrors = containingFunction - ? getPropagatedErrorTypes(containingFunction, checker) + ? getPropagatedErrorTypes(node, containingFunction, sourceFile, checker) : new Set(); // Find unhandled @@ -365,6 +373,10 @@ function checkCallExpression( return null; } + if (preceededByIgnoreComment(node, sourceFile)) { + return null; + } + const start = node.getStart(); const length = node.getWidth(); const { line, character } = sourceFile.getLineAndCharacterOfPosition(start); @@ -559,6 +571,39 @@ function getContainingTryCatch(node: ts.Node): ts.TryStatement | null { return null; } +/** Get errors which the given try/catch passed itself throws within its catch block. */ +function getTryCatchThrownErrors(tryCatch: ts.TryStatement, sourceFile: ts.SourceFile, checker: ts.TypeChecker) { + const thrownErrorTypes: Array = []; + + if (!tryCatch.catchClause) { + return thrownErrorTypes; + } + + function visitThrowStatement(throwStmt: ts.ThrowStatement) { + const thrownErrorType = checker.getTypeAtLocation(throwStmt.expression); + if (!isAnyOrUnknownType(thrownErrorType)) { + if (thrownErrorType.isUnion()) { + for (const type of thrownErrorType.types.filter(t => !isAnyOrUnknownType(t))) { + thrownErrorTypes.push(type); + } + } else { + thrownErrorTypes.push(thrownErrorType); + } + } + } + + function visit(node: ts.Node): void { + if (ts.isThrowStatement(node) && checkThrowStatement(node, sourceFile, checker)) { + visitThrowStatement(node); + } + ts.forEachChild(node, visit); + } + + visit(tryCatch.catchClause); + + return thrownErrorTypes; +} + function isInTryBlock(node: ts.Node, tryStatement: ts.TryStatement): boolean { let current: ts.Node | undefined = node; @@ -661,7 +706,7 @@ function findNarrowedErrorTypes( * Analyze if-statements to find type narrowing branches that don't re-throw. * These represent error types that are handled. */ - function visitIfStatement(ifStmt: ts.IfStatement): void { + function visitIfStatement(ifStmt: ts.IfStatement) { // Get the type of the error variable after the type guard in the if condition // const condition = ifStmt.expression; @@ -817,13 +862,22 @@ function findInstanceofChecks( } function getPropagatedErrorTypes( + node: ts.Node, func: ts.FunctionLikeDeclaration, + sourceFile: ts.SourceFile, checker: ts.TypeChecker, ): Set { const propagated = new Set(); if (!func.type) { return propagated; } + // If `node` is in a try/catch, then the errors propegated are the errors that the catch itself throws + const tryCatch = getContainingTryCatch(node); + if (tryCatch?.catchClause) { + const thrownErrorTypes = getTryCatchThrownErrors(tryCatch, sourceFile, checker); + return new Set(thrownErrorTypes.map(e => checker.typeToString(e))); + } + const returnType = checker.getTypeFromTypeNode(func.type); const errorTypes = extractThrowsErrorTypes(returnType, checker); From 69c45611780cd16d31ad2aa23b3a3cfece4e2c15 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 13:27:10 -0500 Subject: [PATCH 24/43] fix: add missing throws branding --- src/room/data-track/track.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/room/data-track/track.ts b/src/room/data-track/track.ts index bcfbff30eb..fa150c34f4 100644 --- a/src/room/data-track/track.ts +++ b/src/room/data-track/track.ts @@ -1,5 +1,7 @@ +import type { Throws } from '../../utils/throws'; import type { DataTrackFrame } from './frame'; import { type DataTrackHandle } from './handle'; +import type { DataTrackPushFrameError, DataTrackPushFrameErrorReason } from './outgoing/errors'; import type OutgoingDataTrackManager from './outgoing/OutgoingDataTrackManager'; export type DataTrackSid = string; @@ -39,7 +41,11 @@ export class LocalDataTrack { * - The room is no longer connected * - Frames are being pushed too fast (FIXME: this isn't the case in the js implementation?) */ - tryPush(payload: DataTrackFrame['payload'], options?: { signal?: AbortSignal }) { + tryPush(payload: DataTrackFrame['payload'], options?: { signal?: AbortSignal }): Throws< + void, + | DataTrackPushFrameError + | DataTrackPushFrameError + > { // FIXME: rust implementation maps errors to dropped here? // .map_err(|err| PushFrameError::new(err.into_inner(), PushFrameErrorReason::Dropped)) return this.manager.tryProcessAndSend(this.info.pubHandle, payload, options); From 424438eceea462a3f2dc6ce75c50fe5c1b77d6a0 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 13:27:20 -0500 Subject: [PATCH 25/43] fix: add throws ignore due to introduction of throws in Future --- src/room/participant/LocalParticipant.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index 495ccc80dd..c6b9432844 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -275,6 +275,8 @@ export default class LocalParticipant extends Participant { private handleClosing = () => { if (this.reconnectFuture) { + // @throws-transformer ignore - introduced due to adding Throws into Future, investigate this + // further this.reconnectFuture.promise.catch((e) => this.log.warn(e.message, this.logContext)); this.reconnectFuture?.reject?.(new Error('Got disconnected during reconnection attempt')); this.reconnectFuture = undefined; From 98819fa7f03a216c95c1d3a21c494f9fd1775b9c Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 13:28:28 -0500 Subject: [PATCH 26/43] fox: run npm run format --- .../outgoing/OutgoingDataTrackManager.test.ts | 22 ++++++++++++------- .../outgoing/OutgoingDataTrackManager.ts | 16 ++++++++------ src/room/data-track/outgoing/errors.ts | 8 +++---- src/room/data-track/outgoing/pipeline.ts | 10 +++++++-- src/room/data-track/track.ts | 7 ++++-- src/utils/subscribeToEvents.ts | 3 +-- 6 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts index a4f212259e..1c320ea3ad 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { subscribeToEvents } from '../../../utils/subscribeToEvents'; import { DataTrackHandle } from '../handle'; import { DataTrackPacket, FrameMarker } from '../packet'; import OutgoingDataTrackManager, { DataTrackOutgoingManagerCallbacks, Descriptor, } from './OutgoingDataTrackManager'; -import { subscribeToEvents } from '../../../utils/subscribeToEvents'; import { DataTrackPublishError } from './errors'; describe('DataTrackOutgoingManager', () => { @@ -247,12 +247,18 @@ describe('DataTrackOutgoingManager', () => { const manager = OutgoingDataTrackManager.withDescriptors( new Map([ [DataTrackHandle.fromNumber(2), pendingDescriptor], - [DataTrackHandle.fromNumber(6), Descriptor.active({ - sid: 'bogus-sid-6', - pubHandle: 6, - name: 'sixsixsix', - usesE2ee: false, - }, null)], + [ + DataTrackHandle.fromNumber(6), + Descriptor.active( + { + sid: 'bogus-sid-6', + pubHandle: 6, + name: 'sixsixsix', + usesE2ee: false, + }, + null, + ), + ], ]), ); const managerEvents = subscribeToEvents(manager, [ @@ -266,7 +272,7 @@ describe('DataTrackOutgoingManager', () => { expect(pendingDescriptor.completionFuture.promise).rejects.toThrowError('Room disconnected'); // And the active data track should be requested to be unpublished - const unpublishEvent = await managerEvents.waitFor("sfuUnpublishRequest"); + const unpublishEvent = await managerEvents.waitFor('sfuUnpublishRequest'); expect(unpublishEvent.handle).toStrictEqual(6); // Acknowledge that the unpublish has occurred diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts index 87683997a2..c82168434c 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts @@ -1,12 +1,20 @@ import { EventEmitter } from 'events'; import type TypedEmitter from 'typed-emitter'; import { LoggerNames, getLogger } from '../../../logger'; +import type { Throws } from '../../../utils/throws'; import { Future } from '../../utils'; import { type EncryptionProvider } from '../e2ee'; import type { DataTrackFrame } from '../frame'; import { DataTrackHandle, DataTrackHandleAllocator } from '../handle'; import { DataTrackExtensions } from '../packet/extensions'; import { type DataTrackInfo, LocalDataTrack } from '../track'; +import { + DataTrackPublishError, + DataTrackPublishErrorReason, + DataTrackPushFrameError, + DataTrackPushFrameErrorReason, +} from './errors'; +import DataTrackOutgoingPipeline from './pipeline'; import { type DataTrackOptions, type OutputEventPacketsAvailable, @@ -14,9 +22,6 @@ import { type OutputEventSfuUnpublishRequest, type SfuPublishResponseResult, } from './types'; -import DataTrackOutgoingPipeline from './pipeline'; -import { DataTrackPushFrameError, DataTrackPublishError, DataTrackPublishErrorReason, DataTrackPushFrameErrorReason } from './errors'; -import type { Throws } from '../../../utils/throws'; const log = getLogger(LoggerNames.DataTracks); @@ -155,7 +160,6 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => } } - /** Client requested to publish a track. */ async publishRequest(options: DataTrackOptions, signal?: AbortSignal) { const handle = this.handleAllocator.get(); @@ -164,9 +168,7 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => } const timeoutSignal = AbortSignal.timeout(PUBLISH_TIMEOUT_MILLISECONDS); - const combinedSignal = signal - ? AbortSignal.any([signal, timeoutSignal]) - : timeoutSignal; + const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; if (this.descriptors.has(handle)) { // @throws-transformer ignore - this should be treated as a "panic" and not be caught diff --git a/src/room/data-track/outgoing/errors.ts b/src/room/data-track/outgoing/errors.ts index 04176187ec..7559bb0430 100644 --- a/src/room/data-track/outgoing/errors.ts +++ b/src/room/data-track/outgoing/errors.ts @@ -115,11 +115,9 @@ export class DataTrackPushFrameError< } static dropped(cause: unknown) { - return new DataTrackPushFrameError( - 'Frame was dropped', - DataTrackPushFrameErrorReason.Dropped, - { cause } - ); + return new DataTrackPushFrameError('Frame was dropped', DataTrackPushFrameErrorReason.Dropped, { + cause, + }); } } diff --git a/src/room/data-track/outgoing/pipeline.ts b/src/room/data-track/outgoing/pipeline.ts index 4cff2beb4a..8d81e0701e 100644 --- a/src/room/data-track/outgoing/pipeline.ts +++ b/src/room/data-track/outgoing/pipeline.ts @@ -3,7 +3,10 @@ import { type EncryptedPayload, type EncryptionProvider } from '../e2ee'; import { type DataTrackFrame } from '../frame'; import { DataTrackPacket } from '../packet'; import { DataTrackE2eeExtension } from '../packet/extensions'; -import DataTrackPacketizer, { DataTrackPacketizerError, DataTrackPacketizerReason } from '../packetizer'; +import DataTrackPacketizer, { + DataTrackPacketizerError, + DataTrackPacketizerReason, +} from '../packetizer'; import type { DataTrackInfo } from '../track'; import { DataTrackOutgoingPipelineError, DataTrackOutgoingPipelineErrorReason } from './errors'; @@ -40,7 +43,10 @@ export default class DataTrackOutgoingPipeline { try { yield* this.packetizer.packetize(encryptedFrame); } catch (error) { - if (error instanceof DataTrackPacketizerError && error.isReason(DataTrackPacketizerReason.MtuTooShort)) { + if ( + error instanceof DataTrackPacketizerError && + error.isReason(DataTrackPacketizerReason.MtuTooShort) + ) { throw DataTrackOutgoingPipelineError.packetizer(error); } throw error; diff --git a/src/room/data-track/track.ts b/src/room/data-track/track.ts index fa150c34f4..398e2ec939 100644 --- a/src/room/data-track/track.ts +++ b/src/room/data-track/track.ts @@ -1,8 +1,8 @@ import type { Throws } from '../../utils/throws'; import type { DataTrackFrame } from './frame'; import { type DataTrackHandle } from './handle'; -import type { DataTrackPushFrameError, DataTrackPushFrameErrorReason } from './outgoing/errors'; import type OutgoingDataTrackManager from './outgoing/OutgoingDataTrackManager'; +import type { DataTrackPushFrameError, DataTrackPushFrameErrorReason } from './outgoing/errors'; export type DataTrackSid = string; @@ -41,7 +41,10 @@ export class LocalDataTrack { * - The room is no longer connected * - Frames are being pushed too fast (FIXME: this isn't the case in the js implementation?) */ - tryPush(payload: DataTrackFrame['payload'], options?: { signal?: AbortSignal }): Throws< + tryPush( + payload: DataTrackFrame['payload'], + options?: { signal?: AbortSignal }, + ): Throws< void, | DataTrackPushFrameError | DataTrackPushFrameError diff --git a/src/utils/subscribeToEvents.ts b/src/utils/subscribeToEvents.ts index 8dc8de38d3..8d9b623c06 100644 --- a/src/utils/subscribeToEvents.ts +++ b/src/utils/subscribeToEvents.ts @@ -35,7 +35,7 @@ export function subscribeToEvents< return { /** Listen for the next occurrance of an event to be emitted, or return the last event that was - * buffered (but hasn't been processed yet). */ + * buffered (but hasn't been processed yet). */ async waitFor< EventPayload extends Parameters[0], EventName extends EventNames = EventNames, @@ -61,4 +61,3 @@ export function subscribeToEvents< }, }; } - From 3a85e17759493fba8d43fb1e91734d87844a83e4 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 13:29:48 -0500 Subject: [PATCH 27/43] fix: remove unneeded error descriminator --- src/room/data-track/outgoing/pipeline.ts | 5 +---- src/room/data-track/packetizer.ts | 4 ---- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/room/data-track/outgoing/pipeline.ts b/src/room/data-track/outgoing/pipeline.ts index 8d81e0701e..2fd45ebb6e 100644 --- a/src/room/data-track/outgoing/pipeline.ts +++ b/src/room/data-track/outgoing/pipeline.ts @@ -43,10 +43,7 @@ export default class DataTrackOutgoingPipeline { try { yield* this.packetizer.packetize(encryptedFrame); } catch (error) { - if ( - error instanceof DataTrackPacketizerError && - error.isReason(DataTrackPacketizerReason.MtuTooShort) - ) { + if (error instanceof DataTrackPacketizerError) { throw DataTrackOutgoingPipelineError.packetizer(error); } throw error; diff --git a/src/room/data-track/packetizer.ts b/src/room/data-track/packetizer.ts index 19f9681bb3..0e6a8fe7cd 100644 --- a/src/room/data-track/packetizer.ts +++ b/src/room/data-track/packetizer.ts @@ -26,10 +26,6 @@ export class DataTrackPacketizerError< this.reasonName = DataTrackPacketizerReason[reason]; } - isReason(reason: R): this is DataTrackPacketizerError { - return (this.reason as unknown as R) === reason; - } - static mtuTooShort() { return new DataTrackPacketizerError( 'MTU is too short to send frame', From 379ee00e9f0ffadbf51a49539b8ddd1efebda9d8 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 13:31:23 -0500 Subject: [PATCH 28/43] fix: run npm run lint --- src/room/data-track/outgoing/OutgoingDataTrackManager.ts | 2 ++ src/room/data-track/outgoing/pipeline.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts index c82168434c..e5b8793225 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts @@ -90,7 +90,9 @@ const PUBLISH_TIMEOUT_MILLISECONDS = 10_000; export default class OutgoingDataTrackManager extends (EventEmitter as new () => TypedEmitter) { private encryptionProvider: EncryptionProvider | null; + private handleAllocator = new DataTrackHandleAllocator(); + // FIXME: key of this map is the same as the value Descriptor["info"]["pubHandle"] private descriptors = new Map(); diff --git a/src/room/data-track/outgoing/pipeline.ts b/src/room/data-track/outgoing/pipeline.ts index 2fd45ebb6e..0e09bcc48e 100644 --- a/src/room/data-track/outgoing/pipeline.ts +++ b/src/room/data-track/outgoing/pipeline.ts @@ -18,6 +18,7 @@ type Options = { /** Processes outgoing frames into final packets for distribution to the SFU. */ export default class DataTrackOutgoingPipeline { private encryptionProvider: EncryptionProvider | null; + private packetizer: DataTrackPacketizer; /** Maximum transmission unit (MTU) of the transport. */ From dd43cfedbcac256ccc20e4bf9541d9c98e15f76a Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 14:01:47 -0500 Subject: [PATCH 29/43] feat: add packet encryption test --- .../outgoing/OutgoingDataTrackManager.test.ts | 88 +++++++++++++++++++ .../outgoing/OutgoingDataTrackManager.ts | 4 +- src/room/data-track/outgoing/pipeline.ts | 5 +- 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts index 1c320ea3ad..0e7f9c46bf 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { subscribeToEvents } from '../../../utils/subscribeToEvents'; +import { EncryptionProvider } from '../e2ee'; import { DataTrackHandle } from '../handle'; import { DataTrackPacket, FrameMarker } from '../packet'; import OutgoingDataTrackManager, { @@ -9,6 +10,23 @@ import OutgoingDataTrackManager, { } from './OutgoingDataTrackManager'; import { DataTrackPublishError } from './errors'; +/** A fake "encryption" provider used for test purposes. Adds a prefix to the payload. */ +const PrefixingEncryptionProvider: EncryptionProvider = { + encrypt(payload: Uint8Array) { + const prefix = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + + const output = new Uint8Array(prefix.length + payload.length); + output.set(prefix, 0); + output.set(payload, prefix.length); + + return { + payload: output, + iv: new Uint8Array(12), // Just leaving this empty, is this a bad idea? + keyIndex: 0, + }; + }, +}; + describe('DataTrackOutgoingManager', () => { it('should test track publishing (ok case)', async () => { const manager = new OutgoingDataTrackManager(); @@ -163,6 +181,76 @@ describe('DataTrackOutgoingManager', () => { }, ); + it('should send e2ee encrypted datatrack payload', async () => { + const manager = new OutgoingDataTrackManager({ + encryptionProvider: PrefixingEncryptionProvider, + }); + const managerEvents = subscribeToEvents(manager, [ + 'sfuPublishRequest', + 'packetsAvailable', + ]); + + // 1. Publish a data track + const publishRequestPromise = manager.publishRequest({ name: 'test' }); + + // 2. This publish request should be sent along to the SFU + const sfuPublishEvent = await managerEvents.waitFor('sfuPublishRequest'); + expect(sfuPublishEvent.name).toStrictEqual('test'); + expect(sfuPublishEvent.usesE2ee).toStrictEqual(true); // NOTE: this is true, e2ee is enabled! + const handle = sfuPublishEvent.handle; + + // 3. Respond to the SFU publish request with an OK response + manager.receivedSfuPublishResponse(handle, { + type: 'ok', + data: { + sid: 'bogus-sid', + pubHandle: sfuPublishEvent.handle, + name: 'test', + usesE2ee: true, // NOTE: this is true, e2ee is enabled! + }, + }); + + // Get the connected local data track + const localDataTrack = await publishRequestPromise; + expect(localDataTrack.isPublished()).toStrictEqual(true); + + // Kick off sending the payload bytes + localDataTrack.tryPush(new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05])); + + // Make sure the packet that was sent was encrypted with the PrefixingEncryptionProvider + const packetBytes = await managerEvents.waitFor('packetsAvailable'); + const [packet] = DataTrackPacket.fromBinary(packetBytes.bytes); + + const packetJson = packet.toJSON(); + // (note: zero out the header timestamp because the date "now" isn't being mocked) + packetJson.header.timestamp = 0; + + expect(packetJson).toStrictEqual({ + header: { + extensions: { + e2ee: { + iv: new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + keyIndex: 0, + lengthBytes: 13, + tag: 1, + }, + userTimestamp: null, + }, + frameNumber: 0, + marker: 3, + sequence: 0, + timestamp: 0, // (zeroed out in the test, since this isn't mocked) + trackHandle: 1, + }, + payload: new Uint8Array([ + // Encryption added prefix + 0xde, 0xad, 0xbe, 0xef, + // Actual payload + 0x01, 0x02, 0x03, 0x04, 0x05, + ]), + }); + }); + it('should test track unpublishing', async () => { // Create a manager prefilled with a descriptor const manager = OutgoingDataTrackManager.withDescriptors( diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts index e5b8793225..9da9e21e13 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts @@ -82,7 +82,7 @@ type DataTrackLocalManagerOptions = { * * If none, end-to-end encryption will be disabled for all published tracks. */ - decryptionProvider?: EncryptionProvider; + encryptionProvider?: EncryptionProvider; }; /** How long to wait when attempting to publish before timing out. */ @@ -98,7 +98,7 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => constructor(options?: DataTrackLocalManagerOptions) { super(); - this.encryptionProvider = options?.decryptionProvider ?? null; + this.encryptionProvider = options?.encryptionProvider ?? null; } static withDescriptors(descriptors: Map) { diff --git a/src/room/data-track/outgoing/pipeline.ts b/src/room/data-track/outgoing/pipeline.ts index 0e09bcc48e..f4f0c252af 100644 --- a/src/room/data-track/outgoing/pipeline.ts +++ b/src/room/data-track/outgoing/pipeline.ts @@ -3,10 +3,7 @@ import { type EncryptedPayload, type EncryptionProvider } from '../e2ee'; import { type DataTrackFrame } from '../frame'; import { DataTrackPacket } from '../packet'; import { DataTrackE2eeExtension } from '../packet/extensions'; -import DataTrackPacketizer, { - DataTrackPacketizerError, - DataTrackPacketizerReason, -} from '../packetizer'; +import DataTrackPacketizer, { DataTrackPacketizerError } from '../packetizer'; import type { DataTrackInfo } from '../track'; import { DataTrackOutgoingPipelineError, DataTrackOutgoingPipelineErrorReason } from './errors'; From 87ae5056e27ad028beae93137a31a340ba2d8e76 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 14:04:38 -0500 Subject: [PATCH 30/43] fix: add missing changeset --- .changeset/thin-jobs-grab.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/thin-jobs-grab.md diff --git a/.changeset/thin-jobs-grab.md b/.changeset/thin-jobs-grab.md new file mode 100644 index 0000000000..ac806dac83 --- /dev/null +++ b/.changeset/thin-jobs-grab.md @@ -0,0 +1,5 @@ +--- +'livekit-client': patch +--- + +Adds new OutgoingDataTrackManager to manage sending data track payloads From fe7e6492395b91d0d06746b68d2c2474fea71c16 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 14:05:28 -0500 Subject: [PATCH 31/43] fix: swap index import from packetizer -> OutgoingDataTrackManager --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index d8a0179e84..1c18cd5c28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ import * as attributes from './room/attribute-typings'; // FIXME: remove this import in a follow up data track pull request. import './room/data-track/depacketizer'; // FIXME: remove this import in a follow up data track pull request. -import './room/data-track/packetizer'; +import './room/data-track/outgoing/OutgoingDataTrackManager'; import LocalParticipant from './room/participant/LocalParticipant'; import Participant, { ConnectionQuality, ParticipantKind } from './room/participant/Participant'; import type { ParticipantTrackPermission } from './room/participant/ParticipantTrackPermission'; From 30872384a6d43bc80eb5b4f719def1d8aa140214 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 14:12:52 -0500 Subject: [PATCH 32/43] fix: push empty commit to try to get the coderabbit bot to review From 2991109f7596ab7629f0faad33c09ce1dbd9e95c Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 14:56:42 -0500 Subject: [PATCH 33/43] feat: add unpublishing descriptor shutdown case --- src/room/data-track/outgoing/OutgoingDataTrackManager.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts index 9da9e21e13..f4e9013893 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts @@ -293,6 +293,11 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => case 'active': await this.unpublishRequest(descriptor.info.pubHandle); break; + case 'unpublishing': + // Abandon any unpublishing descriptors that were in flight and assume they will get + // cleaned up automatically with the connection shutdown. + descriptor.completionFuture.resolve?.(); + break; } } this.descriptors.clear(); From 5d401073087dfe3d90c05c2206ee09d58f407886 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 14:59:16 -0500 Subject: [PATCH 34/43] fix: add more error cases --- src/room/data-track/outgoing/types.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/room/data-track/outgoing/types.ts b/src/room/data-track/outgoing/types.ts index 36e069f14b..95492ffbd7 100644 --- a/src/room/data-track/outgoing/types.ts +++ b/src/room/data-track/outgoing/types.ts @@ -10,7 +10,13 @@ export type DataTrackOptions = { /** Encodes whether a data track publish request to the SFU has been successful or not. */ export type SfuPublishResponseResult = | { type: 'ok'; data: DataTrackInfo } - | { type: 'error'; error: DataTrackPublishError }; + | { + type: 'error'; + error: + | DataTrackPublishError + | DataTrackPublishError + | DataTrackPublishError; + }; /** Request sent to the SFU to publish a track. */ export type OutputEventSfuPublishRequest = { From 18276351bf71e7ed7da915f5f763ebe2e4ff9303 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 15:13:39 -0500 Subject: [PATCH 35/43] docs: clarify docs comment --- throws-transformer/engine.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/throws-transformer/engine.ts b/throws-transformer/engine.ts index a083e745f3..7a95e1dc81 100644 --- a/throws-transformer/engine.ts +++ b/throws-transformer/engine.ts @@ -354,7 +354,8 @@ function checkCallExpression( : new Set(); // If the catch clause contains no throws all errors are being silenced - // TODO: maybe log a warning here, this is probably bad? + // ie, something like `try { /* code here */ } catch (err) {}` + // TODO: maybe log a warning here, this is probably bad at least in some cases? if (handledErrors === "all") { return null; } From 12aa217a927ef6d715e187ccf5f9fb158732c49c Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 10 Feb 2026 16:25:07 -0500 Subject: [PATCH 36/43] fix: add missing error cases --- src/room/data-track/outgoing/OutgoingDataTrackManager.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts index f4e9013893..71f4b13ae4 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts @@ -29,6 +29,8 @@ export type PendingDescriptor = { type: 'pending'; completionFuture: Future< LocalDataTrack, + | DataTrackPublishError + | DataTrackPublishError | DataTrackPublishError | DataTrackPublishError | DataTrackPublishError From 9f852e5d3e274ceb8769e713706ef646ba3d20c5 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 11 Feb 2026 10:22:14 -0500 Subject: [PATCH 37/43] feat: make assertions read a little better --- .../outgoing/OutgoingDataTrackManager.test.ts | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts index 0e7f9c46bf..bce104ed4d 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts @@ -95,7 +95,7 @@ describe('DataTrackOutgoingManager', () => { frameNumber: 0, marker: FrameMarker.Single, sequence: 0, - timestamp: 0, // (zeroed out in the test, since this isn't mocked) + timestamp: expect.anything(), trackHandle: 5, }, payload: new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]), @@ -116,7 +116,7 @@ describe('DataTrackOutgoingManager', () => { frameNumber: 0, marker: FrameMarker.Start, sequence: 0, - timestamp: 0, // (zeroed out in the test, since this isn't mocked) + timestamp: expect.anything(), trackHandle: 5, }, payload: new Uint8Array(15988 /* 16k mtu - 12 header bytes */).fill(0xbe), @@ -130,7 +130,7 @@ describe('DataTrackOutgoingManager', () => { frameNumber: 0, marker: FrameMarker.Final, sequence: 1, - timestamp: 0, // (zeroed out in the test, since this isn't mocked) + timestamp: expect.anything(), trackHandle: 5, }, payload: new Uint8Array(8012 /* 24k payload - (16k mtu - 12 header bytes) */).fill(0xbe), @@ -172,11 +172,7 @@ describe('DataTrackOutgoingManager', () => { const packetBytes = await managerEvents.waitFor('packetsAvailable'); const [packet] = DataTrackPacket.fromBinary(packetBytes.bytes); - const packetJson = packet.toJSON(); - // (note: zero out the header timestamp because the date "now" isn't being mocked) - packetJson.header.timestamp = 0; - - expect(packetJson).toStrictEqual(outputPacketJson); + expect(packet.toJSON()).toStrictEqual(outputPacketJson); } }, ); @@ -221,11 +217,7 @@ describe('DataTrackOutgoingManager', () => { const packetBytes = await managerEvents.waitFor('packetsAvailable'); const [packet] = DataTrackPacket.fromBinary(packetBytes.bytes); - const packetJson = packet.toJSON(); - // (note: zero out the header timestamp because the date "now" isn't being mocked) - packetJson.header.timestamp = 0; - - expect(packetJson).toStrictEqual({ + expect(packet.toJSON()).toStrictEqual({ header: { extensions: { e2ee: { @@ -239,7 +231,7 @@ describe('DataTrackOutgoingManager', () => { frameNumber: 0, marker: 3, sequence: 0, - timestamp: 0, // (zeroed out in the test, since this isn't mocked) + timestamp: expect.anything(), trackHandle: 1, }, payload: new Uint8Array([ From a90dfb929dfaaa845d8e3b62fa06d41a9df57371 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 11 Feb 2026 10:22:30 -0500 Subject: [PATCH 38/43] feat: send unpublish event explicitly on cancellation --- .../outgoing/OutgoingDataTrackManager.test.ts | 29 +++++++++++++++++++ .../outgoing/OutgoingDataTrackManager.ts | 3 ++ 2 files changed, 32 insertions(+) diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts index bce104ed4d..4b430bc739 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts @@ -81,6 +81,35 @@ describe('DataTrackOutgoingManager', () => { expect(publishRequestPromise).rejects.toThrowError('Data track publication limit reached'); }); + it('should test track publishing (cancellation half way through)', async () => { + const manager = new OutgoingDataTrackManager(); + const managerEvents = subscribeToEvents(manager, [ + 'sfuPublishRequest', + 'sfuUnpublishRequest', + ]); + + // 1. Publish a data track + const controller = new AbortController(); + const publishRequestPromise = manager.publishRequest({ name: 'test' }, controller.signal); + + // 2. This publish request should be sent along to the SFU + const sfuPublishEvent = await managerEvents.waitFor('sfuPublishRequest'); + expect(sfuPublishEvent.name).toStrictEqual('test'); + expect(sfuPublishEvent.usesE2ee).toStrictEqual(false); + const handle = sfuPublishEvent.handle; + + // 3. Explictly cancel the publish + controller.abort(); + + // 4. Make sure an unpublish event is sent so that the SFU cleans up things properly + // on its end as well + const sfuUnpublishEvent = await managerEvents.waitFor('sfuUnpublishRequest'); + expect(sfuUnpublishEvent.handle).toStrictEqual(handle); + + // 5. Make sure cancellation is bubbled up as an error to stop further execution + expect(publishRequestPromise).rejects.toStrictEqual(DataTrackPublishError.cancelled()); + }); + it.each([ // Single packet payload case [ diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts index 71f4b13ae4..a0b728e9ff 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts @@ -191,6 +191,9 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => } this.descriptors.delete(handle); + // Let the SFU know that the publish has been cancelled + this.emit('sfuUnpublishRequest', { handle }); + if (existingDescriptor.type === 'pending') { existingDescriptor.completionFuture.reject?.( timeoutSignal.aborted From 060155dbf3059abbaa47f43d8e0a8673b439237e Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 13 Feb 2026 10:22:12 -0500 Subject: [PATCH 39/43] feat: remove publishing descriptor type, instead add unpublishingFuture to active descriptor type Per comment here: https://github.com/livekit/client-sdk-js/pull/1810#discussion_r2801326844 --- .../outgoing/OutgoingDataTrackManager.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts index a0b728e9ff..9cbeffad88 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts @@ -42,13 +42,11 @@ export type ActiveDescriptor = { info: DataTrackInfo; pipeline: DataTrackOutgoingPipeline; + + /** Resolves when the descriptor is unpublished. */ + unpublishingFuture: Future; }; -// FIXME: rust doesn't have this unpublishing descriptor, is it a good idea? -export type UnpublishingDescriptor = { - type: 'unpublishing'; - completionFuture: Future; -}; -export type Descriptor = PendingDescriptor | ActiveDescriptor | UnpublishingDescriptor; +export type Descriptor = PendingDescriptor | ActiveDescriptor; export const Descriptor = { pending(): PendingDescriptor { @@ -62,11 +60,9 @@ export const Descriptor = { type: 'active', info, pipeline: new DataTrackOutgoingPipeline({ info, encryptionProvider }), + unpublishingFuture: new Future(), }; }, - unpublishing(): UnpublishingDescriptor { - return { type: 'unpublishing', completionFuture: new Future() }; - }, }; export type DataTrackOutgoingManagerCallbacks = { @@ -227,12 +223,20 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => /** Client request to unpublish a track. */ async unpublishRequest(handle: DataTrackHandle) { - const descriptor = Descriptor.unpublishing(); - this.descriptors.set(handle, descriptor); + const descriptor = this.descriptors.get(handle); + if (!descriptor) { + // FIXME: should this be an internal error? + log.warn(`No descriptor for ${handle}`); + return; + } + if (descriptor.type !== 'active') { + log.warn(`Track ${handle} not active`); + return; + } this.emit('sfuUnpublishRequest', { handle }); - await descriptor.completionFuture.promise; + await descriptor.unpublishingFuture.promise; } /** SFU responded to a request to publish a data track. */ @@ -280,12 +284,12 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => } this.descriptors.delete(handle); - if (descriptor.type !== 'unpublishing') { - log.warn(`Track ${handle} hasn't been put into unpublishing status`); + if (descriptor.type !== 'active') { + log.warn(`Track ${handle} not active`); return; } - descriptor.completionFuture.resolve?.(); + descriptor.unpublishingFuture.resolve?.(); } /** Shuts down the manager and all associated tracks. */ From 0b97e1407d35a01b399dc99d6dfa22687b30d979 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 13 Feb 2026 10:25:31 -0500 Subject: [PATCH 40/43] feat: get rid of lagging missing unpublishing case --- src/room/data-track/outgoing/OutgoingDataTrackManager.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts index 9cbeffad88..d1cf42cb8b 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts @@ -300,12 +300,11 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => descriptor.completionFuture.reject?.(DataTrackPublishError.disconnected()); break; case 'active': - await this.unpublishRequest(descriptor.info.pubHandle); - break; - case 'unpublishing': // Abandon any unpublishing descriptors that were in flight and assume they will get // cleaned up automatically with the connection shutdown. - descriptor.completionFuture.resolve?.(); + descriptor.unpublishingFuture.resolve?.(); + + await this.unpublishRequest(descriptor.info.pubHandle); break; } } From 878d5c6d7477d4178c6df2028889c8d3a8f16fa9 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 20 Feb 2026 10:00:03 -0500 Subject: [PATCH 41/43] feat: remove fixmes that have been addressed --- .../outgoing/OutgoingDataTrackManager.test.ts | 2 -- .../outgoing/OutgoingDataTrackManager.ts | 14 ++------------ src/room/data-track/outgoing/errors.ts | 12 +++++------- 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts index 4b430bc739..cea8f6f8c7 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts @@ -389,6 +389,4 @@ describe('DataTrackOutgoingManager', () => { await shutdownPromise; }); - - // FIXME: add e2ee tests }); diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts index d1cf42cb8b..5040852a48 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts @@ -91,7 +91,6 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => private handleAllocator = new DataTrackHandleAllocator(); - // FIXME: key of this map is the same as the value Descriptor["info"]["pubHandle"] private descriptors = new Map(); constructor(options?: DataTrackLocalManagerOptions) { @@ -149,12 +148,7 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => this.emit('packetsAvailable', { bytes: packet.toBinary(), signal: options?.signal }); } } catch (err) { - // FIXME: catch and log errors instead of rethrowing? That is what the rust implementation - // is doing instead. - // process_frame(...).inspect_err(|err| log::debug!("Process failed: {}", err)) - // event_out_tx.try_send(...).inspect_err(|err| log::debug!("Cannot send packet to transport: {}", err)); - // - // In the rust implementation this "dropped" error means something different (not enough room + // NOTE: In the rust implementation this "dropped" error means something different (not enough room // in the track mpsc channel) throw DataTrackPushFrameError.dropped(err); } @@ -181,7 +175,6 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => const onAbort = () => { const existingDescriptor = this.descriptors.get(handle); if (!existingDescriptor) { - // FIXME: should this be an internal error? log.warn(`No descriptor for ${handle}`); return; } @@ -194,7 +187,7 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => existingDescriptor.completionFuture.reject?.( timeoutSignal.aborted ? DataTrackPublishError.timeout() - : // FIXME: the below cancelled case was introduced by web / there isn't a corresponding case in the rust version. + : // NOTE: the below cancelled case was introduced by web / there isn't a corresponding case in the rust version. DataTrackPublishError.cancelled(), ); } @@ -225,7 +218,6 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => async unpublishRequest(handle: DataTrackHandle) { const descriptor = this.descriptors.get(handle); if (!descriptor) { - // FIXME: should this be an internal error? log.warn(`No descriptor for ${handle}`); return; } @@ -243,7 +235,6 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => receivedSfuPublishResponse(handle: DataTrackHandle, result: SfuPublishResponseResult) { const descriptor = this.descriptors.get(handle); if (!descriptor) { - // FIXME: should this be an internal error? log.warn(`No descriptor for ${handle}`); return; } @@ -278,7 +269,6 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => receivedSfuUnpublishResponse(handle: DataTrackHandle) { const descriptor = this.descriptors.get(handle); if (!descriptor) { - // FIXME: should this be an internal error? log.warn(`No descriptor for ${handle}`); return; } diff --git a/src/room/data-track/outgoing/errors.ts b/src/room/data-track/outgoing/errors.ts index 7559bb0430..2340c23593 100644 --- a/src/room/data-track/outgoing/errors.ts +++ b/src/room/data-track/outgoing/errors.ts @@ -21,12 +21,8 @@ export enum DataTrackPublishErrorReason { /** Cannot publish data track when the room is disconnected. */ Disconnected = 4, - // FIXME: get rid of internal error concept, this is just represented as bare throws in js - // Internal = 5, - - // FIXME: this was introduced by web / there isn't a corresponding case in the rust version. - // Upon further reflection though I think this should exist in rust. - Cancelled = 6, + // NOTE: this was introduced by web / there isn't a corresponding case in the rust version. + Cancelled = 5, } export class DataTrackPublishError< @@ -76,7 +72,7 @@ export class DataTrackPublishError< return new DataTrackPublishError('Room disconnected', DataTrackPublishErrorReason.Disconnected); } - // FIXME: this was introduced by web / there isn't a corresponding case in the rust version. + // NOTE: this was introduced by web / there isn't a corresponding case in the rust version. static cancelled() { return new DataTrackPublishError( 'Publish data track cancelled by caller', @@ -89,6 +85,8 @@ export enum DataTrackPushFrameErrorReason { /** Track is no longer published. */ TrackUnpublished = 0, /** Frame was dropped. */ + // NOTE: this should become a web specific error, the rust version of this "dropped" error means + // something different and will be renamed to "QueueFull". Dropped = 1, } From aea83e582e10fa978c39b6b3f650713f0aaea1e4 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 20 Feb 2026 10:00:22 -0500 Subject: [PATCH 42/43] fix: remove options from tryProcessAndSend --- src/room/data-track/outgoing/OutgoingDataTrackManager.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts index 5040852a48..244616bbb5 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts @@ -127,7 +127,6 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => tryProcessAndSend( handle: DataTrackHandle, payload: Uint8Array, - options?: { signal?: AbortSignal }, ): Throws< void, | DataTrackPushFrameError @@ -145,7 +144,7 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => try { for (const packet of descriptor.pipeline.processFrame(frame)) { - this.emit('packetsAvailable', { bytes: packet.toBinary(), signal: options?.signal }); + this.emit('packetsAvailable', { bytes: packet.toBinary() }); } } catch (err) { // NOTE: In the rust implementation this "dropped" error means something different (not enough room From 2b70c4cca9020ba3e25714366dfcb71d3342f31f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 20 Feb 2026 10:00:33 -0500 Subject: [PATCH 43/43] fix: remove options from tryPush and remove Throws type from public interface --- src/room/data-track/track.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/room/data-track/track.ts b/src/room/data-track/track.ts index 398e2ec939..5a2888ab5e 100644 --- a/src/room/data-track/track.ts +++ b/src/room/data-track/track.ts @@ -1,8 +1,6 @@ -import type { Throws } from '../../utils/throws'; import type { DataTrackFrame } from './frame'; import { type DataTrackHandle } from './handle'; import type OutgoingDataTrackManager from './outgoing/OutgoingDataTrackManager'; -import type { DataTrackPushFrameError, DataTrackPushFrameErrorReason } from './outgoing/errors'; export type DataTrackSid = string; @@ -39,18 +37,14 @@ export class LocalDataTrack { * * - The track has been unpublished by the local participant or SFU * - The room is no longer connected - * - Frames are being pushed too fast (FIXME: this isn't the case in the js implementation?) */ - tryPush( - payload: DataTrackFrame['payload'], - options?: { signal?: AbortSignal }, - ): Throws< - void, - | DataTrackPushFrameError - | DataTrackPushFrameError - > { - // FIXME: rust implementation maps errors to dropped here? - // .map_err(|err| PushFrameError::new(err.into_inner(), PushFrameErrorReason::Dropped)) - return this.manager.tryProcessAndSend(this.info.pubHandle, payload, options); + tryPush(payload: DataTrackFrame['payload']) { + try { + return this.manager.tryProcessAndSend(this.info.pubHandle, payload); + } catch (err) { + // NOTE: wrapping in the bare try/catch like this means that the Throws<...> type doesn't + // propegate upwards into the public interface. + throw err; + } } }