Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
c1e4763
add support for packet trailer features
chenosaurus Apr 14, 2026
3d00ac0
update frame metadata rendering in demo
chenosaurus Apr 14, 2026
0e61562
when e2ee is enabled, handle the packet trailer parsing within FrameC…
chenosaurus Apr 14, 2026
f11025a
remove orphaned file
chenosaurus Apr 14, 2026
e00d1dd
remove orphaned file
chenosaurus Apr 14, 2026
09e2b1f
cleanup for consistency
chenosaurus Apr 14, 2026
c9250dd
dont display frame id if not set
chenosaurus Apr 14, 2026
5d9d1bc
clean up
chenosaurus Apr 14, 2026
df3dc78
improve parser
chenosaurus Apr 14, 2026
de281af
clean up
chenosaurus Apr 14, 2026
a47dee7
ensure user_timestamp is bigint
chenosaurus Apr 14, 2026
2028be1
fix bigint literals
chenosaurus Apr 14, 2026
cbda15e
lint
chenosaurus Apr 15, 2026
c9721ff
lint
chenosaurus Apr 15, 2026
96b1cec
bump @livekit/protocol to 1.45.3
chenosaurus Apr 15, 2026
02ec087
update pnpm lock file
chenosaurus Apr 15, 2026
ed291a7
formatting
chenosaurus Apr 15, 2026
b3d39e3
fix access to packetTrailerExtractor
chenosaurus Apr 15, 2026
4bd5ada
fix reconnect logic & dont attach transformer to published tracks fro…
chenosaurus Apr 16, 2026
9f91c94
backup transformer can be added in main thread if worker is not created
chenosaurus Apr 16, 2026
f120282
move packetTrailer files
chenosaurus Apr 16, 2026
b730f81
refactor parsing into helper, clean up framecryptor
chenosaurus Apr 16, 2026
e0d173c
add the moved files
chenosaurus Apr 16, 2026
6db94bd
revert unintended change
chenosaurus Apr 16, 2026
49942ef
lint
chenosaurus Apr 16, 2026
ae7c8f5
update packet trailer to utilize client capabilities
chenosaurus Apr 28, 2026
125464f
dont attach transform if worker is not initialized
chenosaurus Apr 28, 2026
e06d5d2
Merge branch 'main' into dc/feature/packet_trailer
chenosaurus Apr 28, 2026
b124e14
cleanup
chenosaurus Apr 28, 2026
72c6251
Merge branch 'dc/feature/packet_trailer' of github.com:livekit/client…
chenosaurus Apr 28, 2026
d791bc0
ensure safari works
chenosaurus Apr 28, 2026
afc0fee
revert unneded changes
chenosaurus Apr 28, 2026
e51ca72
ensure outgoing video doesnt have packet trailer transformer
chenosaurus Apr 29, 2026
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
2 changes: 1 addition & 1 deletion .size-limit.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module.exports = [
{
path: 'dist/livekit-client.esm.mjs',
import: '{ Room }',
limit: '100 kB',
limit: '101 kB',
},
{
path: 'dist/livekit-client.umd.js',
Expand Down
36 changes: 36 additions & 0 deletions examples/demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
ParticipantEvent,
RemoteParticipant,
RemoteTrackPublication,
RemoteVideoTrack,
Room,
RoomEvent,
ScreenSharePresets,
Expand All @@ -40,7 +41,10 @@ import {
supportsAV1,
supportsVP9,
} from '../../src/index';
//@ts-ignore
import PTWorker from '../../src/packetTrailer/worker/packetTrailer.worker?worker';
import type { DataTrackFrame } from '../../src/room/data-track/frame';
import { TrackEvent } from '../../src/room/events';
import { isSVCCodec, sleep, supportsH265 } from '../../src/room/utils';

setLogLevel(LogLevel.debug);
Expand Down Expand Up @@ -106,6 +110,7 @@ const appActions = {
const cryptoKey = (<HTMLSelectElement>$('crypto-key')).value;
const autoSubscribe = (<HTMLInputElement>$('auto-subscribe')).checked;
const e2eeEnabled = (<HTMLInputElement>$('e2ee')).checked;
const packetTrailerEnabled = (<HTMLInputElement>$('packet-trailer')).checked;
const audioOutputId = (<HTMLSelectElement>$('audio-output')).value;
let backupCodecPolicy: BackupCodecPolicy | undefined;
if ((<HTMLInputElement>$('multicodec-simulcast')).checked) {
Expand Down Expand Up @@ -137,6 +142,7 @@ const appActions = {
encryption: e2eeEnabled
? { keyProvider: state.e2eeKeyProvider, worker: new E2EEWorker() }
: undefined,
packetTrailer: packetTrailerEnabled ? { worker: new PTWorker() } : undefined,
};
if (
roomOpts.publishDefaults?.videoCodec === 'av1' ||
Expand Down Expand Up @@ -243,6 +249,35 @@ const appActions = {
appendLog('subscribed to track', pub.trackSid, participant.identity);
renderParticipant(participant);
renderScreenShare(room);
if (track instanceof RemoteVideoTrack) {
let lastLatencyUpdate = 0;
let latencyDisplay = '';
track.on(TrackEvent.TimeSyncUpdate, ({ rtpTimestamp }) => {
const meta = track.lookupFrameMetadata({ rtpTimestamp });
const overlayElm = document.getElementById(`pt-overlay-${participant.identity}`);
if (overlayElm && meta) {
let text = meta.frameId ? `Frame ID: ${meta.frameId}` : '';
if (meta.userTimestamp) {
const now = Date.now();
const receiveTime = new Date(now);
const publishTime = new Date(Number(meta.userTimestamp / 1000n));
if (now - lastLatencyUpdate >= 500) {
lastLatencyUpdate = now;
latencyDisplay = `${(receiveTime.getTime() - publishTime.getTime()).toFixed(1)}ms`;
}
const fmt = (d: Date) => {
const pad = (n: number, w = 2) => String(n).padStart(w, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}:${pad(d.getMilliseconds(), 3)}`;
};
text +=
`\nPublish: ${fmt(publishTime)}` +
`\nReceive: ${fmt(receiveTime)}` +
`\nLatency: ${latencyDisplay}`;
}
overlayElm.textContent = text;
}
});
}
})
.on(RoomEvent.TrackUnsubscribed, (_, pub, participant) => {
appendLog('unsubscribed from track', pub.trackSid);
Expand Down Expand Up @@ -850,6 +885,7 @@ function renderParticipant(participant: Participant, remove: boolean = false) {
div.innerHTML = `
<video id="video-${identity}"></video>
<audio id="audio-${identity}"></audio>
<div id="pt-overlay-${identity}" class="pt-overlay"></div>
Comment thread
chenosaurus marked this conversation as resolved.
Dismissed
<div class="info-bar">
<div id="name-${identity}" class="name">
</div>
Expand Down
4 changes: 4 additions & 0 deletions examples/demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ <h2>Livekit Sample App</h2>
<input type="checkbox" class="form-check-input" id="e2ee" />
<label for="e2ee" class="form-check-label"> E2E Encryption </label>
</div>
<div>
<input type="checkbox" class="form-check-input" id="packet-trailer" />
<label for="packet-trailer" class="form-check-label"> Packet Trailer </label>
</div>
<div>
<select id="preferred-codec" class="custom-select" style="width: auto"></select>
</div>
Expand Down
16 changes: 16 additions & 0 deletions examples/demo/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,19 @@
position: absolute;
z-index: 4;
}

.participant .pt-overlay {
position: absolute;
top: 4px;
left: 4px;
padding: 2px 6px;
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
font-family: monospace;
font-size: 11px;
line-height: 1.4;
border-radius: 3px;
z-index: 6;
pointer-events: none;
white-space: pre;
}
12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
"types": "./dist/src/e2ee/worker/e2ee.worker.d.ts",
"import": "./dist/livekit-client.e2ee.worker.mjs",
"require": "./dist/livekit-client.e2ee.worker.js"
},
"./packet-trailer-worker": {
"types": "./dist/src/packetTrailer/worker/packetTrailer.worker.d.ts",
"import": "./dist/livekit-client.pt.worker.mjs",
"require": "./dist/livekit-client.pt.worker.js"
}
},
"files": [
Expand All @@ -29,14 +34,17 @@
],
"./dist/src/e2ee/worker/e2ee.worker.d.ts": [
"./dist/ts4.2/e2ee/worker/e2ee.worker.d.ts"
],
"./dist/src/packetTrailer/worker/packetTrailer.worker.d.ts": [
"./dist/ts4.2/packetTrailer/worker/packetTrailer.worker.d.ts"
]
}
},
"repository": "git@github.com:livekit/client-sdk-js.git",
"author": "LiveKit <hello@livekit.io>",
"license": "Apache-2.0",
"scripts": {
"build": "rollup --config --bundleConfigAsCjs && rollup --config rollup.config.worker.js --bundleConfigAsCjs && pnpm downlevel-dts",
"build": "rollup --config --bundleConfigAsCjs && rollup --config rollup.config.worker.js --bundleConfigAsCjs && rollup --config rollup.config.pt-worker.js --bundleConfigAsCjs && pnpm downlevel-dts",
"build:clean": "rm -rf ./dist && pnpm build",
"build:watch": "rollup --watch --config --bundleConfigAsCjs",
"build:worker:watch": "rollup --watch --config rollup.config.worker.js --bundleConfigAsCjs",
Expand All @@ -57,7 +65,7 @@
},
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.45.3",
"@livekit/protocol": "1.45.6",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
Expand Down
18 changes: 9 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions rollup.config.pt-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import terser from '@rollup/plugin-terser';
import typescript from 'rollup-plugin-typescript2';
import packageJson from './package.json';
import { commonPlugins, kebabCaseToPascalCase } from './rollup.config';

export default {
input: 'src/packetTrailer/worker/packetTrailer.worker.ts',
output: [
{
file: `dist/${packageJson.name}.pt.worker.mjs`,
format: 'es',
strict: true,
sourcemap: true,
},
{
file: `dist/${packageJson.name}.pt.worker.js`,
format: 'umd',
strict: true,
sourcemap: true,
name: kebabCaseToPascalCase(packageJson.name) + '.pt.worker',
plugins: [terser()],
},
],
plugins: [typescript({ tsconfig: './src/packetTrailer/worker/tsconfig.json' }), ...commonPlugins],
};
112 changes: 102 additions & 10 deletions src/api/SignalClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import {
ClientInfo_Capability,
DisconnectReason,
JoinRequest,
JoinResponse,
LeaveRequest,
ReconnectResponse,
SignalRequest,
SignalResponse,
WrappedJoinRequest,
WrappedJoinRequest_Compression,
} from '@livekit/protocol';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConnectionError, ConnectionErrorReason } from '../room/errors';
Expand Down Expand Up @@ -60,6 +64,7 @@ interface MockWebSocketStreamOptions {
connection?: WebSocketConnection;
opened?: Promise<WebSocketConnection>;
closed?: Promise<WebSocketCloseInfo>;
onUrl?: (url: string) => void;
readyState?: number;
}

Expand All @@ -69,18 +74,19 @@ function mockWebSocketStream(options: MockWebSocketStreamOptions = {}) {
opened = connection ? Promise.resolve(connection) : new Promise(() => {}),
closed = new Promise(() => {}),
readyState = 1,
onUrl,
} = options;

return vi.mocked(WebSocketStream).mockImplementationOnce(
() =>
({
url: 'wss://test.livekit.io',
opened,
closed,
close: vi.fn(),
readyState,
}) as any,
);
return vi.mocked(WebSocketStream).mockImplementationOnce((url) => {
onUrl?.(url);
return {
url: 'wss://test.livekit.io',
opened,
closed,
close: vi.fn(),
readyState,
} as any;
});
}

describe('SignalClient.connect', () => {
Expand All @@ -99,6 +105,47 @@ describe('SignalClient.connect', () => {
signalClient = new SignalClient(false);
});

async function decodeJoinRequestFromUrl(url: string): Promise<JoinRequest> {
const joinRequestParam = new URL(url).searchParams.get('join_request');
expect(joinRequestParam).toBeTruthy();

const paddedBase64Url = joinRequestParam!.padEnd(
joinRequestParam!.length + ((4 - (joinRequestParam!.length % 4)) % 4),
'=',
);
const binaryString = atob(paddedBase64Url.replace(/-/g, '+').replace(/_/g, '/'));
const wrappedBytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i += 1) {
wrappedBytes[i] = binaryString.charCodeAt(i);
}

const wrappedJoinRequest = WrappedJoinRequest.fromBinary(wrappedBytes);
if (wrappedJoinRequest.compression === WrappedJoinRequest_Compression.NONE) {
return JoinRequest.fromBinary(wrappedJoinRequest.joinRequest);
}

const stream = new DecompressionStream('gzip');
const writer = stream.writable.getWriter();
writer.write(wrappedJoinRequest.joinRequest);
writer.close();

const chunks: Uint8Array[] = [];
const reader = stream.readable.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const bytes = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.length;
}
return JoinRequest.fromBinary(bytes);
}

describe('Happy Path - Initial Join', () => {
it('should successfully connect and receive join response', async () => {
const joinResponse = createJoinResponse();
Expand All @@ -113,6 +160,51 @@ describe('SignalClient.connect', () => {
expect(result).toEqual(joinResponse);
expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
});

it('does not advertise packet trailer capability by default', async () => {
const joinResponse = createJoinResponse();
const signalResponse = createSignalResponse('join', joinResponse);
const mockReadable = createMockReadableStream([signalResponse]);
const mockConnection = createMockConnection(mockReadable);
let capturedUrl = '';

mockWebSocketStream({
connection: mockConnection,
onUrl: (url) => {
capturedUrl = url;
},
});

await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);

const joinRequest = await decodeJoinRequestFromUrl(capturedUrl);
expect(joinRequest.clientInfo?.capabilities).toEqual([]);
});

it('advertises packet trailer capability when provided', async () => {
const joinResponse = createJoinResponse();
const signalResponse = createSignalResponse('join', joinResponse);
const mockReadable = createMockReadableStream([signalResponse]);
const mockConnection = createMockConnection(mockReadable);
let capturedUrl = '';

mockWebSocketStream({
connection: mockConnection,
onUrl: (url) => {
capturedUrl = url;
},
});

await signalClient.join('wss://test.livekit.io', 'test-token', {
...defaultOptions,
clientInfoCapabilities: [ClientInfo_Capability.CAP_PACKET_TRAILER],
});

const joinRequest = await decodeJoinRequestFromUrl(capturedUrl);
expect(joinRequest.clientInfo?.capabilities).toEqual([
ClientInfo_Capability.CAP_PACKET_TRAILER,
]);
});
});

describe('Happy Path - Reconnect', () => {
Expand Down
Loading
Loading