UNPKG

livekit-client

Version:

JavaScript/TypeScript client SDK for LiveKit

606 lines (520 loc) 20.6 kB
/* eslint-disable @typescript-eslint/no-unused-vars */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type DecryptDataResponseMessage, type EncryptDataResponseMessage, LocalDataTrack, } from '../../..'; import { type BaseE2EEManager } from '../../../e2ee/E2eeManager'; import { subscribeToEvents } from '../../../utils/subscribeToEvents'; import RTCEngine from '../../RTCEngine'; import Room from '../../Room'; import { DataTrackHandle } from '../handle'; import { DataTrackPacket, FrameMarker } from '../packet'; import OutgoingDataTrackManager, { type DataTrackOutgoingManagerCallbacks, Descriptor, } from './OutgoingDataTrackManager'; import { DataTrackPublishError } from './errors'; /** Fake encryption provider for testing e2ee data track features. */ export class PrefixingEncryptionProvider implements BaseE2EEManager { isEnabled = true; isDataChannelEncryptionEnabled = true; setup(_room: Room) {} setupEngine(_engine: RTCEngine) {} setParticipantCryptorEnabled(_enabled: boolean, _participantIdentity: string) {} setSifTrailer(_trailer: Uint8Array) {} on(_event: any, _listener: any): this { return this; } /** A fake "encryption" provider used for test purposes. Adds a prefix to the payload. */ async encryptData(data: Uint8Array): Promise<EncryptDataResponseMessage['data']> { const prefix = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); const output = new Uint8Array(prefix.length + data.length); output.set(prefix, 0); output.set(data, prefix.length); return { uuid: crypto.randomUUID(), payload: output, iv: new Uint8Array(12), // Just leaving this empty, is this a bad idea? keyIndex: 0, }; } /** A fake "decryption" provider used for test purposes. Assumes the payload is prefixed with * 0xdeafbeef, which is stripped off. */ async handleEncryptedData( payload: Uint8Array, _iv: Uint8Array, _participantIdentity: string, _keyIndex: number, ): Promise<DecryptDataResponseMessage['data']> { if (payload[0] !== 0xde || payload[1] !== 0xad || payload[2] !== 0xbe || payload[3] !== 0xef) { throw new Error( `PrefixingEncryptionProvider: first four bytes of payload were not 0xdeadbeef, found ${payload.slice(0, 4)}`, ); } return { uuid: crypto.randomUUID(), payload: payload.slice(4), }; } } describe('DataTrackOutgoingManager', () => { it('should test track publishing (ok case)', async () => { const manager = new OutgoingDataTrackManager(); const managerEvents = subscribeToEvents<DataTrackOutgoingManagerCallbacks>(manager, [ 'sfuPublishRequest', ]); const localDataTrack = new LocalDataTrack({ name: 'test' }, manager); expect(localDataTrack.isPublished()).toStrictEqual(false); // 1. Publish a data track const publishRequestPromise = localDataTrack.publish(); // 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 an OK response manager.receivedSfuPublishResponse(handle, { type: 'ok', data: { sid: 'bogus-sid', pubHandle: sfuPublishEvent.handle, name: 'test', usesE2ee: false, }, }); // Make sure that the original input event resolves. await publishRequestPromise; expect(localDataTrack.isPublished()).toStrictEqual(true); }); it('should test track publishing (error case)', async () => { const manager = new OutgoingDataTrackManager(); const managerEvents = subscribeToEvents<DataTrackOutgoingManagerCallbacks>(manager, [ 'sfuPublishRequest', ]); // 1. Publish a data track const localDataTrack = new LocalDataTrack({ name: 'test' }, manager); const publishRequestPromise = localDataTrack.publish(); // 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.receivedSfuPublishResponse(sfuPublishEvent.handle, { type: 'error', error: DataTrackPublishError.limitReached(), }); // Make sure that the rejection bubbles back to the caller await 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<DataTrackOutgoingManagerCallbacks>(manager, [ 'sfuPublishRequest', 'sfuUnpublishRequest', ]); // 1. Publish a data track const controller = new AbortController(); const localDataTrack = new LocalDataTrack({ name: 'test' }, manager); const publishRequestPromise = localDataTrack.publish(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 await expect(publishRequestPromise).rejects.toStrictEqual(DataTrackPublishError.cancelled()); }); it('should test track publishing (cancellation before it starts)', async () => { const manager = new OutgoingDataTrackManager(); const managerEvents = subscribeToEvents<DataTrackOutgoingManagerCallbacks>(manager, [ 'sfuPublishRequest', 'sfuUnpublishRequest', ]); // Publish a data track const localDataTrack = new LocalDataTrack({ name: 'test' }, manager); const publishRequestPromise = localDataTrack.publish(AbortSignal.abort(/* already aborted */)); // Make sure cancellation is immediately bubbled up await expect(publishRequestPromise).rejects.toStrictEqual(DataTrackPublishError.cancelled()); // And there were no pending sfu publish requests sent expect(managerEvents.areThereBufferedEvents('sfuPublishRequest')).toBe(false); }); it('should test track publishing, unpublishing, and republishing again', async () => { const manager = new OutgoingDataTrackManager(); const managerEvents = subscribeToEvents<DataTrackOutgoingManagerCallbacks>(manager, [ 'sfuPublishRequest', 'sfuUnpublishRequest', ]); // 1. Create a local data track const localDataTrack = new LocalDataTrack({ name: 'test' }, manager); expect(localDataTrack.isPublished()).toStrictEqual(false); // 2. Publish it const publishRequestPromise = localDataTrack.publish(); // 3. 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; // 4. 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: false, }, }); // Make sure that the original input event resolves. await publishRequestPromise; // 5. Now the data track should be published expect(localDataTrack.isPublished()).toStrictEqual(true); // 6. Unpublish the data track const unpublishRequestPromise = localDataTrack.unpublish(); const sfuUnpublishEvent = await managerEvents.waitFor('sfuUnpublishRequest'); manager.receivedSfuUnpublishResponse(sfuUnpublishEvent.handle); await unpublishRequestPromise; // 7. Now the data track should be unpublished expect(localDataTrack.isPublished()).toStrictEqual(false); // 8. Now, republish the track and make sure that be done a second time const publishRequestPromise2 = localDataTrack.publish(); const sfuPublishEvent2 = await managerEvents.waitFor('sfuPublishRequest'); expect(sfuPublishEvent2.name).toStrictEqual('test'); expect(sfuPublishEvent2.usesE2ee).toStrictEqual(false); const handle2 = sfuPublishEvent2.handle; manager.receivedSfuPublishResponse(handle2, { type: 'ok', data: { sid: 'bogus-sid', pubHandle: sfuPublishEvent2.handle, name: 'test', usesE2ee: false, }, }); await publishRequestPromise2; // 9. Ensure that the track is published again expect(localDataTrack.isPublished()).toStrictEqual(true); // 10. Also ensure that the handle used on the second publish attempt differs from the first // publish attempt. expect(handle).not.toStrictEqual(handle2); }); 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: expect.anything(), 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: expect.anything(), 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: expect.anything(), 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<unknown>) => { // Create a manager prefilled with a descriptor const manager = OutgoingDataTrackManager.withDescriptors( new Map([ [ DataTrackHandle.fromNumber(5), Descriptor.active( { sid: 'bogus-sid', pubHandle: 5, name: 'test', usesE2ee: false, }, null, ), ], ]), ); const managerEvents = subscribeToEvents<DataTrackOutgoingManagerCallbacks>(manager, [ 'packetAvailable', ]); const localDataTrack = LocalDataTrack.withExplicitHandle({ name: 'track name' }, manager, 5); // Kick off sending the bytes... localDataTrack.tryPush({ payload: 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('packetAvailable'); const [packet] = DataTrackPacket.fromBinary(packetBytes.bytes); expect(packet.toJSON()).toStrictEqual(outputPacketJson); } }, ); it('should send e2ee encrypted datatrack payload', async () => { const manager = new OutgoingDataTrackManager({ e2eeManager: new PrefixingEncryptionProvider(), }); const managerEvents = subscribeToEvents<DataTrackOutgoingManagerCallbacks>(manager, [ 'sfuPublishRequest', 'packetAvailable', ]); // 1. Publish a data track const localDataTrack = new LocalDataTrack({ name: 'test' }, manager); const publishRequestPromise = localDataTrack.publish(); // 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 await publishRequestPromise; expect(localDataTrack.isPublished()).toStrictEqual(true); // Kick off sending the payload bytes localDataTrack.tryPush({ payload: new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]) }); // Make sure the packet that was sent was encrypted with the PrefixingEncryptionProvider const packetBytes = await managerEvents.waitFor('packetAvailable'); const [packet] = DataTrackPacket.fromBinary(packetBytes.bytes); expect(packet.toJSON()).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: expect.anything(), 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( new Map([ [ DataTrackHandle.fromNumber(5), Descriptor.active( { sid: 'bogus-sid', pubHandle: 5, name: 'test', usesE2ee: false, }, null, ), ], ]), ); const managerEvents = subscribeToEvents<DataTrackOutgoingManagerCallbacks>(manager, [ 'sfuUnpublishRequest', ]); // Make sure the descriptor is in there expect(manager.getDescriptor(5)?.type).toStrictEqual('active'); // Unpublish data track const unpublishRequestPromise = manager.unpublishRequest(DataTrackHandle.fromNumber(5)); const sfuUnpublishEvent = await managerEvents.waitFor('sfuUnpublishRequest'); expect(sfuUnpublishEvent.handle).toStrictEqual(5); manager.receivedSfuUnpublishResponse(DataTrackHandle.fromNumber(5)); await unpublishRequestPromise; // Make sure data track is no longer expect(manager.getDescriptor(5)).toStrictEqual(null); }); it('should test a full reconnect', async () => { const pubHandle = 5; // Create a manager prefilled with a descriptor const manager = OutgoingDataTrackManager.withDescriptors( new Map([ [ DataTrackHandle.fromNumber(5), Descriptor.active( { sid: 'bogus-sid', pubHandle, name: 'test', usesE2ee: false, }, null, ), ], ]), ); const managerEvents = subscribeToEvents<DataTrackOutgoingManagerCallbacks>(manager, [ 'sfuPublishRequest', 'packetAvailable', 'sfuUnpublishRequest', ]); const localDataTrack = LocalDataTrack.withExplicitHandle({ name: 'track name' }, manager, 5); // Make sure the descriptor is in there expect(manager.getDescriptor(5)?.type).toStrictEqual('active'); // Simulate a full reconnect, which means that any published tracks will need to be republished. manager.sfuWillRepublishTracks(); // Even though behind the scenes the SFU publications are not active, the user should still see // it as "published", sfu reconnects are an implementation detail expect(localDataTrack.isPublished()).toStrictEqual(true); // But, even though `isPublished` is true, pushing data should drop (no sfu to send them to!) await expect(() => localDataTrack.tryPush({ payload: new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]) }), ).rejects.toThrowError('Frame was dropped'); // 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; expect(handle).toStrictEqual(pubHandle); // 3. Respond to the SFU publish request with an OK response manager.receivedSfuPublishResponse(handle, { type: 'ok', data: { sid: 'bogus-sid-REPUBLISHED', pubHandle: sfuPublishEvent.handle, name: 'test', usesE2ee: false, }, }); // After all this, the local data track should still be published expect(localDataTrack.isPublished()).toStrictEqual(true); // And the sid should be the new value expect(localDataTrack.info!.sid).toStrictEqual('bogus-sid-REPUBLISHED'); // And now that the tracks are backed by the SFU again, pushes should function! await localDataTrack.tryPush({ payload: new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]) }); await managerEvents.waitFor('packetAvailable'); }); it('should query currently active descriptors', async () => { // Create a manager prefilled with a descriptor const manager = OutgoingDataTrackManager.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 result = await manager.queryPublished(); expect(result).toStrictEqual([ { sid: 'bogus-sid-2', pubHandle: 2, name: 'twotwotwo', usesE2ee: false }, { 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 = OutgoingDataTrackManager.withDescriptors( new Map<DataTrackHandle, Descriptor>([ [DataTrackHandle.fromNumber(2), pendingDescriptor], [ DataTrackHandle.fromNumber(6), Descriptor.active( { sid: 'bogus-sid-6', pubHandle: 6, name: 'sixsixsix', usesE2ee: false, }, null, ), ], ]), ); const managerEvents = subscribeToEvents<DataTrackOutgoingManagerCallbacks>(manager, [ 'sfuUnpublishRequest', ]); // Shut down the manager const shutdownPromise = manager.shutdown(); // The pending data track should be cancelled await 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.receivedSfuUnpublishResponse(DataTrackHandle.fromNumber(6)); await shutdownPromise; }); });