livekit-client
Version:
JavaScript/TypeScript client SDK for LiveKit
606 lines (520 loc) • 20.6 kB
text/typescript
/* 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;
});
});