Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/client/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ const external = [
const browserConfig = {
input: 'index.ts',
output: {
file: 'dist/index.browser.es.js',
dir: 'dist',
format: 'esm',
sourcemap: true,
entryFileNames: 'index.browser.es.js',
chunkFileNames: '[name].browser.es.js',
},
external: external.filter((dep) => !browserIgnoredModules.includes(dep)),
plugins: [
Expand All @@ -55,6 +57,7 @@ const createNodeConfig = (outputFile, format) => ({
file: outputFile,
format: format,
sourcemap: true,
inlineDynamicImports: true,
},
external,
plugins: [
Expand Down
9 changes: 8 additions & 1 deletion packages/client/src/rtc/BasePeerConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { NegotiationError } from './NegotiationError';
import { StreamSfuClient } from '../StreamSfuClient';
import { AllSfuEvents, Dispatcher } from './Dispatcher';
import { isChrome } from '../helpers/browsers';
import { withoutConcurrency } from '../helpers/concurrency';
import { StatsTracer, Tracer, traceRTCPeerConnection } from '../stats';
import type { BasePeerConnectionOpts, OnReconnectionNeeded } from './types';
Expand Down Expand Up @@ -89,7 +90,13 @@ export abstract class BasePeerConnection {
}

private createPeerConnection = (connectionConfig?: RTCConfiguration) => {
const pc = new RTCPeerConnection(connectionConfig);
const config: RTCConfiguration = { ...connectionConfig };
// Chrome needs encodedInsertableStreams for the Insertable Streams path.
if (this.clientPublishOptions?.encryptionKey && isChrome()) {
// @ts-expect-error not part of the standard lib yet
config.encodedInsertableStreams = true;
}
const pc = new RTCPeerConnection(config);
pc.addEventListener('icecandidate', this.onIceCandidate);
pc.addEventListener('icecandidateerror', this.onIceCandidateError);
pc.addEventListener(
Expand Down
15 changes: 15 additions & 0 deletions packages/client/src/rtc/Publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
toVideoLayers,
} from './layers';
import { isSvcCodec } from './codecs';
import { supportsE2EE } from './e2ee/compatibility';
import { isAudioTrackType } from './helpers/tracks';
import { extractMid, removeCodecsExcept, setStartBitrate } from './helpers/sdp';
import { withoutConcurrency } from '../helpers/concurrency';
Expand Down Expand Up @@ -137,6 +138,20 @@ export class Publisher extends BasePeerConnection {
const params = transceiver.sender.getParameters();
params.degradationPreference = 'maintain-framerate';
await transceiver.sender.setParameters(params);
const { encryptionKey } = this.clientPublishOptions || {};
if (encryptionKey) {
if (supportsE2EE()) {
const { createEncryptor } = await import('./e2ee/e2ee');
createEncryptor(
transceiver.sender,
encryptionKey,
publishOption.codec?.name.toLowerCase(),
);
this.logger.debug('E2EE encryptor attached to sender');
} else {
this.logger.warn(`E2EE requested but not supported`);
}
}

const trackType = publishOption.trackType;
this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
Expand Down
13 changes: 13 additions & 0 deletions packages/client/src/rtc/Subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { BasePeerConnectionOpts } from './types';
import { NegotiationError } from './NegotiationError';
import { PeerType } from '../gen/video/sfu/models/models';
import { SubscriberOffer } from '../gen/video/sfu/event/events';
import { supportsE2EE } from './e2ee/compatibility';
import { toTrackType, trackTypeToParticipantStreamKey } from './helpers/tracks';
import { enableStereo, removeCodecsExcept } from './helpers/sdp';

Expand Down Expand Up @@ -93,6 +94,18 @@ export class Subscriber extends BasePeerConnection {
this.state.removeOrphanedTrack(primaryStream.id);
});

const { encryptionKey } = this.clientPublishOptions || {};
if (encryptionKey) {
if (supportsE2EE()) {
import('./e2ee/e2ee').then(({ createDecryptor }) => {
createDecryptor(e.receiver, encryptionKey);
this.logger.debug('E2EE decryptor attached to receiver');
});
} else {
this.logger.warn(`E2EE requested but not supported`);
}
}
Comment on lines +97 to +107
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add error handling for the dynamic import.

The dynamic import lacks a .catch() handler. If the import fails (e.g., network error loading the chunk, or an exception in module initialization), it will result in an unhandled promise rejection with no indication to the user that E2EE failed to initialize.

🛡️ Proposed fix
     const { encryptionKey } = this.clientPublishOptions || {};
     if (encryptionKey) {
       if (supportsE2EE()) {
-        import('./e2ee/e2ee').then(({ createDecryptor }) => {
-          createDecryptor(e.receiver, encryptionKey);
-          this.logger.debug('E2EE decryptor attached to receiver');
-        });
+        import('./e2ee/e2ee')
+          .then(({ createDecryptor }) => {
+            createDecryptor(e.receiver, encryptionKey);
+            this.logger.debug('E2EE decryptor attached to receiver');
+          })
+          .catch((err) => {
+            this.logger.error('Failed to attach E2EE decryptor', err);
+          });
       } else {
         this.logger.warn(`E2EE requested but not supported`);
       }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { encryptionKey } = this.clientPublishOptions || {};
if (encryptionKey) {
if (supportsE2EE()) {
import('./e2ee/e2ee').then(({ createDecryptor }) => {
createDecryptor(e.receiver, encryptionKey);
this.logger.debug('E2EE decryptor attached to receiver');
});
} else {
this.logger.warn(`E2EE requested but not supported`);
}
}
const { encryptionKey } = this.clientPublishOptions || {};
if (encryptionKey) {
if (supportsE2EE()) {
import('./e2ee/e2ee')
.then(({ createDecryptor }) => {
createDecryptor(e.receiver, encryptionKey);
this.logger.debug('E2EE decryptor attached to receiver');
})
.catch((err) => {
this.logger.error('Failed to attach E2EE decryptor', err);
});
} else {
this.logger.warn(`E2EE requested but not supported`);
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client/src/rtc/Subscriber.ts` around lines 97 - 107, The dynamic
import in Subscriber (where supportsE2EE() leads to
import('./e2ee/e2ee').then(({ createDecryptor }) => {
createDecryptor(e.receiver, encryptionKey); this.logger.debug('E2EE decryptor
attached to receiver'); })) lacks error handling; add a .catch handler on the
import promise to log the failure via this.logger.error (include the error and
context like "failed to load E2EE module" or "failed to initialize decryptor")
and, if appropriate, call any fallback or cleanup (e.g., notify caller or avoid
attaching decryptor) so unhandled promise rejections are prevented and failures
are visible.


const trackType = toTrackType(rawTrackType);
if (!trackType) {
return this.logger.error(`Unknown track type: ${rawTrackType}`);
Expand Down
45 changes: 45 additions & 0 deletions packages/client/src/rtc/__tests__/Publisher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,51 @@ describe('Publisher', () => {
expect(negotiateSpy).toHaveBeenCalled();
});

it('should attach an encryptor when an encryption key is provided', async () => {
publisher.dispose();
publisher = new Publisher(
{
sfuClient,
dispatcher,
state,
tag: 'test',
enableTracing: false,
clientPublishOptions: {
encryptionKey: 'shared-secret',
},
},
[
{
id: 1,
trackType: TrackType.VIDEO,
bitrate: 1000,
// @ts-expect-error - incomplete data
codec: { name: 'vp9' },
fps: 30,
maxTemporalLayers: 3,
maxSpatialLayers: 3,
},
],
);

const track = new MediaStreamTrack();
const clone = new MediaStreamTrack();
vi.spyOn(track, 'clone').mockReturnValue(clone);
// @ts-expect-error - private method
vi.spyOn(publisher, 'negotiate').mockResolvedValue();

await publisher.publish(track, TrackType.VIDEO);

const transceiver = vi.mocked(publisher['pc'].addTransceiver).mock
.results[0]?.value;
expect(transceiver.sender.transform).toMatchObject({
options: expect.objectContaining({
operation: 'encode',
key: 'shared-secret',
}),
});
});

it('should update an existing transceiver for a new track', async () => {
const track = new MediaStreamTrack();
const clone = new MediaStreamTrack();
Expand Down
35 changes: 35 additions & 0 deletions packages/client/src/rtc/__tests__/Subscriber.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,41 @@ describe('Subscriber', () => {
expect(baseTrack.stop).toHaveBeenCalled();
expect(baseStream.removeTrack).toHaveBeenCalledWith(baseTrack);
});

it('should attach a decryptor when an encryption key is provided', async () => {
subscriber.dispose();
subscriber = new Subscriber({
sfuClient,
dispatcher,
state,
connectionConfig: { iceServers: [] },
tag: 'test',
enableTracing: true,
clientPublishOptions: {
encryptionKey: 'shared-secret',
},
});

const mediaStream = new MediaStream();
const mediaStreamTrack = new MediaStreamTrack();
const receiver = { transform: null };
// @ts-expect-error - mock
mediaStream.id = '123:TRACK_TYPE_VIDEO';

const onTrack = subscriber['handleOnTrack'];
// @ts-expect-error - incomplete mock
onTrack({ streams: [mediaStream], track: mediaStreamTrack, receiver });

// decryptor is attached via dynamic import, flush the microtask queue
await new Promise((resolve) => setTimeout(resolve, 0));

expect(receiver.transform).toMatchObject({
options: expect.objectContaining({
operation: 'decode',
key: 'shared-secret',
}),
});
});
});

describe('Negotiation', () => {
Expand Down
30 changes: 30 additions & 0 deletions packages/client/src/rtc/__tests__/mocks/webrtc.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const RTCRtpTransceiverMock = vi.fn((): Partial<RTCRtpTransceiver> => {
replaceTrack: vi.fn(),
getParameters: vi.fn().mockReturnValue({}),
setParameters: vi.fn(),
transform: null,
},
setCodecPreferences: vi.fn(),
mid: '',
Expand All @@ -79,6 +80,7 @@ const RTCRtpReceiverMock = vi.fn((): Partial<typeof RTCRtpReceiver> => {
getCapabilities: vi.fn(),
};
});
RTCRtpReceiverMock.prototype.transform = null;
vi.stubGlobal('RTCRtpReceiver', RTCRtpReceiverMock);

const RTCRtpSenderMock = vi.fn((): Partial<typeof RTCRtpSender> => {
Expand All @@ -88,8 +90,36 @@ const RTCRtpSenderMock = vi.fn((): Partial<typeof RTCRtpSender> => {
track: vi.fn(),
};
});
RTCRtpSenderMock.prototype.transform = null;
vi.stubGlobal('RTCRtpSender', RTCRtpSenderMock);

const WorkerMock = vi.fn((): Partial<Worker> => {
return {
postMessage: vi.fn(),
terminate: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
});
vi.stubGlobal('Worker', WorkerMock);

const RTCRtpScriptTransformMock = vi.fn(
(worker: Worker, options?: unknown): Partial<RTCRtpScriptTransform> => {
return {
worker,
options,
};
},
);
vi.stubGlobal('RTCRtpScriptTransform', RTCRtpScriptTransformMock);

if (typeof URL !== 'undefined' && typeof URL.createObjectURL !== 'function') {
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:stream-video-e2ee'),
});
}

const AudioContextMock = vi.fn((): Partial<AudioContext> => {
return {
state: 'suspended',
Expand Down
7 changes: 7 additions & 0 deletions packages/client/src/rtc/e2ee/compatibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Checks whether the browser supports Encoded Transforms for E2EE.
*/
export const supportsE2EE = (): boolean =>
typeof RTCRtpScriptTransform !== 'undefined' ||
(typeof RTCRtpSender !== 'undefined' &&
'createEncodedStreams' in RTCRtpSender.prototype);
49 changes: 49 additions & 0 deletions packages/client/src/rtc/e2ee/e2ee.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { getWorker } from './worker';

/**
* Chrome exposes RTCRtpScriptTransform, but it doesn't seem to work reliably.
* Use Insertable Streams (createEncodedStreams) there instead.
*/
const shouldUseInsertableStreams = (): boolean =>
typeof navigator !== 'undefined' &&
navigator.userAgent?.includes('Chrome') &&
typeof RTCRtpSender !== 'undefined' &&
'createEncodedStreams' in RTCRtpSender.prototype;

/** Tracks senders/receivers that already have encoded streams piped. */
let piped: WeakSet<RTCRtpSender | RTCRtpReceiver> | undefined;

const attachTransform = (
target: RTCRtpSender | RTCRtpReceiver,
key: string,
operation: 'encode' | 'decode',
codec?: string,
) => {
const w = getWorker();
if (!shouldUseInsertableStreams()) {
target.transform = new RTCRtpScriptTransform(w, {
operation,
key,
codec,
});
return;
}

if ((piped ??= new WeakSet()).has(target)) return;
piped.add(target);
// @ts-expect-error createEncodedStreams is not in the standard typedefs
const { readable, writable } = target.createEncodedStreams();
w.postMessage({ operation, readable, writable, key, codec }, [
readable,
writable,
]);
};

export const createEncryptor = (
sender: RTCRtpSender,
key: string,
codec?: string,
) => attachTransform(sender, key, 'encode', codec);

export const createDecryptor = (receiver: RTCRtpReceiver, key: string) =>
attachTransform(receiver, key, 'decode');
Loading
Loading