Building a Videoconference App
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
This page was written on 2026-10-24 (checked on 2026-10-24) against Node.js 24.21 (ESM, no TypeScript), mediasoup 3.31.0 and
mediasoup-client 3.25.3, ws 8.22.0, esbuild 0.28.2, coturn 4.18.0, LiveKit server 1.13.10 with livekit-client 2.23.0,
livekit-server-sdk 2.19.2 and LiveKit Egress 1.15.0, Redis 8.8.3 and Docker Engine 29 with Compose v2. The browser was
Chrome 155.0.8059.85 with fake capture devices. Every file shown below is the complete file, and the whole stack was run:
the Docker Compose project (coturn, the Huddle server with mediasoup, and for the LiveKit variant a LiveKit server, Redis
and Egress) and two to four headless Chrome clients in each of the mesh, SFU and LiveKit modes. The checks and their
results are listed in What was verified.
Huddle is the videoconference application of this section. The earlier pages teach one technology each: how a peer connection and its SDP work (Peer Connections and SDP), how two browsers negotiate without colliding (Signaling and Perfect Negotiation), how they find a path through NATs (ICE, STUN and TURN), and what an SFU does (Topologies: Mesh, SFU and MCU, mediasoup and LiveKit). This page puts them together into one program that you can start with a single command, and it keeps the explanations of the individual technologies on their own pages. The design decisions (components, room state, layouts, bandwidth adaptation, mobile constraints) are collected in Videoconference Architecture.
What Huddle does
Huddle has one browser client and one Node.js server, and the same client works in three modes that differ only in how media travels. A participant opens the page, picks a name, a room and a mode, and joins.
| Feature | Mesh mode (up to four) | SFU mode (mediasoup) | LiveKit mode |
|---|---|---|---|
Signalling |
The server relays opaque messages between members of a room ( |
The server forwards mediasoup requests and notifications ( |
LiveKit’s own WebSocket; Huddle only mints a token ( |
Media path |
One |
One send and one receive transport to the SFU, one producer per local track, one consumer per remote track. |
|
Negotiation |
Perfect negotiation, with the newcomer making the first offer. |
None: the client and server exchange RTP parameters, not SDP. |
Handled by the SDK. |
Screen sharing |
A second video sender with its own stream, announced to the peer in a |
A third producer with |
A track published with source |
Device switching |
|
|
|
Mute |
|
|
|
Active speaker |
Web Audio analysers in the browser ( |
The mediasoup |
|
Chat |
A pre-negotiated |
A mediasoup |
|
Quality indicator |
|
|
|
Recording |
|
The same. |
LiveKit Egress composes the room on the server. |
End-to-end encryption |
|
The same transform, installed before the first frame. |
|
Mesh mode is the right place to start. It needs no media server, so you see every step of a call (offers, answers, candidates, tracks) in your own code, and it works for small meetings. The cost is arithmetic: with four people each browser uploads its video three times and downloads three streams, so a 1.5 Mb/s budget leaves 500 kb/s per link, and the encoder and the uplink are the limit long before the signalling is. The SFU mode removes that limit by uploading once. The decision between the two, and between building on an SFU library and embedding or renting one, is made in Videoconference Architecture and Comparison and Decision Guide.
Architecture of the build
The server in server/ does five jobs and nothing else: it serves the static client, hands out a single-use WebSocket
ticket together with TURN credentials (POST /api/session), relays mesh signalling, drives mediasoup for the SFU mode
and accepts recording chunks. In production the same Node.js process would sit behind a reverse proxy that terminates
TLS (see Proxies, Load Balancers and Deployment).
The signalling protocol is the same small request/response protocol in both self-hosted modes, so a single client class
(src/signaling.js) serves both. A request is {id, method, data}, the answer is {id, data} or {id, error}, and the
server pushes notifications as {notify, data}. This is the shape that mediasoup recommends for application signalling
and that the chat protocol of Message Design and Serialization
generalises with an envelope. The Chatterbox protocol itself is not reused here, because a call needs request/response
semantics for transport creation that a chat does not.
Project layout and dependencies
huddle/
package.json
Dockerfile
compose.yaml coturn + Huddle (mesh and SFU)
compose.livekit.yaml adds LiveKit, Redis and Egress
turnserver.conf
.env host ports, HOST_IP
server/
config.mjs auth.mjs turn.mjs rpc.mjs mesh.mjs sfu.mjs recordings.mjs livekit.mjs main.mjs
src/ browser code, bundled by esbuild into public/
main.js signaling.js media.js mesh.js sfu.js session-livekit.js
quality.js speaker.js recorder.js e2ee.js e2ee-worker.js ui.js
public/
index.html
{
"name": "huddle",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/main.js --bundle --splitting --format=esm --target=es2022 --outdir=public && cp src/e2ee-worker.js public/e2ee-worker.js && cp node_modules/livekit-client/dist/livekit-client.e2ee.worker.js public/livekit-client.e2ee.worker.js",
"start": "node server/main.mjs"
},
"dependencies": {
"livekit-server-sdk": "2.19.2",
"mediasoup": "3.31.0",
"ws": "8.22.0"
},
"devDependencies": {
"esbuild": "0.28.2",
"livekit-client": "2.23.0",
"mediasoup-client": "3.25.3"
}
}
mediasoup and ws run on the server, livekit-server-sdk is only loaded when the LiveKit mode is on. The browser
libraries (mediasoup-client, livekit-client) are development dependencies because esbuild bundles them into
public/main.js and a lazily loaded chunk for LiveKit. The build script also copies the two encoded-transform workers
next to the page, because a Worker needs a URL it can fetch.
Source: mediasoup: Installation
Prerequisites and the TURN address
Docker with Compose v2 and a recent Chrome, Edge or Firefox are enough. Two environment facts matter.
-
getUserMedia()andgetDisplayMedia()work only in a secure context, which includeshttp://localhostbut nothttp://192.168.1.20. Open Huddle throughlocalhoston the machine that runs the stack, or put it behind HTTPS. -
The media servers must advertise an address that browsers can reach. mediasoup takes it from
ANNOUNCED_ADDRESS, LiveKit from--node-ip, and the TURN URLs fromTURN_HOST. In the Compose files all three come from one variable,HOST_IP, which must be the LAN address of the machine (not127.0.0.1): Chrome binds its UDP sockets to the LAN interface and does not reach a TURN server onlocalhost(the reason is explained in ICE, STUN and TURN).
The server
Configuration
All settings come from the environment, so the same image runs on a laptop and in production. The defaults are the ones used by the Compose file, and the secrets are obvious placeholders that must be replaced.
// All settings come from the environment so the same image runs locally and in production.
const env = process.env;
export const config = {
port: Number(env.HTTP_PORT ?? 3000),
rtcPort: Number(env.RTC_PORT ?? 40000),
// The address browsers must use to reach mediasoup (the host's LAN or public IP, not the container's).
announcedAddress: env.ANNOUNCED_ADDRESS ?? '127.0.0.1',
allowedOrigins: (env.ALLOWED_ORIGINS ?? 'http://localhost:3000').split(','),
ticketSecret: env.HUDDLE_TICKET_SECRET ?? 'change-me-ticket-secret',
turn: {
host: env.TURN_HOST ?? '127.0.0.1',
port: Number(env.TURN_PORT ?? 3478),
secret: env.TURN_SHARED_SECRET ?? 'example-shared-secret-change-me',
ttlSeconds: Number(env.TURN_TTL_SECONDS ?? 3600),
},
// Which backends this process serves: mesh needs nothing, sfu starts a mediasoup worker, livekit talks to a LiveKit server.
modes: (env.HUDDLE_MODES ?? 'mesh,sfu').split(','),
livekit: {
url: env.LIVEKIT_URL ?? 'http://localhost:7880', // used by this server (REST and Egress)
publicUrl: env.LIVEKIT_PUBLIC_URL ?? 'ws://localhost:7880', // used by browsers
apiKey: env.LIVEKIT_API_KEY ?? 'huddle-dev-key',
apiSecret: env.LIVEKIT_API_SECRET ?? 'change-me-change-me-change-me-32chars',
},
maxMeshRoom: 4,
recordingsDir: env.RECORDINGS_DIR ?? './recordings',
};
Source: The Twelve-Factor App: Config
Tickets and upload tokens
The browser WebSocket API cannot set an Authorization header, and browsers do not apply the same-origin policy to
WebSocket handshakes. Huddle therefore authenticates in two steps. A normal HTTP request (here the demo endpoint
POST /api/session, in a real application one behind your login session) returns a ticket: the user name, a 30 second
expiry and a random nonce, signed with HMAC-SHA-256. The browser puts it in the query string of the WebSocket URL, and the
server redeems it exactly once. A ticket in a URL ends up in access logs, which is acceptable only because it expires
quickly and works once. The same module signs the upload token that binds one participant to one room for the recording
endpoint.
// Short-lived, single-use WebSocket tickets and HMAC-signed upload tokens.
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { config } from './config.mjs';
const TICKET_TTL_MS = 30_000;
const used = new Map(); // nonce -> expiry, so that a ticket works exactly once
const sign = (body) => createHmac('sha256', config.ticketSecret).update(body).digest('base64url');
function verify(parts, mac) {
const expected = Buffer.from(sign(parts));
const given = Buffer.from(mac);
return given.length === expected.length && timingSafeEqual(given, expected);
}
/** Called by an authenticated HTTP endpoint; `user` comes from your login session. */
export function issueTicket(user) {
const body = `${user}.${Date.now() + TICKET_TTL_MS}.${randomBytes(8).toString('hex')}`;
return `${body}.${sign(body)}`;
}
/** Returns the user name, or null for a malformed, forged, expired or replayed ticket. */
export function redeemTicket(ticket) {
const parts = String(ticket ?? '').split('.');
if (parts.length !== 4) return null;
const [user, expires, nonce, mac] = parts;
if (!verify(`${user}.${expires}.${nonce}`, mac)) return null;
const now = Date.now();
for (const [n, exp] of used) if (exp < now) used.delete(n);
if (Number(expires) < now || used.has(nonce)) return null;
used.set(nonce, Number(expires));
return user;
}
/** An upload token binds one participant to one room for the recording endpoint. */
export const issueUploadToken = (room, peerId) => `${room}.${peerId}.${sign(`upload:${room}.${peerId}`)}`;
export function checkUploadToken(token, room, peerId) {
const mac = String(token ?? '').split('.')[2];
return Boolean(mac) && verify(`upload:${room}.${peerId}`, mac);
}
Source: MDN: WebSocket() constructor
TURN credentials
A static TURN password in the page would let anyone use your relay for free traffic. coturn’s use-auth-secret mode
accepts credentials that the application can compute without talking to coturn: the user name is
<expiry timestamp>:<user> and the password is base64(HMAC-SHA1(shared secret, username)). The credentials stop working
at the expiry time. The convention is the "TURN REST API", an expired Internet-Draft (draft-uberti-behave-turn-rest) that
coturn and eturnal both implement.
// Ephemeral TURN credentials (the "TURN REST API" convention used by coturn's use-auth-secret).
import { createHmac } from 'node:crypto';
import { config } from './config.mjs';
export function iceServersFor(userId, now = Date.now()) {
const { host, port, secret, ttlSeconds } = config.turn;
const username = `${Math.floor(now / 1000) + ttlSeconds}:${userId}`;
const credential = createHmac('sha1', secret).update(username).digest('base64');
return [
{ urls: `stun:${host}:${port}` },
{ urls: [`turn:${host}:${port}?transport=udp`, `turn:${host}:${port}?transport=tcp`], username, credential },
];
}
Request and response over a WebSocket
rpc() turns a WebSocket and a table of handlers into the protocol described above. It rejects binary frames (close code
1003) and invalid JSON (1007), looks handlers up with Object.hasOwn so that a message such as {"method":"constructor"}
cannot reach a prototype property, and returns a function that sends notifications.
// Request/response plus notifications over one WebSocket:
// client -> server {id, method, data} server -> client {id, data} | {id, error}
// server -> client {notify, data}
export function rpc(ws, handlers) {
ws.on('message', async (raw, isBinary) => {
if (isBinary) return ws.close(1003, 'text only');
let msg;
try { msg = JSON.parse(raw.toString()); } catch { return ws.close(1007, 'invalid JSON'); }
const handler = Object.hasOwn(handlers, msg.method) ? handlers[msg.method] : null;
try {
if (!handler) throw new Error(`unknown method ${msg.method}`);
ws.send(JSON.stringify({ id: msg.id, data: (await handler(msg.data ?? {})) ?? {} }));
} catch (err) {
ws.send(JSON.stringify({ id: msg.id, error: err.message }));
}
});
return (notify, data) => ws.readyState === ws.OPEN && ws.send(JSON.stringify({ notify, data }));
}
export const validRoom = (name) => /^[a-z0-9-]{1,64}$/.test(String(name ?? ''));
Source: ws: API documentation
Mesh signalling
In mesh mode the server knows nothing about WebRTC. It keeps a map of rooms, refuses the fifth participant (room_full),
tells the existing members when somebody joins or leaves, and forwards signal messages to one member of the same room.
The payload (a description, a candidate or a meta message) is opaque to the server, so it cannot be tampered with in a
way the browsers do not already guard against, and the server cannot learn anything from it that SDP does not already
reveal.
// Mesh mode: the server only relays opaque signalling messages between members of one room.
import { randomUUID } from 'node:crypto';
import { config } from './config.mjs';
import { issueUploadToken } from './auth.mjs';
import { rpc, validRoom } from './rpc.mjs';
const rooms = new Map(); // room name -> Map(peerId -> peer)
export function onConnection(ws, user) {
const peer = { id: `p_${randomUUID().slice(0, 8)}`, name: user, room: null };
let notifyMe;
const others = () => [...(rooms.get(peer.room)?.values() ?? [])].filter((p) => p !== peer);
function leave() {
if (!peer.room) return;
const members = rooms.get(peer.room);
members.delete(peer.id);
for (const p of members.values()) p.notify('peerLeft', { peerId: peer.id });
if (members.size === 0) rooms.delete(peer.room);
peer.room = null;
}
notifyMe = rpc(ws, {
join({ room }) {
if (peer.room) throw new Error('already joined');
if (!validRoom(room)) throw new Error('bad room name');
const members = rooms.get(room) ?? new Map();
if (members.size >= config.maxMeshRoom) throw new Error('room_full'); // mesh does not scale past four
const peers = [...members.values()].map((p) => ({ id: p.id, name: p.name }));
members.set(peer.id, peer);
rooms.set(room, members);
peer.room = room;
for (const p of peers) members.get(p.id).notify('peerJoined', { peer: { id: peer.id, name: peer.name } });
return { peerId: peer.id, peers, uploadToken: issueUploadToken(room, peer.id) };
},
signal({ to, data }) {
const target = others().find((p) => p.id === to); // only members of the same room are reachable
if (!target) throw new Error('not_found');
target.notify('signal', { from: peer.id, data }); // the payload is opaque to the server
},
leave,
});
peer.notify = notifyMe;
ws.on('close', leave);
}
Source: MDN: Signaling and video calling
SFU signalling with mediasoup
The SFU module is the mediasoup page’s server grown into the full application. One worker listens on a single UDP and
TCP port through a WebRtcServer (so the firewall needs one port, not one per transport), each room gets a router and an
ActiveSpeakerObserver, and each participant gets transports that also enable SCTP for the chat data channel. The
handlers map onto the browser lifecycle:
-
joinreturns the router’s RTP capabilities and the producers and data producers that already exist. -
createTransport,connectTransportandrestartIcemanage the two transports.restartIceis what the client calls when a transport reportsfailed, for example after a Wi-Fi change. -
producerecordssource(mic,cameraorscreen) inappDataand announces the new producer to the room. The audio producers are added to the speaker observer.closeProducer,pauseProducerandresumeProducerimplement stopping a screen share and mute. -
consumecreates the consumer paused andresumeConsumerstarts it after the client has attached the track, so no packets are lost before the receiving side exists.setPreferredLayerschooses a simulcast layer per consumer. -
produceDataandconsumeDataconnect chat messages: every participant produces one data producer and consumes the data producers of the others.
When a connection closes, closing the transports closes the producers and consumers that belong to them, and the last participant to leave closes the router.
// SFU mode: one mediasoup worker, one router per room, the browser talks to it through this signalling layer.
import { randomUUID } from 'node:crypto';
import * as mediasoup from 'mediasoup';
import { config } from './config.mjs';
import { issueUploadToken } from './auth.mjs';
import { rpc, validRoom } from './rpc.mjs';
const mediaCodecs = [
{ kind: 'audio', mimeType: 'audio/opus', clockRate: 48000, channels: 2 },
{ kind: 'video', mimeType: 'video/VP8', clockRate: 90000 },
{ kind: 'video', mimeType: 'video/H264', clockRate: 90000,
parameters: { 'packetization-mode': 1, 'profile-level-id': '42e01f', 'level-asymmetry-allowed': 1 } },
];
const worker = await mediasoup.createWorker({ logLevel: 'warn' });
worker.on('died', (err) => { console.error('mediasoup worker died', err); process.exit(1); });
// One UDP and one TCP port serve every transport of this worker.
const webRtcServer = await worker.createWebRtcServer({
listenInfos: ['udp', 'tcp'].map((protocol) => ({
protocol, ip: '0.0.0.0', announcedAddress: config.announcedAddress, port: config.rtcPort,
})),
});
const rooms = new Map(); // name -> { name, router, peers: Map, speakers }
async function getRoom(name) {
let room = rooms.get(name);
if (room) return room;
const router = await worker.createRouter({ mediaCodecs });
const speakers = await router.createActiveSpeakerObserver({ interval: 300 });
room = { name, router, speakers, peers: new Map() };
speakers.on('dominantspeaker', ({ producer }) => broadcast(room, null, 'activeSpeaker', { peerId: producer.appData.peerId }));
rooms.set(name, room);
return room;
}
function broadcast(room, exceptId, notify, data) {
for (const p of room.peers.values()) if (p.id !== exceptId) p.notify(notify, data);
}
export function onConnection(ws, user) {
const peer = {
id: `p_${randomUUID().slice(0, 8)}`, name: user, room: null,
transports: new Map(), producers: new Map(), consumers: new Map(), dataProducers: new Map(), dataConsumers: new Map(),
};
const need = (map, id) => { const v = map.get(id); if (!v) throw new Error('not_found'); return v; };
peer.notify = rpc(ws, {
async join({ room: name }) {
if (peer.room) throw new Error('already joined');
if (!validRoom(name)) throw new Error('bad room name');
const room = await getRoom(name);
peer.room = room;
room.peers.set(peer.id, peer);
const existing = [];
const existingData = [];
for (const other of room.peers.values()) {
for (const p of other.producers.values()) {
existing.push({ producerId: p.id, peerId: other.id, kind: p.kind, source: p.appData.source, paused: p.paused });
}
for (const d of other.dataProducers.values()) existingData.push({ dataProducerId: d.id, peerId: other.id });
}
broadcast(room, peer.id, 'peerJoined', { peer: { id: peer.id, name: peer.name } });
return {
peerId: peer.id, uploadToken: issueUploadToken(name, peer.id),
peers: [...room.peers.values()].filter((p) => p !== peer).map((p) => ({ id: p.id, name: p.name })),
routerRtpCapabilities: room.router.rtpCapabilities, existing, existingData,
};
},
async createTransport() {
const transport = await peer.room.router.createWebRtcTransport({
webRtcServer, enableUdp: true, enableTcp: true, preferUdp: true,
enableSctp: true, numSctpStreams: { OS: 256, MIS: 256 }, // SCTP carries the chat data channel
});
peer.transports.set(transport.id, transport);
return {
id: transport.id, iceParameters: transport.iceParameters, iceCandidates: transport.iceCandidates,
dtlsParameters: transport.dtlsParameters, sctpParameters: transport.sctpParameters,
};
},
async connectTransport({ transportId, dtlsParameters }) {
await need(peer.transports, transportId).connect({ dtlsParameters });
},
async restartIce({ transportId }) {
return { iceParameters: await need(peer.transports, transportId).restartIce() };
},
async produce({ transportId, kind, rtpParameters, source }) {
const producer = await need(peer.transports, transportId)
.produce({ kind, rtpParameters, appData: { peerId: peer.id, source } });
peer.producers.set(producer.id, producer);
producer.on('transportclose', () => peer.producers.delete(producer.id));
if (kind === 'audio') await peer.room.speakers.addProducer({ producerId: producer.id });
broadcast(peer.room, peer.id, 'newProducer', { producerId: producer.id, peerId: peer.id, kind, source, paused: false });
return { id: producer.id };
},
async closeProducer({ producerId }) {
need(peer.producers, producerId).close();
peer.producers.delete(producerId);
broadcast(peer.room, peer.id, 'producerClosed', { peerId: peer.id, producerId });
},
async pauseProducer({ producerId }) {
await need(peer.producers, producerId).pause();
broadcast(peer.room, peer.id, 'producerPaused', { peerId: peer.id, producerId, paused: true });
},
async resumeProducer({ producerId }) {
await need(peer.producers, producerId).resume();
broadcast(peer.room, peer.id, 'producerPaused', { peerId: peer.id, producerId, paused: false });
},
async consume({ transportId, producerId, rtpCapabilities }) {
if (!peer.room.router.canConsume({ producerId, rtpCapabilities })) throw new Error('cannot_consume');
const consumer = await need(peer.transports, transportId)
.consume({ producerId, rtpCapabilities, paused: true }); // resumed once the client has the track
peer.consumers.set(consumer.id, consumer);
const gone = () => { peer.consumers.delete(consumer.id); peer.notify('consumerClosed', { consumerId: consumer.id }); };
consumer.on('producerclose', gone);
consumer.on('transportclose', () => peer.consumers.delete(consumer.id));
return { id: consumer.id, producerId, kind: consumer.kind, rtpParameters: consumer.rtpParameters };
},
async resumeConsumer({ consumerId }) { await need(peer.consumers, consumerId).resume(); },
async setPreferredLayers({ consumerId, spatialLayer, temporalLayer }) {
await need(peer.consumers, consumerId).setPreferredLayers({ spatialLayer, temporalLayer });
},
async produceData({ transportId, sctpStreamParameters, label }) {
const dp = await need(peer.transports, transportId).produceData({ sctpStreamParameters, label, appData: { peerId: peer.id } });
peer.dataProducers.set(dp.id, dp);
broadcast(peer.room, peer.id, 'newDataProducer', { dataProducerId: dp.id, peerId: peer.id });
return { id: dp.id };
},
async consumeData({ transportId, dataProducerId }) {
const dc = await need(peer.transports, transportId).consumeData({ dataProducerId });
peer.dataConsumers.set(dc.id, dc);
return { id: dc.id, dataProducerId, sctpStreamParameters: dc.sctpStreamParameters, label: dc.label };
},
});
ws.on('close', () => {
for (const t of peer.transports.values()) t.close(); // also closes this peer's producers and consumers
if (!peer.room) return;
peer.room.peers.delete(peer.id);
broadcast(peer.room, peer.id, 'peerLeft', { peerId: peer.id });
if (peer.room.peers.size === 0) { peer.room.router.close(); rooms.delete(peer.room.name); }
});
}
Source: mediasoup: API
Recording uploads
Recording in this build happens in the browser (see [recording]), so the server only needs an endpoint that appends the chunks it receives to a file. The token must belong to the participant and room in the query string, the chunk size is capped, and the file name is built from validated parts only, so a request cannot write outside the recordings directory.
// POST /api/recordings?room=&peer=&part=<n>: appends MediaRecorder chunks to one file per recording.
import { createWriteStream, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { checkUploadToken } from './auth.mjs';
import { config } from './config.mjs';
import { validRoom } from './rpc.mjs';
const MAX_CHUNK = 8 * 1024 * 1024;
export async function handleUpload(req, res, url) {
const room = url.searchParams.get('room');
const peer = url.searchParams.get('peer');
const recording = url.searchParams.get('rec');
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '');
if (!validRoom(room) || !/^p_[0-9a-f]{8}$/.test(peer ?? '') || !/^[0-9]{10,}$/.test(recording ?? '')) {
return res.writeHead(400).end();
}
if (!checkUploadToken(token, room, peer)) return res.writeHead(401).end();
if (Number(req.headers['content-length'] ?? 0) > MAX_CHUNK) return res.writeHead(413).end();
const dir = join(config.recordingsDir, room);
mkdirSync(dir, { recursive: true });
// Chunks arrive in order on one connection at a time; "a" appends to the same file.
await pipeline(req, createWriteStream(join(dir, `${peer}-${recording}.webm`), { flags: 'a' }));
res.writeHead(204).end();
}
Source: MDN: MediaRecorder
The HTTP and WebSocket entry point
main.mjs serves public/ (only files with a known extension, normalised so that .. cannot escape), the session and
configuration endpoints, and upgrades the two WebSocket paths. The upgrade handler is where the security checks run, before
a WebSocket exists: the path must be known, the Origin header must be on the allow-list (this is what stops
Cross-Site WebSocket Hijacking, because a malicious page can open a WebSocket to your server with the victim’s cookies and
network position), and the ticket must be valid. Failures answer with 403 or 401 and close the socket. A 20 second
ping closes connections that missed the previous pong, so half-open connections do not pile up and load balancers
(whose idle timeouts are often 60 seconds) never see an idle socket.
The SFU and LiveKit modules are imported only when they are enabled, because importing sfu.mjs starts a mediasoup
worker.
// Huddle server: static files, session endpoint, recording upload and the two signalling WebSockets.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { WebSocketServer } from 'ws';
import { config } from './config.mjs';
import { issueTicket, redeemTicket } from './auth.mjs';
import { iceServersFor } from './turn.mjs';
import { handleUpload } from './recordings.mjs';
import * as mesh from './mesh.mjs';
// Backends are loaded on demand: importing sfu.mjs starts a mediasoup worker.
const sfu = config.modes.includes('sfu') ? await import('./sfu.mjs') : null;
const livekit = config.modes.includes('livekit') ? await import('./livekit.mjs') : null;
const PUBLIC = fileURLToPath(new URL('../public/', import.meta.url));
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css' };
const USER = /^[a-z0-9-]{1,32}$/;
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
try {
if (url.pathname === '/api/session' && req.method === 'POST') {
// Demo login: a real application authenticates the request and takes `user` from the session.
const user = url.searchParams.get('user') ?? '';
if (!USER.test(user)) return res.writeHead(400).end();
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
return res.end(JSON.stringify({ ticket: issueTicket(user), iceServers: iceServersFor(user) }));
}
if (url.pathname === '/api/config') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ modes: ['mesh', ...config.modes.filter((m) => m !== 'mesh')] }));
}
if (livekit && url.pathname.startsWith('/api/livekit/') && await livekit.handle(req, res, url)) return;
if (url.pathname === '/favicon.ico') return res.writeHead(204).end();
if (url.pathname === '/api/recordings' && req.method === 'POST') return await handleUpload(req, res, url);
const file = normalize(url.pathname === '/' ? '/index.html' : url.pathname);
const type = TYPES[extname(file)];
if (!type || file.includes('..')) return res.writeHead(404).end();
res.writeHead(200, { 'content-type': type, 'cache-control': 'no-cache' });
res.end(await readFile(join(PUBLIC, file)));
} catch (err) {
if (!res.headersSent) res.writeHead(err.code === 'ENOENT' ? 404 : 500);
res.end();
}
});
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
const modes = { '/ws/mesh': mesh, ...(sfu && { '/ws/sfu': sfu }) };
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, 'http://localhost');
const reject = (status, text) => { socket.write(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\n\r\n`); socket.destroy(); };
const mode = modes[url.pathname];
if (!mode) return reject(404, 'Not Found');
// Browsers do not apply the same-origin policy to WebSockets: check Origin ourselves.
if (!config.allowedOrigins.includes(req.headers.origin)) return reject(403, 'Forbidden');
const user = redeemTicket(url.searchParams.get('ticket'));
if (!user) return reject(401, 'Unauthorized');
wss.handleUpgrade(req, socket, head, (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
mode.onConnection(ws, user);
});
});
// Ping every 20 s; terminate connections that missed the previous pong (load balancers drop idle sockets).
setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) { ws.terminate(); continue; }
ws.isAlive = false;
ws.ping();
}
}, 20_000).unref();
server.listen(config.port, () => console.log(`huddle http://localhost:${config.port} (rtc ${config.rtcPort})`));
process.on('SIGTERM', () => process.exit(0));
The browser client
The client is plain ES modules, bundled by esbuild. There is no framework, so every DOM update is visible, and the
modules are small enough to read in one sitting. Data flows in one direction: the session (mesh, SFU or LiveKit) reports
events through a handlers object (peer, peerLeft, track, trackEnded, state, speaker, chat, quality),
and main.js turns them into DOM changes. The three sessions implement the same methods (join, setMuted,
replaceTrack, startScreen, stopScreen, sendChat, close), which is what makes the LiveKit variant a small diff.
The page
The markup is shared by all modes. The mode list is filled from /api/config, so a server without LiveKit does not offer
it. Controls are real buttons with aria-pressed for toggles, tiles are figure elements with captions, and the quality
indicator and the status line are live regions, so a screen reader hears state changes.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Huddle</title>
<style>
:root { color-scheme: light dark; --bg: #f6f7f9; --fg: #1b1f24; --card: #ffffff; --line: #c9ced6; --accent: #2457c5; --good: #1a7f37; --fair: #9a6700; --poor: #cf222e; }
@media (prefers-color-scheme: dark) { :root { --bg: #14171c; --fg: #e8eaee; --card: #1d2229; --line: #39414d; --accent: #7aa2f7; --good: #3fb950; --fair: #d29922; --poor: #f85149; } }
* { box-sizing: border-box; }
body { margin: 0; font: 16px/1.4 system-ui, sans-serif; background: var(--bg); color: var(--fg); }
main { max-width: 1200px; margin: 0 auto; padding: 16px; }
form, .panel { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px; }
label { display: block; margin: 8px 0 2px; font-size: 14px; }
input, select, button { font: inherit; padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); color: var(--fg); }
button { cursor: pointer; }
button[aria-pressed="true"] { background: var(--accent); color: #fff; border-color: var(--accent); }
button:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
#stage { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; margin-bottom: 12px; }
.tile { position: relative; margin: 0; background: #000; border: 3px solid transparent; border-radius: 8px; overflow: hidden; aspect-ratio: 16 / 9; }
.tile.screen { grid-column: 1 / -1; }
.tile.speaking { border-color: var(--good); }
.tile video { width: 100%; height: 100%; object-fit: contain; }
.tile figcaption { position: absolute; left: 0; bottom: 0; right: 0; padding: 2px 8px; color: #fff; background: rgba(0,0,0,.55); font-size: 14px; }
.badge { margin-left: 8px; color: #ffb4b4; }
#controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; }
#quality { font-variant-numeric: tabular-nums; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--line); }
#quality[data-score="good"] { color: var(--good); } #quality[data-score="fair"] { color: var(--fair); } #quality[data-score="poor"] { color: var(--poor); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
#log { list-style: none; margin: 0 0 8px; padding: 0; max-height: 180px; overflow: auto; }
#log .mine { color: var(--accent); }
#chatForm { display: flex; gap: 8px; } #chatText { flex: 1; }
</style>
</head>
<body>
<main>
<h1>Huddle</h1>
<form id="lobby" onsubmit="return false">
<label for="name">Your name (a-z, 0-9, dash)</label>
<input id="name" pattern="[a-z0-9-]{1,32}" required autocomplete="off">
<label for="room">Room</label>
<input id="room" pattern="[a-z0-9-]{1,64}" required autocomplete="off">
<label for="mode">Mode</label>
<select id="mode"></select>
<label for="passphrase">End-to-end passphrase (optional, shared out of band)</label>
<input id="passphrase" type="password" autocomplete="off">
<label><input id="relay" type="checkbox"> Mesh: force TURN relay</label>
<p><button id="join" type="button">Join</button></p>
</form>
<section id="call" hidden>
<div id="stage" role="group" aria-label="Participants"></div>
<div id="controls">
<button id="muteMic" type="button" aria-pressed="false">Mute microphone</button>
<button id="muteCam" type="button" aria-pressed="false">Turn camera off</button>
<button id="share" type="button" aria-pressed="false">Share screen</button>
<button id="recordButton" type="button" aria-pressed="false">Record</button><span id="recordState" aria-live="polite">idle</span>
<label for="mic" class="sr">Microphone</label><select id="mic"></select>
<label for="cam" class="sr">Camera</label><select id="cam"></select>
<button id="leave" type="button">Leave</button>
<span id="quality" data-score="unknown" role="status">quality: measuring</span>
</div>
<div class="panel">
<ul id="log" aria-live="polite" aria-label="Chat"></ul>
<form id="chatForm"><input id="chatText" aria-label="Message" autocomplete="off" maxlength="2000"><button>Send</button></form>
</div>
</section>
<p id="status" role="status" aria-live="polite"></p>
</main>
<script type="module" src="/main.js"></script>
</body>
</html>
Source: MDN: aria-pressed
Signalling client
openSignaling() performs the two-step authentication described earlier (HTTP session, then WebSocket with the ticket)
and implements request/response correlation with an incrementing id. When the socket closes, pending requests are
rejected so the UI never waits for an answer that cannot come.
// Request/response plus notifications over the signalling WebSocket (see server/rpc.mjs).
export async function openSignaling(mode, user) {
// 1. The authenticated HTTP call returns a single-use ticket and the ICE servers (with ephemeral TURN credentials).
const res = await fetch(`/api/session?user=${encodeURIComponent(user)}`, { method: 'POST' });
if (!res.ok) throw new Error(`login failed (${res.status})`);
const { ticket, iceServers } = await res.json();
// 2. The browser cannot set headers on a WebSocket, so the ticket travels in the query string.
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${scheme}://${location.host}/ws/${mode}?ticket=${encodeURIComponent(ticket)}`);
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = () => reject(new Error('the signalling handshake was rejected'));
});
const pending = new Map();
const listeners = new Map();
let seq = 0;
ws.onmessage = ({ data }) => {
const msg = JSON.parse(data);
if (msg.notify) return listeners.get(msg.notify)?.(msg.data);
const call = pending.get(msg.id);
pending.delete(msg.id);
if (!call) return;
msg.error ? call.reject(new Error(msg.error)) : call.resolve(msg.data);
};
ws.onclose = (event) => {
for (const call of pending.values()) call.reject(new Error('signalling connection closed'));
pending.clear();
listeners.get('close')?.(event);
};
return {
iceServers,
request: (method, data) => new Promise((resolve, reject) => {
const id = ++seq;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, data }));
}),
on: (name, fn) => listeners.set(name, fn),
close: () => ws.close(1000, 'bye'),
};
}
Source: MDN: WebSocket
Capture, device switching and screen sharing
LocalMedia owns the capture. The details come from Media Capture and Devices
and Screen Sharing:
-
Device labels are empty until the user has granted permission, so
devices()runs afterstart(). Switching opens the new device with anexactdevice id, copies the mute state to the new track, and stops the old track so the camera light goes off; the caller then swaps the track into the connections withreplaceTrack(), which needs no renegotiation. -
getDisplayMedia()must be called from a user gesture (the click on the share button), and the user can end sharing from the browser’s own "Stop sharing" bar, which is why the track’sendedevent callsstopScreen()andonScreenEndedremoves the tile and the sender.contentHint = 'detail'tells the encoder to protect sharp text rather than frame rate. -
Mesh mode asks for 640x360 at 24 frames per second, because every participant encodes one copy per peer, and the SFU and LiveKit modes ask for 1280x720 at 30.
// Local media: camera and microphone, device switching, mute and screen sharing.
export class LocalMedia {
tracks = { audio: null, video: null, screen: null };
stream = new MediaStream(); // microphone + camera, the stream that is shown in the local tile
screenStream = null;
onScreenEnded = () => {};
async start({ audioDeviceId, videoDeviceId, width = 1280, height = 720, frameRate = 30 } = {}) {
const media = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, ...(audioDeviceId && { deviceId: { exact: audioDeviceId } }) },
video: { width: { ideal: width }, height: { ideal: height }, frameRate: { ideal: frameRate },
...(videoDeviceId && { deviceId: { exact: videoDeviceId } }) },
});
this.tracks.audio = media.getAudioTracks()[0];
this.tracks.video = media.getVideoTracks()[0];
media.getTracks().forEach((t) => this.stream.addTrack(t));
}
/** Labels are empty until permission is granted, so call this after start(). */
async devices() {
const all = await navigator.mediaDevices.enumerateDevices();
const pick = (kind) => all.filter((d) => d.kind === kind).map((d, i) => ({ id: d.deviceId, label: d.label || `${kind} ${i + 1}` }));
return { audio: pick('audioinput'), video: pick('videoinput') };
}
/** Opens another device and returns the new track; the caller swaps it into the connections. */
async switchDevice(kind, deviceId) {
const media = await navigator.mediaDevices.getUserMedia({ [kind]: { deviceId: { exact: deviceId } } });
const track = media.getTracks()[0];
const old = this.tracks[kind];
track.enabled = old ? old.enabled : true; // keep the mute state
if (old) { this.stream.removeTrack(old); old.stop(); }
this.stream.addTrack(track);
this.tracks[kind] = track;
return track;
}
/** Must run inside a user gesture (a click handler). */
async startScreen() {
const media = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15, max: 30 } }, audio: false });
const track = media.getVideoTracks()[0];
track.contentHint = 'detail'; // favour sharp text over frame rate
track.onended = () => this.stopScreen(); // the browser's own "Stop sharing" button
this.tracks.screen = track;
this.screenStream = media;
return track;
}
stopScreen() {
if (!this.tracks.screen) return;
this.tracks.screen.onended = null;
this.tracks.screen.stop();
this.tracks.screen = null;
this.screenStream = null;
this.onScreenEnded();
}
stopAll() {
this.stopScreen();
for (const t of this.stream.getTracks()) t.stop();
}
}
Mesh session: perfect negotiation, tracks and data channel
MeshSession creates one Link per remote participant. A link owns an RTCPeerConnection and runs the perfect
negotiation pattern of Signaling and Perfect Negotiation:
onnegotiationneeded sets a local description with the argument-less setLocalDescription(), an incoming offer that
arrives while the link is making an offer or is not stable is a collision, the impolite peer ignores it and the polite
peer rolls back implicitly. Politeness is decided by comparing the two peer ids, so the two sides compute opposite
answers without coordination.
Four details turn the pattern into a working call:
-
The newcomer offers. Creating the first data channel or the first sender triggers
negotiationneeded. If both sides did that when somebody joins, every join would produce a collision. In Huddle only the joining participant calls#prepare()immediately (it creates the data channel and adds the tracks); an existing participant does it right aftersetRemoteDescription(offer)and before answering, which reuses the m-lines of the offer and starts no second negotiation. Perfect negotiation is still needed for what follows: two people can start screen sharing at the same instant, and that simultaneous renegotiation is resolved by the collision rules (see What was verified). -
The data channel is pre-negotiated (
negotiated: true, id: 0on both sides), so nobody has to decide who creates it andonopenfires on both ends. It carries chat and the mute state. -
The screen is announced before it is offered. A receiver cannot tell a camera stream from a screen stream by looking at the track, so the sender first signals
meta {screenStreamId}(the id of theMediaStreamthat carries the screen) and then adds the track. The two messages travel over the same ordered WebSocket, so the meta message is always handled before the offer, andontrackcan comparestream.idwith the announced id.meta {screenStreamId: null}announces the end of sharing before the sender is removed. -
A watchdog restarts stalled links. ICE failures are reported as
failedand handled withrestartIce(), but a link that never leavesneworcheckingreports nothing, so a timer callsrestartIce()if the link is not connected after 8 seconds.
The upload budget is divided among the links (applyBitrate() sets maxBitrate on each video sender to the total divided
by the number of peers) and is recomputed whenever somebody joins or leaves. With end-to-end encryption the transforms
are installed as soon as a transceiver exists, on both its sender and its receiver, and the video codec is restricted to
VP8 because the encrypted frame layout assumes VP8 payload headers.
// Mesh mode: one RTCPeerConnection per remote participant, negotiated with the perfect negotiation pattern.
const MAX_VIDEO_BITRATE = 1_500_000; // total upload budget, shared by every outgoing video stream
class Link {
#pc; #polite; #makingOffer = false; #ignoreOffer = false; #watchdog = null; #prepared = false;
remoteScreenStreamId = null;
senders = {};
constructor(session, peer, initiator) {
this.session = session;
this.peer = peer;
this.#polite = session.peerId < peer.id; // any rule works if both sides compute opposite answers
const pc = (this.#pc = new RTCPeerConnection({
iceServers: session.iceServers,
iceTransportPolicy: session.relayOnly ? 'relay' : 'all',
}));
pc.onicecandidate = ({ candidate }) => { if (candidate) this.signal({ candidate }); };
pc.ontrack = (event) => this.#onTrack(event);
pc.onconnectionstatechange = () => session.on.linkState?.(peer.id, pc.connectionState);
pc.oniceconnectionstatechange = () => {
clearTimeout(this.#watchdog);
if (pc.iceConnectionState === 'failed') pc.restartIce();
// A link that stays disconnected or never connects (an unresolved mDNS name, a blocked path) gets a fresh ICE attempt.
else if (pc.iceConnectionState !== 'connected' && pc.iceConnectionState !== 'completed') this.#armWatchdog();
};
this.#armWatchdog();
pc.onnegotiationneeded = async () => {
try {
this.#makingOffer = true;
await pc.setLocalDescription(); // creates the right description for the current signalling state
this.signal({ description: pc.localDescription });
} catch (err) {
console.error('negotiation failed', err);
} finally {
this.#makingOffer = false;
}
};
// The newcomer offers and the existing participant answers, so a join never produces glare. The answerer adds
// its tracks after it has seen the offer (see handleSignal). Perfect negotiation still covers later changes.
if (initiator) this.#prepare();
}
// Creating the first data channel or the first sender triggers negotiationneeded, so the answerer does both only
// after the offer arrived, and the offer's m-lines are reused.
#prepare() {
this.#prepared = true;
// A pre-negotiated channel (same id on both sides) avoids deciding who creates it.
const channel = (this.channel = this.#pc.createDataChannel('huddle', { negotiated: true, id: 0 }));
channel.binaryType = 'arraybuffer';
channel.onopen = () => this.session.sendStateTo(this);
channel.onmessage = ({ data }) => this.session.receiveData(this.peer.id, data);
this.#addLocalTracks();
}
#armWatchdog() {
clearTimeout(this.#watchdog);
this.#watchdog = setTimeout(() => {
if (!['connected', 'completed'].includes(this.#pc.iceConnectionState)) this.#pc.restartIce(); // fires negotiationneeded
}, 8000);
}
get pc() { return this.#pc; }
signal(data) { return this.session.sig.request('signal', { to: this.peer.id, data }).catch(() => {}); }
#addLocalTracks() {
const { local, e2ee } = this.session;
for (const kind of ['audio', 'video']) {
const track = local.tracks[kind];
if (!track) continue;
this.senders[kind] = this.#pc.addTrack(track, local.stream);
this.#protect(this.senders[kind]);
}
if (e2ee) this.#preferVp8();
if (local.tracks.screen) this.addScreen(local.tracks.screen, local.screenStream);
this.applyBitrate();
}
// Install both transforms as soon as the transceiver exists. Chrome 155 delivers no frames to a receiver transform
// that is assigned in ontrack on a transceiver created by addTrack, so doing it here is not just tidier.
#protect(sender) {
const e2ee = this.session.e2ee;
if (!e2ee) return;
e2ee.protect(sender);
const { receiver } = this.#pc.getTransceivers().find((t) => t.sender === sender);
if (!receiver.transform) e2ee.unprotect(receiver);
}
#preferVp8() {
const tr = this.#pc.getTransceivers().find((t) => t.sender.track?.kind === 'video');
const vp8 = RTCRtpReceiver.getCapabilities('video').codecs.filter((c) => c.mimeType === 'video/VP8');
tr?.setCodecPreferences(vp8); // the encrypted frame layout assumes VP8 headers
}
addScreen(track, stream) {
// Tell the peer which stream is the screen *before* the offer that contains it (same ordered WebSocket).
this.signal({ meta: { screenStreamId: stream.id } });
this.senders.screen = this.#pc.addTrack(track, stream);
this.#protect(this.senders.screen);
this.applyBitrate();
}
removeScreen() {
this.signal({ meta: { screenStreamId: null } });
if (this.senders.screen) this.#pc.removeTrack(this.senders.screen);
delete this.senders.screen;
}
async applyBitrate() {
const links = this.session.links.size || 1;
for (const [name, sender] of Object.entries(this.senders)) {
if (name === 'audio') continue;
const params = sender.getParameters();
if (!params.encodings?.length) continue; // not negotiated yet
params.encodings[0].maxBitrate = Math.floor(MAX_VIDEO_BITRATE / links);
await sender.setParameters(params).catch(() => {});
}
}
#onTrack({ track, streams: [stream], receiver }) {
if (this.session.e2ee && !receiver.transform) this.session.e2ee.unprotect(receiver);
const source = stream && stream.id === this.remoteScreenStreamId ? 'screen' : 'camera';
this.session.on.track?.({ peerId: this.peer.id, source, track });
track.onended = () => this.session.on.trackEnded?.({ peerId: this.peer.id, source, kind: track.kind });
}
// Everything received from the signalling channel for this peer goes through here.
async handleSignal({ description, candidate, meta }) {
const pc = this.#pc;
if (meta) {
this.remoteScreenStreamId = meta.screenStreamId;
if (!meta.screenStreamId) this.session.on.trackEnded?.({ peerId: this.peer.id, source: 'screen', kind: 'video' });
return;
}
try {
if (description) {
const collision = description.type === 'offer' && (this.#makingOffer || pc.signalingState !== 'stable');
this.#ignoreOffer = !this.#polite && collision; // the impolite peer ignores the colliding offer
if (this.#ignoreOffer) return;
await pc.setRemoteDescription(description); // the polite peer rolls back implicitly
if (description.type === 'offer') {
if (!this.#prepared) this.#prepare(); // reuses the m-lines created by the offer
await pc.setLocalDescription();
this.signal({ description: pc.localDescription });
}
} else if (candidate) {
try { await pc.addIceCandidate(candidate); } catch (err) { if (!this.#ignoreOffer) throw err; }
}
} catch (err) {
console.error('signal handling failed', err);
}
}
close() { clearTimeout(this.#watchdog); this.#pc.close(); }
}
export class MeshSession {
mode = 'mesh';
links = new Map();
muted = { audio: false, video: false };
constructor({ sig, local, e2ee, relayOnly, on }) {
Object.assign(this, { sig, local, e2ee, relayOnly, on });
this.iceServers = sig.iceServers;
}
async join(room) {
this.sig.on('peerJoined', ({ peer }) => this.#addLink(peer, false));
this.sig.on('peerLeft', ({ peerId }) => this.#removeLink(peerId));
this.sig.on('signal', ({ from, data }) => this.links.get(from)?.handleSignal(data));
const joined = await this.sig.request('join', { room });
Object.assign(this, { peerId: joined.peerId, uploadToken: joined.uploadToken, room });
for (const peer of joined.peers) this.#addLink(peer, true);
return joined;
}
#addLink(peer, initiator) {
this.links.set(peer.id, new Link(this, peer, initiator));
this.on.peer?.(peer);
this.links.forEach((l) => l.applyBitrate()); // each link gets a smaller share of the upload budget
}
#removeLink(peerId) {
this.links.get(peerId)?.close();
this.links.delete(peerId);
this.on.peerLeft?.(peerId);
this.links.forEach((l) => l.applyBitrate());
}
setMuted(kind, muted) {
this.muted[kind] = muted;
this.local.tracks[kind].enabled = !muted; // a disabled track sends silence or black frames
for (const link of this.links.values()) this.sendStateTo(link);
}
async replaceTrack(kind, track) {
await Promise.all([...this.links.values()].map((l) => l.senders[kind]?.replaceTrack(track)));
}
async startScreen(track, stream) { this.links.forEach((l) => l.addScreen(track, stream)); }
async stopScreen() { this.links.forEach((l) => l.removeScreen()); }
// ----- data channel: chat and mute state -----
async #encode(msg) { return this.e2ee ? this.e2ee.seal(msg) : JSON.stringify(msg); }
sendStateTo(link) {
if (link.channel?.readyState !== 'open') return;
for (const kind of ['audio', 'video']) {
this.#encode({ t: 'state', kind, muted: this.muted[kind] }).then((d) => link.channel.send(d));
}
}
async sendChat(text) {
const data = await this.#encode({ t: 'chat', text, ts: Date.now() });
for (const link of this.links.values()) if (link.channel?.readyState === 'open') link.channel.send(data);
}
async receiveData(peerId, data) {
try {
const msg = this.e2ee ? await this.e2ee.open(data) : JSON.parse(data);
if (msg.t === 'chat') this.on.chat?.({ peerId, text: String(msg.text).slice(0, 2000), ts: msg.ts });
else if (msg.t === 'state') this.on.state?.({ peerId, kind: msg.kind, muted: Boolean(msg.muted) });
} catch { /* malformed or undecryptable message: ignore */ }
}
statsSources() { return [...this.links].map(([id, l]) => ({ id, getStats: () => l.pc.getStats() })); }
iceRestart() { this.links.forEach((l) => l.pc.restartIce()); }
close() { this.links.forEach((l) => l.close()); this.links.clear(); this.sig.close(); }
}
Source: MDN: Perfect negotiation
SFU session: producers and consumers
SfuSession follows the lifecycle in the diagram, which is the mediasoup page’s sequence extended with the Huddle
specifics. The browser never builds an SDP: Device.load() takes the router’s RTP capabilities, transports are created
from the parameters the server returns, and the connect, produce and producedata events of a transport are where the
client asks the server to do its half.
Camera video is published with three simulcast layers of three temporal layers each (L1T3), which gives the SFU the
choices described in Codecs, Simulcast and SVC. A
ResizeObserver on each tile reports its width, and setTileWidth() asks for the matching layer: below 240 px the lowest
layer, below 480 px the middle one, otherwise the highest. The producer keeps encoding all three layers and the SFU
forwards only the selected one to each consumer; a client that wants to stop encoding unused layers can lower the
producer’s maximum spatial layer, and LiveKit does this automatically with dynacast. With end-to-end encryption the
onRtpSender and onRtpReceiver callbacks install the transforms before the first frame goes through.
// SFU mode: one send transport, one receive transport, one producer per local track, one consumer per remote track.
import { Device } from 'mediasoup-client';
const CAMERA_ENCODINGS = [ // three simulcast layers, each with three temporal layers
{ maxBitrate: 150_000, scaleResolutionDownBy: 4, scalabilityMode: 'L1T3' },
{ maxBitrate: 500_000, scaleResolutionDownBy: 2, scalabilityMode: 'L1T3' },
{ maxBitrate: 1_500_000, scalabilityMode: 'L1T3' },
];
export class SfuSession {
mode = 'sfu';
producers = {}; // 'audio' | 'video' | 'screen' -> Producer
consumers = new Map(); // consumerId -> { consumer, peerId, source }
muted = { audio: false, video: false };
constructor({ sig, local, e2ee, on }) {
Object.assign(this, { sig, local, e2ee, on });
}
async join(room) {
const { sig } = this;
sig.on('peerJoined', ({ peer }) => this.on.peer?.(peer));
sig.on('peerLeft', ({ peerId }) => this.#peerLeft(peerId));
sig.on('newProducer', (p) => this.#consume(p));
sig.on('producerClosed', ({ peerId, producerId }) => this.#producerClosed(peerId, producerId));
sig.on('producerPaused', ({ peerId, producerId, paused }) => this.#remotePaused(peerId, producerId, paused));
sig.on('consumerClosed', ({ consumerId }) => this.#dropConsumer(consumerId));
sig.on('activeSpeaker', ({ peerId }) => this.on.speaker?.(peerId));
sig.on('newDataProducer', (d) => this.#consumeData(d));
const joined = await sig.request('join', { room });
Object.assign(this, { peerId: joined.peerId, uploadToken: joined.uploadToken, room });
this.device = await Device.factory();
await this.device.load({ routerRtpCapabilities: joined.routerRtpCapabilities });
this.sendTransport = await this.#transport('send');
this.recvTransport = await this.#transport('recv');
for (const peer of joined.peers) this.on.peer?.(peer);
await this.#publish();
for (const p of joined.existing) await this.#consume(p);
for (const d of joined.existingData) await this.#consumeData(d);
return joined;
}
async #transport(direction) {
const params = await this.sig.request('createTransport');
const transport = direction === 'send' ? this.device.createSendTransport(params) : this.device.createRecvTransport(params);
transport.on('connect', ({ dtlsParameters }, ok, fail) =>
this.sig.request('connectTransport', { transportId: transport.id, dtlsParameters }).then(ok, fail));
if (direction === 'send') {
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) =>
this.sig.request('produce', { transportId: transport.id, kind, rtpParameters, source: appData.source })
.then(({ id }) => ok({ id }), fail));
transport.on('producedata', ({ sctpStreamParameters, label }, ok, fail) =>
this.sig.request('produceData', { transportId: transport.id, sctpStreamParameters, label })
.then(({ id }) => ok({ id }), fail));
}
transport.on('connectionstatechange', async (state) => {
this.on.linkState?.(direction, state);
if (state === 'failed') await this.#restartIce(transport); // network change: new ICE credentials, same DTLS session
});
return transport;
}
async #restartIce(transport) {
const { iceParameters } = await this.sig.request('restartIce', { transportId: transport.id });
await transport.restartIce({ iceParameters });
}
iceRestart() { return Promise.all([this.sendTransport, this.recvTransport].map((t) => this.#restartIce(t))); }
// ----- publishing -----
async #publish() {
const { local, e2ee } = this;
const onRtpSender = e2ee ? (sender) => e2ee.protect(sender) : undefined; // runs before the first frame is sent
const vp8 = e2ee && this.device.rtpCapabilities.codecs.find((c) => c.mimeType.toLowerCase() === 'video/vp8');
this.producers.audio = await this.sendTransport.produce({
track: local.tracks.audio, appData: { source: 'mic' }, onRtpSender,
codecOptions: { opusDtx: true, opusFec: true },
});
this.producers.video = await this.sendTransport.produce({
track: local.tracks.video, appData: { source: 'camera' }, encodings: CAMERA_ENCODINGS, onRtpSender, ...(vp8 && { codec: vp8 }),
});
this.dataProducer = await this.sendTransport.produceData({ label: 'huddle', ordered: true });
if (local.tracks.screen) await this.startScreen(local.tracks.screen);
}
async startScreen(track) {
const { e2ee } = this;
this.producers.screen = await this.sendTransport.produce({
track, appData: { source: 'screen' }, encodings: [{ maxBitrate: 2_500_000 }],
onRtpSender: e2ee ? (sender) => e2ee.protect(sender) : undefined,
...(e2ee && { codec: this.device.rtpCapabilities.codecs.find((c) => c.mimeType.toLowerCase() === 'video/vp8') }),
});
}
async stopScreen() {
const producer = this.producers.screen;
if (!producer) return;
delete this.producers.screen;
producer.close();
await this.sig.request('closeProducer', { producerId: producer.id });
}
async setMuted(kind, muted) {
this.muted[kind] = muted;
const producer = this.producers[kind];
muted ? producer.pause() : producer.resume(); // stops or restarts the sender locally
await this.sig.request(muted ? 'pauseProducer' : 'resumeProducer', { producerId: producer.id });
}
replaceTrack(kind, track) { return this.producers[kind].replaceTrack({ track }); }
// ----- consuming -----
async #consume({ producerId, peerId, kind, source, paused }) {
const info = await this.sig.request('consume', {
transportId: this.recvTransport.id, producerId, rtpCapabilities: this.device.recvRtpCapabilities,
});
const consumer = await this.recvTransport.consume({
id: info.id, producerId, kind: info.kind, rtpParameters: info.rtpParameters,
onRtpReceiver: this.e2ee ? (receiver) => this.e2ee.unprotect(receiver) : undefined,
});
this.consumers.set(consumer.id, { consumer, peerId, source, producerId });
this.on.track?.({ peerId, source: source === 'screen' ? 'screen' : 'camera', track: consumer.track });
if (paused) this.on.state?.({ peerId, kind: kind === 'audio' ? 'audio' : 'video', muted: true });
await this.sig.request('resumeConsumer', { consumerId: consumer.id }); // the server created it paused
}
#dropConsumer(consumerId) {
const entry = this.consumers.get(consumerId);
if (!entry) return;
this.consumers.delete(consumerId);
entry.consumer.close();
this.on.trackEnded?.({ peerId: entry.peerId, source: entry.source === 'screen' ? 'screen' : 'camera', kind: entry.consumer.kind });
}
#producerClosed(peerId, producerId) {
for (const [id, e] of this.consumers) if (e.producerId === producerId) this.#dropConsumer(id);
}
#remotePaused(peerId, producerId, paused) {
const entry = [...this.consumers.values()].find((e) => e.producerId === producerId);
if (entry) this.on.state?.({ peerId, kind: entry.consumer.kind === 'audio' ? 'audio' : 'video', muted: paused });
}
#peerLeft(peerId) {
for (const [id, e] of this.consumers) {
if (e.peerId !== peerId) continue;
this.consumers.delete(id);
e.consumer.close();
}
this.on.peerLeft?.(peerId);
}
/** Ask for the simulcast layer that matches the size of the tile (called from a ResizeObserver). */
setTileWidth(peerId, width) {
const spatialLayer = width < 240 ? 0 : width < 480 ? 1 : 2;
for (const { consumer, peerId: owner, source } of this.consumers.values()) {
if (owner === peerId && consumer.kind === 'video' && source !== 'screen') {
this.sig.request('setPreferredLayers', { consumerId: consumer.id, spatialLayer, temporalLayer: 2 }).catch(() => {});
}
}
}
// ----- data (chat) -----
async #consumeData({ dataProducerId, peerId }) {
const info = await this.sig.request('consumeData', { transportId: this.recvTransport.id, dataProducerId });
const dc = await this.recvTransport.consumeData({
id: info.id, dataProducerId, sctpStreamParameters: info.sctpStreamParameters, label: info.label,
});
dc.binaryType = 'arraybuffer';
dc.on('message', (data) => this.receiveData(peerId, data));
}
async sendChat(text) {
const msg = { t: 'chat', text, ts: Date.now() };
this.dataProducer.send(this.e2ee ? await this.e2ee.seal(msg) : JSON.stringify(msg));
}
async receiveData(peerId, data) {
try {
const msg = this.e2ee ? await this.e2ee.open(data) : JSON.parse(data);
if (msg.t === 'chat') this.on.chat?.({ peerId, text: String(msg.text).slice(0, 2000), ts: msg.ts });
} catch { /* ignore malformed messages */ }
}
statsSources() {
return [this.sendTransport, this.recvTransport].filter(Boolean).map((t, i) => ({ id: i ? 'recv' : 'send', getStats: () => t.getStats() }));
}
close() {
this.sendTransport?.close();
this.recvTransport?.close();
this.sig.close();
}
}
Source: mediasoup-client: API
Active speaker
In SFU mode the server tells everybody (the observer’s dominantspeaker event). In mesh mode no server sees the audio, so
each browser measures it: one AnalyserNode per participant, read every 100 ms, with a fast attack and a slow release
(about a second) so the highlight does not flicker between words, and a 1.5x margin before the speaker changes. Two
details come from running it. A time-domain reading of 2048 samples covers only 43 ms, so a periodic beep from a fake
device is missed unless the level is smoothed. And Chromium delivers audio to a Web Audio source only when the remote
stream is also attached to a media element, so the analyser is built on the stream that the video element plays.
// Mesh mode has no server in the media path, so the browser measures audio levels itself.
export class SpeakerDetector {
#ctx = new AudioContext();
#inputs = new Map(); // peer id -> { analyser, data, level }
#current = null;
#timer = null;
constructor(onChange) { this.onChange = onChange; }
/** Pass the stream that a media element plays: Chromium feeds Web Audio from a remote stream only then. */
add(peerId, stream) {
if (this.#inputs.has(peerId) || !stream.getAudioTracks().length) return;
const analyser = this.#ctx.createAnalyser();
analyser.fftSize = 2048; // about 43 ms of samples per reading
this.#ctx.createMediaStreamSource(stream).connect(analyser); // not connected to the speakers: the <video> plays the sound
this.#inputs.set(peerId, { analyser, data: new Float32Array(analyser.fftSize), level: 0 });
this.#timer ??= setInterval(() => this.#tick(), 100);
}
remove(peerId) {
this.#inputs.delete(peerId);
if (this.#current === peerId) this.#current = null;
if (!this.#inputs.size) { clearInterval(this.#timer); this.#timer = null; }
}
#tick() {
let best = null, bestLevel = 0.02; // below this smoothed RMS nobody is speaking
for (const [id, input] of this.#inputs) {
input.analyser.getFloatTimeDomainData(input.data);
const rms = Math.sqrt(input.data.reduce((sum, v) => sum + v * v, 0) / input.data.length);
input.level = Math.max(rms, input.level * 0.9); // fast attack, slow release (about one second)
if (input.level > bestLevel) { best = id; bestLevel = input.level; }
}
// Hysteresis: keep the current speaker unless somebody is clearly louder.
const currentLevel = this.#inputs.get(this.#current)?.level ?? 0;
if (best && best !== this.#current && bestLevel > currentLevel * 1.5) {
this.#current = best;
this.onChange(best);
}
}
close() { clearInterval(this.#timer); this.#ctx.close(); }
}
Source: MDN: AnalyserNode
Quality indicator
QualityMonitor samples getStats() every two seconds on every source it is given (every link in mesh mode, the two
transports in SFU mode) and turns the counters into rates by subtracting the previous sample, which is how the standard
statistics are meant to be used (see Stats, Quality and Bandwidth).
The score uses the round-trip time of the nominated candidate pair, the loss percentage and jitter of the inbound streams
and the qualityLimitationReason of outbound video, and the thresholds are deliberately simple: loss above 8%, round
trip above 400 ms or jitter above 80 ms is poor, loss above 2%, 200 ms, 30 ms or a bandwidth limit is fair. A bandwidth
limit is the normal state of a call during its first seconds, while the bandwidth estimate ramps up, so a fresh call is
usually fair and a loaded laptop with four video encoders can report poor because of jitter alone.
// Call-quality indicator: samples getStats() and turns the deltas into good, fair or poor.
export class QualityMonitor {
#prev = new Map();
#timer = null;
constructor(getSources, onSample, intervalMs = 2000) {
this.getSources = getSources;
this.onSample = onSample;
this.intervalMs = intervalMs;
}
start() { this.#timer = setInterval(() => this.sample().catch(() => {}), this.intervalMs); }
stop() { clearInterval(this.#timer); }
async sample() {
const out = { rttMs: null, inKbps: 0, outKbps: 0, lossPct: 0, jitterMs: 0, limitation: null };
let packets = 0, lost = 0;
for (const { id, getStats } of this.getSources()) {
const report = await getStats();
const transport = [...report.values()].find((s) => s.type === 'transport');
for (const s of report.values()) {
if (s.type === 'candidate-pair' && s.state === 'succeeded' && (s.nominated || s.id === transport?.selectedCandidatePairId)
&& s.currentRoundTripTime != null) {
out.rttMs = Math.max(out.rttMs ?? 0, s.currentRoundTripTime * 1000);
}
const key = `${id}:${s.id}`;
const prev = this.#prev.get(key);
this.#prev.set(key, s);
if (!prev || s.timestamp <= prev.timestamp) continue;
const seconds = (s.timestamp - prev.timestamp) / 1000;
if (s.type === 'inbound-rtp') {
out.inKbps += ((s.bytesReceived - prev.bytesReceived) * 8) / seconds / 1000;
packets += s.packetsReceived - prev.packetsReceived;
lost += Math.max(0, s.packetsLost - prev.packetsLost);
out.jitterMs = Math.max(out.jitterMs, (s.jitter ?? 0) * 1000);
} else if (s.type === 'outbound-rtp') {
out.outKbps += ((s.bytesSent - prev.bytesSent) * 8) / seconds / 1000;
if (s.kind === 'video' && s.qualityLimitationReason && s.qualityLimitationReason !== 'none') out.limitation = s.qualityLimitationReason;
}
}
}
out.lossPct = packets + lost > 0 ? (100 * lost) / (packets + lost) : 0;
out.score = QualityMonitor.score(out);
this.onSample(out);
return out;
}
static score({ rttMs, lossPct, jitterMs, limitation }) {
if (rttMs == null) return 'unknown';
if (lossPct > 8 || rttMs > 400 || jitterMs > 80) return 'poor';
if (lossPct > 2 || rttMs > 200 || jitterMs > 30 || limitation === 'bandwidth') return 'fair';
return 'good';
}
}
Recording
Recording in the browser is a MediaRecorder on the local stream. It produces a WebM chunk every
two seconds and LocalRecorder uploads each chunk with fetch(), strictly one after another, so the server can append to
a file and the recording survives a crashed tab with all but the last two seconds. This records your own camera and
microphone only. A recording of the whole meeting needs a server-side component (a mediasoup PlainTransport feeding
FFmpeg, or LiveKit Egress as in the LiveKit variant below); the options are compared in
Recording and Media Processing. When end-to-end
encryption is on, the record button is disabled, because the browser holds the plaintext and an upload would defeat the
purpose.
// Records the local camera and microphone with MediaRecorder and uploads a chunk every two seconds.
export class LocalRecorder {
#recorder = null;
#queue = Promise.resolve();
state = 'idle';
constructor({ stream, room, peerId, token, onState }) {
Object.assign(this, { stream, room, peerId, token, onState });
}
start() {
const mimeType = ['video/webm;codecs=vp8,opus', 'video/webm'].find((t) => MediaRecorder.isTypeSupported(t));
if (!mimeType) throw new Error('MediaRecorder cannot produce WebM here');
const recording = Date.now().toString().padStart(10, '0');
const url = `/api/recordings?room=${this.room}&peer=${this.peerId}&rec=${recording}`;
this.#recorder = new MediaRecorder(this.stream, { mimeType, videoBitsPerSecond: 1_500_000 });
this.#recorder.ondataavailable = ({ data }) => {
if (!data.size) return;
// Upload chunks strictly one after another so the file is appended in order.
this.#queue = this.#queue.then(() => fetch(url, { method: 'POST', body: data, headers: { authorization: `Bearer ${this.token}` } }))
.then((res) => { if (!res.ok) throw new Error(`upload failed (${res.status})`); })
.catch((err) => this.#set(`error: ${err.message}`));
};
this.#recorder.onstop = () => this.#queue.then(() => this.#set('idle'));
this.#recorder.start(2000);
this.#set('recording');
}
stop() { this.#recorder?.state !== 'inactive' && this.#recorder?.stop(); }
#set(state) { this.state = state; this.onState?.(state); }
}
End-to-end encryption
DTLS-SRTP encrypts media between the browser and the SFU, but the SFU decrypts it to forward it, so a compromised or
curious server can see everything. End-to-end encryption adds a second layer that the server cannot remove, using an
encoded transform: the browser hands every encoded audio and video frame to a worker, which encrypts the payload
with AES-GCM before the frame is packetised and decrypts it after reassembly. Chrome’s older createEncodedStreams() is a
legacy API; RTCRtpScriptTransform is the standard one used here. Huddle’s version is intentionally small, and
End-to-End Encryption explains what a complete design adds (SFrame as
specified in RFC 9605, per-sender keys, rotation when somebody leaves, and verification of identities).
-
The key is derived in the browser with PBKDF2 from a passphrase that the participants share out of band, for example in the URL fragment (
#key=...), which is never sent to the server. A shared passphrase is a demonstration, not a key management system. -
Each frame keeps a few bytes in the clear (10 on a VP8 key frame, 3 on a delta frame, 1 for Opus) so that the SFU can still read the codec payload header it needs for layer selection and key frame detection. Everything else is ciphertext followed by the 12-byte IV, and the clear bytes are authenticated as additional data.
-
A frame that fails authentication (wrong passphrase, tampering) is dropped. Chat messages on the data channel are sealed with the same key.
// Optional end-to-end encryption with a passphrase shared out of band (see the End-to-End Encryption page for real key exchange).
export async function deriveKey(passphrase, room) {
const material = await crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', iterations: 310_000, salt: new TextEncoder().encode(`huddle:${room}`) },
material, { name: 'AES-GCM', length: 128 }, false, ['encrypt', 'decrypt'],
);
}
export class E2ee {
constructor(key) { this.key = key; }
#worker(operation) {
const worker = new Worker('/e2ee-worker.js');
worker.postMessage({ type: 'key', key: this.key });
return new RTCRtpScriptTransform(worker, { operation });
}
protect(sender) { sender.transform = this.#worker('encrypt'); }
unprotect(receiver) { receiver.transform = this.#worker('decrypt'); }
// Data-channel messages: [12-byte IV][AES-GCM ciphertext].
async seal(value) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const data = new TextEncoder().encode(JSON.stringify(value));
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, this.key, data));
const out = new Uint8Array(12 + ct.length);
out.set(iv);
out.set(ct, 12);
return out.buffer;
}
async open(buffer) {
const bytes = new Uint8Array(buffer);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: bytes.subarray(0, 12) }, this.key, bytes.subarray(12));
return JSON.parse(new TextDecoder().decode(plain));
}
}
Source: MDN: RTCRtpSender.transform
// Runs one RTCRtpScriptTransform. Frame layout on the wire: [clear header][AES-GCM ciphertext + tag][12-byte IV].
let key = null;
let counter = 0n;
const prefix = crypto.getRandomValues(new Uint8Array(4)); // distinguishes the senders that share one key
onmessage = (event) => { if (event.data.type === 'key') key = event.data.key; };
// Bytes left in the clear so the SFU can still read the codec payload header and detect key frames.
// VP8: 10 bytes on key frames and 3 on delta frames. Opus (audio frames have no type): 1 byte.
const clearBytes = (frame) => (frame.type === 'key' ? 10 : frame.type === 'delta' ? 3 : 1);
async function encryptFrame(frame, controller) {
if (!key) return; // drop instead of leaking plaintext while no key is installed
const data = new Uint8Array(frame.data);
const n = Math.min(clearBytes(frame), data.length);
const iv = new Uint8Array(12);
iv.set(prefix);
new DataView(iv.buffer).setBigUint64(4, counter++);
const body = new Uint8Array(await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv, additionalData: data.subarray(0, n) }, key, data.subarray(n)));
const out = new Uint8Array(n + body.length + 12);
out.set(data.subarray(0, n));
out.set(body, n);
out.set(iv, n + body.length);
frame.data = out.buffer;
controller.enqueue(frame);
}
async function decryptFrame(frame, controller) {
if (!key) return;
const data = new Uint8Array(frame.data);
const n = clearBytes(frame);
if (data.length < n + 16 + 12) return; // too short to be one of ours
const iv = data.subarray(data.length - 12);
try {
const plain = new Uint8Array(await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv, additionalData: data.subarray(0, n) }, key, data.subarray(n, data.length - 12)));
const out = new Uint8Array(n + plain.length);
out.set(data.subarray(0, n));
out.set(plain, n);
frame.data = out.buffer;
controller.enqueue(frame);
} catch {
// Authentication failed: wrong passphrase or tampered frame. Drop it.
}
}
onrtctransform = ({ transformer }) => {
const transform = transformer.options.operation === 'encrypt' ? encryptFrame : decryptFrame;
transformer.readable.pipeThrough(new TransformStream({ transform })).pipeTo(transformer.writable);
};
Stage, chat and indicators
Stage keeps one tile per participant and source (peerId:camera or peerId:screen), each with its own MediaStream
so that a tile with only a screen track works the same as a camera tile. A screen tile spans the whole grid row. Remote
text is always inserted with textContent, never with innerHTML, because chat messages are attacker-controlled input.
// DOM helpers: the tile grid, the chat log and the quality badge.
const $ = (id) => document.getElementById(id);
export class Stage {
tiles = new Map(); // key -> { el, video, stream, name }
onTileResize = () => {};
#observer = new ResizeObserver((entries) => {
for (const { target, contentRect } of entries) this.onTileResize(target.dataset.peer, contentRect.width);
});
/** key is "<peerId>:camera" or "<peerId>:screen". */
tile(key, { name, local = false }) {
let tile = this.tiles.get(key);
if (tile) return tile;
const [peerId, source] = key.split(':');
const el = document.createElement('figure');
el.className = `tile ${source}`;
el.dataset.peer = peerId;
const video = document.createElement('video');
video.autoplay = true;
video.playsInline = true;
video.muted = local; // never play your own microphone back
video.setAttribute('aria-label', `${name} ${source === 'screen' ? 'screen' : 'camera'}`);
const caption = document.createElement('figcaption');
caption.textContent = source === 'screen' ? `${name} (screen)` : name;
const badge = document.createElement('span');
badge.className = 'badge';
badge.hidden = true;
caption.append(badge);
el.append(video, caption);
$('stage').append(el);
this.#observer.observe(el);
tile = { el, video, badge, stream: new MediaStream(), name };
video.srcObject = tile.stream;
this.tiles.set(key, tile);
return tile;
}
addTrack(key, track, opts) {
const tile = this.tile(key, opts);
tile.stream.addTrack(track);
tile.video.play().catch(() => {});
}
removeTrack(key, kind) {
const tile = this.tiles.get(key);
if (!tile) return;
for (const t of tile.stream.getTracks()) if (t.kind === kind) tile.stream.removeTrack(t);
if (!tile.stream.getTracks().length) this.removeTile(key);
}
removeTile(key) {
const tile = this.tiles.get(key);
if (!tile) return;
this.#observer.unobserve(tile.el);
tile.el.remove();
this.tiles.delete(key);
}
removePeer(peerId) {
for (const key of [...this.tiles.keys()]) if (key.startsWith(`${peerId}:`)) this.removeTile(key);
}
setMutedBadge(peerId, audioMuted, videoMuted) {
const tile = this.tiles.get(`${peerId}:camera`);
if (!tile) return;
const parts = [audioMuted && 'mic off', videoMuted && 'camera off'].filter(Boolean);
tile.badge.textContent = parts.join(', ');
tile.badge.hidden = parts.length === 0;
}
setSpeaker(peerId) {
for (const [key, { el }] of this.tiles) el.classList.toggle('speaking', key === `${peerId}:camera`);
}
}
export function appendChat(who, text, mine) {
const li = document.createElement('li');
li.className = mine ? 'mine' : '';
const strong = document.createElement('strong');
strong.textContent = `${who}: `;
li.append(strong, document.createTextNode(text)); // textContent only: never innerHTML for remote text
$('log').append(li);
li.scrollIntoView({ block: 'end' });
}
export function fillSelect(select, devices, current) {
select.replaceChildren(...devices.map((d) => {
const option = new Option(d.label, d.id, false, d.id === current);
return option;
}));
}
export function showQuality(sample) {
const el = $('quality');
el.dataset.score = sample.score;
const parts = [`quality: ${sample.score === 'unknown' ? 'measuring' : sample.score}`];
if (sample.rttMs != null) parts.push(`rtt ${Math.round(sample.rttMs)} ms`, `loss ${sample.lossPct.toFixed(1)}%`, `jitter ${Math.round(sample.jitterMs)} ms`);
if (sample.inKbps != null) parts.push(`down ${Math.round(sample.inKbps)} kbps`, `up ${Math.round(sample.outKbps)} kbps`);
if (sample.limitation) parts.push(`limited by ${sample.limitation}`);
el.textContent = parts.join(' | ');
}
export { $ };
Source: MDN: ResizeObserver
The entry point
main.js is the glue. Its handlers object is the only code that touches the DOM on behalf of a session, join()
captures media, opens the signalling socket (LiveKit has its own), derives the E2EE key and creates the session of the
chosen mode, and the remaining functions implement the controls: mute, device switching, screen sharing, chat, recording
and leaving. The local tile is keyed me, so notifications that carry the participant’s own id (the SFU announces the
active speaker to everybody, including the speaker) are mapped to it.
// Huddle browser entry point: wires the form, the session (mesh or SFU), the controls and the indicators.
import { openSignaling } from './signaling.js';
import { LocalMedia } from './media.js';
import { MeshSession } from './mesh.js';
import { SfuSession } from './sfu.js';
import { QualityMonitor } from './quality.js';
import { SpeakerDetector } from './speaker.js';
import { LocalRecorder } from './recorder.js';
import { E2ee, deriveKey } from './e2ee.js';
import { Stage, $, appendChat, fillSelect, showQuality } from './ui.js';
const stage = new Stage();
const names = new Map(); // peer id -> display name
const muteState = new Map(); // peer id -> { audio, video }
let session, local, recorder, monitor, speakers;
const params = new URLSearchParams(location.search);
$('name').value = params.get('name') ?? '';
$('room').value = params.get('room') ?? 'standup';
const { modes } = await (await fetch('/api/config')).json();
const LABELS = { mesh: 'Mesh (peer to peer, up to 4)', sfu: 'SFU (mediasoup)', livekit: 'LiveKit' };
$('mode').replaceChildren(...modes.map((m) => new Option(LABELS[m], m)));
$('mode').value = modes.includes(params.get('mode')) ? params.get('mode') : 'mesh';
$('relay').checked = params.get('relay') === '1';
$('passphrase').value = decodeURIComponent(location.hash.replace(/^#key=/, '')); // the fragment never reaches the server
const nameOf = (id) => names.get(id) ?? id;
const setStatus = (text) => { $('status').textContent = text; };
function renderMute(peerId) {
const s = muteState.get(peerId) ?? {};
stage.setMutedBadge(peerId, s.audio, s.video);
}
const handlers = {
peer: (peer) => names.set(peer.id, peer.name),
peerLeft: (peerId) => {
stage.removePeer(peerId);
speakers?.remove(peerId);
setStatus(`${nameOf(peerId)} left`);
},
track: ({ peerId, source, track }) => {
stage.addTrack(`${peerId}:${source}`, track, { name: nameOf(peerId) });
if (source === 'camera') {
renderMute(peerId);
// Pass the stream that the <video> plays: Chromium feeds Web Audio from a remote stream only then.
if (track.kind === 'audio') speakers?.add(peerId, stage.tiles.get(`${peerId}:camera`).stream);
}
},
trackEnded: ({ peerId, source, kind }) => stage.removeTrack(`${peerId}:${source}`, kind),
state: ({ peerId, kind, muted }) => {
muteState.set(peerId, { ...muteState.get(peerId), [kind]: muted });
renderMute(peerId);
},
speaker: (peerId) => stage.setSpeaker(peerId === session?.peerId ? 'me' : peerId), // the local tile is keyed 'me'
chat: ({ peerId, text }) => appendChat(nameOf(peerId), text, false),
linkState: (who, state) => setStatus(`${who}: ${state}`),
quality: showQuality,
};
async function join() {
const name = $('name').value.trim();
const room = $('room').value.trim();
const mode = $('mode').value;
$('join').disabled = true;
try {
local = new LocalMedia();
// In a mesh every participant encodes one copy per peer, so ask for a smaller picture.
await local.start(mode === 'mesh' ? { width: 640, height: 360, frameRate: 24 } : {});
wireScreenEnd();
// LiveKit brings its own signalling; the other modes use this application's WebSocket.
const sig = mode === 'livekit' ? null : await openSignaling(mode, name);
sig?.on('close', () => setStatus('disconnected from the signalling server: reload to rejoin'));
const passphrase = $('passphrase').value;
const e2ee = passphrase ? new E2ee(await deriveKey(passphrase, room)) : null;
const options = { sig, local, e2ee, on: handlers };
if (mode === 'sfu') session = new SfuSession(options);
else if (mode === 'livekit') {
const { LiveKitSession } = await import('./session-livekit.js'); // loaded on demand: livekit-client is large
session = new LiveKitSession({ ...options, user: name, passphrase });
} else session = new MeshSession({ ...options, relayOnly: $('relay').checked });
if (mode === 'mesh') {
speakers = new SpeakerDetector((id) => stage.setSpeaker(id));
speakers.add('me', local.stream);
}
stage.onTileResize = (peerId, width) => session.setTileWidth?.(peerId, width);
names.set('me', name);
stage.addTrack('me:camera', local.tracks.video, { name: `${name} (you)`, local: true });
stage.addTrack('me:camera', local.tracks.audio, { name: `${name} (you)`, local: true });
const joined = await session.join(room);
names.set(joined.peerId, name);
$('lobby').hidden = true;
$('call').hidden = false;
$('recordButton').disabled = Boolean(e2ee); // a recording would hold plaintext, which defeats E2EE
setStatus(`in ${room} (${mode}${e2ee ? ', end-to-end encrypted' : ''})`);
const devices = await local.devices();
fillSelect($('mic'), devices.audio, local.tracks.audio.getSettings().deviceId);
fillSelect($('cam'), devices.video, local.tracks.video.getSettings().deviceId);
if (session.statsSources) {
monitor = new QualityMonitor(() => session.statsSources(), showQuality);
monitor.start();
}
Object.assign(window, { huddle: { session, local, stage, handlers } }); // handles for debugging and tests
} catch (err) {
console.error(err);
setStatus(`could not join: ${err.message}`);
$('join').disabled = false;
}
}
$('join').onclick = join;
for (const kind of ['audio', 'video']) {
const button = $(kind === 'audio' ? 'muteMic' : 'muteCam');
button.onclick = async () => {
const muted = button.getAttribute('aria-pressed') !== 'true';
button.setAttribute('aria-pressed', String(muted));
await session.setMuted(kind, muted);
muteState.set('me', { ...muteState.get('me'), [kind]: muted });
renderMute('me');
};
}
async function switchDevice(kind, deviceId) {
const track = await local.switchDevice(kind === 'audio' ? 'audio' : 'video', deviceId);
await session.replaceTrack(kind, track);
stage.removeTrack('me:camera', track.kind);
stage.addTrack('me:camera', track, { name: names.get('me'), local: true });
}
$('mic').onchange = (e) => switchDevice('audio', e.target.value);
$('cam').onchange = (e) => switchDevice('video', e.target.value);
$('share').onclick = async () => {
if (local.tracks.screen) return local.stopScreen();
try {
const track = await local.startScreen(); // getDisplayMedia needs this click as its user gesture
stage.addTrack('me:screen', track, { name: names.get('me'), local: true });
await session.startScreen(track, local.screenStream);
$('share').setAttribute('aria-pressed', 'true');
} catch (err) {
setStatus(`screen sharing failed: ${err.name}`); // NotAllowedError when the picker is dismissed
}
};
// Fires for the "Stop sharing" button of the browser as well as for our own button.
function wireScreenEnd() {
local.onScreenEnded = async () => {
stage.removeTile('me:screen');
$('share').setAttribute('aria-pressed', 'false');
await session.stopScreen();
};
}
$('chatForm').onsubmit = async (event) => {
event.preventDefault();
const text = $('chatText').value.trim();
if (!text) return;
$('chatText').value = '';
appendChat('you', text, true);
await session.sendChat(text);
};
$('recordButton').onclick = async () => {
if (session.startRecording) { // LiveKit: Egress records on the server
const on = $('recordButton').getAttribute('aria-pressed') !== 'true';
await (on ? session.startRecording() : session.stopRecording());
$('recordButton').setAttribute('aria-pressed', String(on));
$('recordState').textContent = on ? 'recording' : 'idle';
return;
}
if (recorder?.state === 'recording') return recorder.stop();
recorder = new LocalRecorder({
stream: local.stream, room: session.room, peerId: session.peerId, token: session.uploadToken,
onState: (state) => {
$('recordButton').setAttribute('aria-pressed', String(state === 'recording'));
$('recordState').textContent = state;
},
});
recorder.start();
};
$('leave').onclick = () => {
recorder?.stop();
monitor?.stop();
speakers?.close();
local.stopAll();
session.close();
location.reload();
};
window.addEventListener('pagehide', () => session?.close());
Source: MDN: MediaStream
Docker Compose
Two Compose files describe the stack. compose.yaml runs the Huddle server (with mediasoup inside) and coturn, and
compose.livekit.yaml adds LiveKit, Redis and Egress. Everything configurable is a variable with a default, so a
developer starts the stack with one command and a deployment overrides the variables.
Image
The build has two stages. The first compiles the mediasoup worker if no prebuilt binary runs on the base image (which is
why it installs pip and a compiler; without pip the install fails with "No module named pip"), bundles the browser code,
and removes development dependencies. The second stage copies only the result into a slim image that runs as the
unprivileged node user. The recordings directory is created and owned by that user in the image, because a named volume
copies the ownership of the directory it mounts over; without that line the first recording upload fails with 500
(permission denied).
FROM node:24-bookworm AS build
# mediasoup compiles its C++ worker when no prebuilt binary runs on the base image, which needs pip and a compiler.
RUN apt-get update && apt-get install -y --no-install-recommends python3-pip build-essential \
&& rm -rf /var/lib/apt/lists/*
ENV PIP_BREAK_SYSTEM_PACKAGES=1
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY src ./src
COPY public ./public
RUN npm run build && npm prune --omit=dev
FROM node:24-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/public ./public
COPY package.json ./
COPY server ./server
# The named volume copies this ownership on first use, so the unprivileged user can write recordings.
RUN mkdir -p /data/recordings && chown node:node /data/recordings
USER node
CMD ["node", "server/main.mjs"]
Source: Docker docs: Multi-stage builds
Services
# Start with: HOST_IP=<your LAN IP> docker compose up --build
# Stop with: docker compose down -v
name: huddle
services:
huddle:
build: .
environment:
HTTP_PORT: "3000"
RTC_PORT: ${RTC_PORT:-40000}
ANNOUNCED_ADDRESS: ${HOST_IP:?set HOST_IP to the LAN address of this machine}
ALLOWED_ORIGINS: http://localhost:${HTTP_PORT:-3000}
TURN_HOST: ${HOST_IP}
TURN_PORT: ${TURN_PORT:-3478}
TURN_SHARED_SECRET: ${TURN_SHARED_SECRET:-example-shared-secret-change-me}
HUDDLE_TICKET_SECRET: ${HUDDLE_TICKET_SECRET:-change-me-ticket-secret}
RECORDINGS_DIR: /data/recordings
volumes:
- recordings:/data/recordings
ports:
- "${HTTP_PORT:-3000}:3000"
- "${RTC_PORT:-40000}:${RTC_PORT:-40000}/udp"
- "${RTC_PORT:-40000}:${RTC_PORT:-40000}/tcp"
restart: unless-stopped
coturn:
image: coturn/coturn:4.18.0
command:
- "-c"
- "/etc/coturn/turnserver.conf"
- "--listening-port=${TURN_PORT:-3478}"
- "--min-port=${RELAY_MIN:-49160}"
- "--max-port=${RELAY_MAX:-49200}"
- "--static-auth-secret=${TURN_SHARED_SECRET:-example-shared-secret-change-me}"
volumes:
- ./turnserver.conf:/etc/coturn/turnserver.conf:ro
ports:
- "${TURN_PORT:-3478}:${TURN_PORT:-3478}/udp"
- "${TURN_PORT:-3478}:${TURN_PORT:-3478}/tcp"
- "${RELAY_MIN:-49160}-${RELAY_MAX:-49200}:${RELAY_MIN:-49160}-${RELAY_MAX:-49200}/udp"
restart: unless-stopped
volumes:
recordings:
Three details deserve attention.
-
Port numbers are the same inside and outside. mediasoup, coturn and LiveKit put the port number into ICE candidates, so mapping container port 40000 to a different host port would advertise a port that does not exist from the outside. The Compose file therefore publishes
${RTC_PORT}:${RTC_PORT}and passes the same variable to the process. -
The TURN relay range is a published range. coturn allocates a relayed UDP port per allocation from
--min-portto--max-port, and every port in that range must be published. The default range of 41 ports allows at most 41 simultaneous relayed allocations, which is generous for a lab and the number to size for production (see Scaling WebRTC and TURN). -
ALLOWED_ORIGINSmust match the address in the address bar. The WebSocket handshake is refused with403if the origin differs, for example if you open the page through127.0.0.1while the allow-list sayslocalhost.
# coturn 4.18.0 for Huddle. Ports and the shared secret come from the command line (see compose.yaml).
use-auth-secret
realm=huddle.example.test
fingerprint
no-tls
log-file=stdout
simple-log
# A relay must never reach internal services (SSRF protection).
no-multicast-peers
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=100.64.0.0-100.127.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
# 172.16.0.0/12 is NOT denied in this lab: the relayed addresses of two browsers live in the Docker network.
# In production add: denied-peer-ip=172.16.0.0-172.31.255.255 and external-ip=<public IP>/<private IP>
user-quota=12
total-quota=1200
max-bps=3000000
stale-nonce=600
The comments in the file are the checklist for production: deny the private ranges (here 172.16.0.0/12 is left open on
purpose, because both browsers in the lab are relayed through the same coturn and their relay addresses live in the
Docker network), set external-ip when the server sits behind NAT, and enable TLS on port 5349 or 443 so clients behind
strict firewalls still have a path (see ICE, STUN and TURN).
Source: coturn wiki: configuration
Running it
Every value has a default in the Compose files, and a .env file next to them overrides it without touching the YAML:
# .env (optional)
HOST_IP=192.168.1.20
HTTP_PORT=3000
RTC_PORT=40000
TURN_PORT=3478
RELAY_MIN=49160
RELAY_MAX=49200
LIVEKIT_PORT=7880
LIVEKIT_TCP_PORT=7881
LIVEKIT_RTC_PORT=7882
npm install
export HOST_IP=192.168.1.20 # the LAN address of this machine
docker compose up --build
# open the same room in two or more windows (use localhost, not the LAN address)
# http://localhost:3000/?name=alice&room=standup&mode=mesh
# http://localhost:3000/?name=bob&room=standup&mode=mesh
# switch to the SFU: replace mode=mesh with mode=sfu
# end-to-end encryption: share a passphrase in the fragment
# http://localhost:3000/?name=alice&room=standup&mode=sfu#key=correct-horse-battery
# force the relay to see TURN at work (mesh only): add &relay=1
# LiveKit variant
docker compose -f compose.yaml -f compose.livekit.yaml up --build
# http://localhost:3000/?name=alice&room=standup&mode=livekit
docker compose down -v
Source: Docker docs: docker compose up
Driving it with headless Chrome
A call is hard to test by hand, but Chrome can fake the camera, the microphone and the permission prompt:
--use-fake-device-for-media-stream provides a moving test pattern and a periodic beep (the device-count=3 variant adds
a second and third camera, which the device switch needs), --use-fake-ui-for-media-stream accepts the permission prompt
without a dialog, --autoplay-policy=no-user-gesture-required lets remote audio start without a click, and
--auto-select-desktop-capture-source answers the screen picker. getUserMedia() works because the page comes from
localhost. Each client runs in its own browser context, so cookies and permissions are separate, and the script clicks
the join button because that click is the user gesture that autoplay and getDisplayMedia() require. A tile is counted as
playing when its video element reports a non-zero videoWidth, which only happens after a frame has been decoded.
// Drives N headless Chrome clients through a Huddle call: join, media, chat, mute, screen sharing, leave.
// Usage: node test/drive.mjs <mesh|sfu|livekit> <participants 2-4>
import puppeteer from 'puppeteer-core';
const [mode = 'mesh', count = '3'] = process.argv.slice(2);
const n = Number(count);
const base = process.env.BASE_URL ?? 'http://localhost:3000';
const chrome = process.env.CHROME ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const room = `drive-${Date.now() % 100000}`;
async function until(check, what, ms = 30000) {
const end = Date.now() + ms;
while (Date.now() < end) { try { if (await check()) return; } catch { /* page still loading */ } await sleep(300); }
throw new Error(`timed out waiting for ${what}`);
}
const browser = await puppeteer.launch({
executablePath: chrome,
headless: true,
args: ['--use-fake-device-for-media-stream=device-count=3', '--use-fake-ui-for-media-stream',
'--autoplay-policy=no-user-gesture-required', '--auto-select-desktop-capture-source=Entire screen'],
});
// A tile counts as playing when its <video> has decoded frames (videoWidth is 0 before the first one).
const playing = (page) => page.evaluate(() => [...document.querySelectorAll('.tile video')].filter((v) => v.videoWidth > 0).length);
const pages = [];
try {
for (const name of ['alice', 'bob', 'carol', 'dave'].slice(0, n)) {
const page = await (await browser.createBrowserContext()).newPage();
await page.goto(`${base}/?name=${name}&room=${room}&mode=${mode}`);
await page.click('#join'); // the click is the user gesture that autoplay and screen capture need
pages.push(page);
await sleep(700);
}
for (const page of pages) await until(async () => (await playing(page)) === n, `${n} playing tiles`);
console.log(`ok ${n} participants see each other (${mode})`);
await pages[0].type('#chatText', 'hello from alice');
await pages[0].keyboard.press('Enter');
for (const page of pages.slice(1)) await until(() => page.$eval('#log', (e) => e.textContent.includes('alice: hello')), 'chat');
console.log('ok chat reached everybody');
await pages[0].click('#muteMic');
await until(() => pages[1].evaluate(() => [...document.querySelectorAll('.badge')].some((b) => b.textContent.includes('mic off'))), 'mute badge');
console.log('ok mute state reached the others');
await pages[1].click('#share');
await until(() => pages[0].evaluate(() => [...document.querySelectorAll('.tile.screen video')].some((v) => v.videoWidth > 0)), 'screen share');
console.log('ok screen share received');
await pages[1].click('#share');
await until(() => pages[0].evaluate(() => !document.querySelector('.tile.screen')), 'screen tile removed');
console.log('ok screen share stopped');
await pages[n - 1].click('#leave');
await until(async () => (await pages[0].evaluate(() => document.querySelectorAll('.tile').length)) === n - 1, 'leave');
console.log('ok participant left, tile removed');
} finally {
await browser.close();
}
Source: Puppeteer: launch()
Install the driver with npm install --save-dev puppeteer-core and run node test/drive.mjs sfu 4. Chrome does not reach
a TURN server on localhost, so the relay checks need the LAN address in HOST_IP, as described above. Real
network conditions (loss, delay, a restrictive firewall) need real devices or a shaped network; the fake devices only
prove the wiring.
What happens during a call
This section follows one participant through the features of the table at the top, naming the code that runs.
Joining. The page asks POST /api/session for a ticket and ICE servers, opens the WebSocket, and sends join. In mesh
mode the answer lists the people already in the room, and the newcomer creates one link per person and makes the first
offer on each. In SFU mode the answer carries the router capabilities, the transports are created, the participant
produces microphone, camera and a data producer, and consumes what already exists. A newcomer is announced to the room
by peerJoined, so the others can name the new tile.
Mute. In mesh mode setMuted() flips track.enabled, which makes the sender transmit silence or black frames at
almost no cost, and sends {t: 'state', kind, muted} on every data channel (and again when a channel opens, so a late
joiner learns the current state). In SFU mode producer.pause() stops the sender and the server tells the room with
producerPaused. In both cases the receiving tile shows a badge. Muting is cooperative: a malicious client can ignore it,
so enforcement of "mute everybody" belongs on the server (in LiveKit, RoomServiceClient.mutePublishedTrack()).
Screen sharing. The share button calls getDisplayMedia() inside its click handler. The track is added as a second video
sender (mesh) or a third producer (SFU, source: 'screen'), and on the receiving side the tile’s key ends in :screen, so
it spans the row. Stopping from either button removes the sender or closes the producer (the server tells the room with
producerClosed), and the tile disappears when the last track of the stream ends.
Switching a device. The select box calls LocalMedia.switchDevice(), which opens the new device, and then
replaceTrack() swaps the track in every sender without renegotiating. The previous device is released. A devicechange
listener (a pluggable headset) would call the same function.
Active speaker. The tile of the speaker gets a green border. In the SFU mode this is a server decision shared by all clients, which keeps everybody’s view consistent, and in the mesh mode each browser decides for itself.
Chat. Messages are JSON on the data channel (mesh) or data producers (SFU), limited to 2000 characters on receipt and
rendered with textContent. Chat that must survive a rejoin, history, receipts and moderation are a different
application; Chatterbox in Building a Chat App is that application.
Quality. Every two seconds the indicator shows the score, round trip, loss, jitter, download and upload rates and the limitation reason.
Reconnecting. A failed transport is repaired with an ICE restart: restartIce() on a peer connection in mesh mode,
the restartIce request followed by transport.restartIce() in SFU mode. Both were exercised in the test run and media
kept flowing. If the WebSocket itself is lost, the page tells the user to reload, because the server holds the room
state in memory; a production client would reconnect and rejoin, following
Connection Lifecycle, Heartbeats and Reconnection.
Leaving. The leave button stops the tracks, closes the session and reloads the page. Closing the tab triggers pagehide,
which closes the session too, and a crashed browser is detected by the missed pong.
The LiveKit variant as a diff
LiveKit replaces the part of Huddle that is hardest to write and to operate, the media server and its signalling. The Huddle server keeps only two jobs, minting an access token and starting a recording, and the browser keeps its whole interface. The change touches four existing files and adds three, which is the point of having one session interface.
| Concern | Self-hosted (mesh and SFU) | LiveKit |
|---|---|---|
Server signalling |
|
None: the browser connects to LiveKit with a token. |
Authentication |
Single-use ticket for the WebSocket, ephemeral TURN credentials. |
A JWT access token with a video grant (room, publish, subscribe, data, record) that expires in 10 minutes. |
TURN |
coturn. |
Built into the LiveKit server (disabled here, and enabled in production), plus TURN/TLS and UDP mux. |
Simulcast, layer choice, speaker detection |
Your code and mediasoup configuration. |
Built in: |
Recording |
Browser |
Egress: a composite of the room ( |
End-to-end encryption |
Your transform and key management. |
|
Scaling |
You shard rooms over workers (see Scaling WebRTC and TURN). |
LiveKit routes rooms over nodes through Redis. |
Server. server/livekit.mjs is a new file. The token endpoint creates an AccessToken, adds a grant that allows
joining one room, publishing, subscribing and sending data, and returns the server URL with the signed token. The
recording endpoints verify the same token, require the roomRecord grant and start or stop a room composite Egress.
Granting roomRecord to everybody is a demonstration shortcut; a real application grants it to hosts only.
// LiveKit backend: this server no longer relays signalling, it only mints tokens and drives Egress recordings.
import { AccessToken, TokenVerifier, EgressClient, EncodedFileOutput, EncodedFileType } from 'livekit-server-sdk';
import { config } from './config.mjs';
import { validRoom } from './rpc.mjs';
const { url, publicUrl, apiKey, apiSecret } = config.livekit;
const egress = new EgressClient(url, apiKey, apiSecret);
const verifier = new TokenVerifier(apiKey, apiSecret);
const USER = /^[a-z0-9-]{1,32}$/;
const recordings = new Map(); // room -> egress id
function send(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(body && JSON.stringify(body));
return true;
}
/** Returns true when it handled the request. */
export async function handle(req, res, u) {
if (u.pathname === '/api/livekit/token' && req.method === 'POST') {
// Demo login: a real application authenticates the request and takes `user` from the session.
const user = u.searchParams.get('user') ?? '';
const room = u.searchParams.get('room') ?? '';
if (!USER.test(user) || !validRoom(room)) return send(res, 400, { error: 'bad request' });
const at = new AccessToken(apiKey, apiSecret, { identity: user, name: user, ttl: '10m' });
at.addGrant({ room, roomJoin: true, canPublish: true, canSubscribe: true, canPublishData: true, roomRecord: true });
return send(res, 200, { url: publicUrl, token: await at.toJwt() });
}
if (u.pathname === '/api/livekit/recording' && ['POST', 'DELETE'].includes(req.method)) {
// The participant proves who they are with the LiveKit token they already hold.
let grants;
try { grants = await verifier.verify((req.headers.authorization ?? '').replace(/^Bearer /, '')); } catch { return send(res, 401); }
const room = grants.video?.room;
if (!grants.video?.roomRecord || !room) return send(res, 403);
if (req.method === 'POST') {
if (recordings.has(room)) return send(res, 409, { error: 'already recording' });
const file = new EncodedFileOutput({ fileType: EncodedFileType.MP4, filepath: '/out/{room_name}-{time}.mp4' });
const info = await egress.startRoomCompositeEgress(room, { file }, { layout: 'grid' });
recordings.set(room, info.egressId);
return send(res, 200, { egressId: info.egressId });
}
const egressId = recordings.get(room);
if (!egressId) return send(res, 404, { error: 'not recording' });
recordings.delete(room);
await egress.stopEgress(egressId);
return send(res, 200, { stopped: egressId });
}
return false;
}
main.mjs and config.mjs change in a few lines: a list of enabled modes, lazy imports for the two optional backends, and
two routes.
--- a/server/config.mjs
+++ b/server/config.mjs
@@
- maxMeshRoom: 4,
+ // Which backends this process serves: mesh needs nothing, sfu starts a mediasoup worker, livekit talks to a LiveKit server.
+ modes: (env.HUDDLE_MODES ?? 'mesh,sfu').split(','),
+ livekit: {
+ url: env.LIVEKIT_URL ?? 'http://localhost:7880', // used by this server (REST and Egress)
+ publicUrl: env.LIVEKIT_PUBLIC_URL ?? 'ws://localhost:7880', // used by browsers
+ apiKey: env.LIVEKIT_API_KEY ?? 'huddle-dev-key',
+ apiSecret: env.LIVEKIT_API_SECRET ?? 'change-me-change-me-change-me-32chars',
+ },
+ maxMeshRoom: 4,
--- a/server/main.mjs
+++ b/server/main.mjs
@@
-import * as sfu from './sfu.mjs';
+// Backends are loaded on demand: importing sfu.mjs starts a mediasoup worker.
+const sfu = config.modes.includes('sfu') ? await import('./sfu.mjs') : null;
+const livekit = config.modes.includes('livekit') ? await import('./livekit.mjs') : null;
@@
+ if (livekit && url.pathname.startsWith('/api/livekit/') && await livekit.handle(req, res, url)) return;
@@
-const modes = { '/ws/mesh': mesh, '/ws/sfu': sfu };
+const modes = { '/ws/mesh': mesh, ...(sfu && { '/ws/sfu': sfu }) };
Browser. src/session-livekit.js is the third session. The session methods map onto the SDK as follows: join() fetches the
token, creates a Room (with an encryption option when a passphrase is set), registers listeners, connects, and
publishes the tracks that LocalMedia already captured with publishTrack(), so device switching and screen capture
code stays untouched. Subscribed tracks are handed to the same track handler as in the other modes, with
participant.identity as the peer id. Because the interface reuses the page’s own tiles, adaptiveStream is switched off and
setTileWidth() reports sizes with setVideoDimensions(); with LiveKit’s own track.attach() the SDK would observe the
element sizes by itself. Chat uses publishData() with a topic, and the encryption option makes the SDK encrypt data
messages as well as media, so the session needs no sealing helper of its own. The quality score comes from
ConnectionQualityChanged instead of getStats().
// LiveKit mode: the same session interface as MeshSession and SfuSession, implemented with livekit-client.
import { Room, RoomEvent, Track, ConnectionQuality, ExternalE2EEKeyProvider } from 'livekit-client';
const QUALITY = {
[ConnectionQuality.Excellent]: 'good', [ConnectionQuality.Good]: 'good',
[ConnectionQuality.Poor]: 'fair', [ConnectionQuality.Lost]: 'poor', [ConnectionQuality.Unknown]: 'unknown',
};
const sourceOf = (pub) => (pub.source === Track.Source.ScreenShare ? 'screen' : 'camera');
const kindOf = (pub) => (pub.kind === Track.Kind.Audio ? 'audio' : 'video');
export class LiveKitSession {
mode = 'livekit';
pubs = {}; // 'audio' | 'video' | 'screen' -> LocalTrackPublication
constructor({ user, local, passphrase, sig, on }) {
Object.assign(this, { user, local, passphrase, on });
this.sig = sig; // unused: LiveKit brings its own signalling
}
async join(roomName) {
const res = await fetch(`/api/livekit/token?user=${encodeURIComponent(this.user)}&room=${roomName}`, { method: 'POST' });
if (!res.ok) throw new Error(`token request failed (${res.status})`);
const { url, token } = await res.json();
this.token = token;
const options = { adaptiveStream: false, dynacast: true }; // tile sizes are reported by hand, see setTileWidth()
if (this.passphrase) {
this.keyProvider = new ExternalE2EEKeyProvider();
await this.keyProvider.setKey(this.passphrase);
options.encryption = { keyProvider: this.keyProvider, worker: new Worker('/livekit-client.e2ee.worker.js') };
}
const room = (this.room = new Room(options));
this.#listen(room);
if (this.passphrase) await room.setE2EEEnabled(true);
await room.connect(url, token);
const { audio, video } = this.local.tracks;
this.pubs.audio = await room.localParticipant.publishTrack(audio, { source: Track.Source.Microphone });
this.pubs.video = await room.localParticipant.publishTrack(video, { source: Track.Source.Camera, simulcast: true });
const peers = [...room.remoteParticipants.values()].map((p) => ({ id: p.identity, name: p.identity }));
return { peerId: room.localParticipant.identity, peers, uploadToken: token };
}
#listen(room) {
const { on } = this;
room
.on(RoomEvent.ParticipantConnected, (p) => on.peer?.({ id: p.identity, name: p.identity }))
.on(RoomEvent.ParticipantDisconnected, (p) => on.peerLeft?.(p.identity))
.on(RoomEvent.TrackSubscribed, (track, pub, p) => {
on.peer?.({ id: p.identity, name: p.identity });
on.track?.({ peerId: p.identity, source: sourceOf(pub), track: track.mediaStreamTrack });
if (pub.isMuted && sourceOf(pub) === 'camera') on.state?.({ peerId: p.identity, kind: kindOf(pub), muted: true });
})
.on(RoomEvent.TrackUnsubscribed, (track, pub, p) => on.trackEnded?.({ peerId: p.identity, source: sourceOf(pub), kind: kindOf(pub) }))
.on(RoomEvent.TrackMuted, (pub, p) => this.#muted(pub, p, true))
.on(RoomEvent.TrackUnmuted, (pub, p) => this.#muted(pub, p, false))
.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
const first = speakers[0]?.identity;
if (first) on.speaker?.(first === room.localParticipant.identity ? 'me' : first);
})
.on(RoomEvent.ConnectionQualityChanged, (quality, p) => {
if (p.isLocal) on.quality?.({ score: QUALITY[quality] ?? 'unknown' });
})
.on(RoomEvent.DataReceived, (payload, p, kind, topic) => {
if (topic === 'chat' && p) this.receiveData(p.identity, payload);
})
.on(RoomEvent.ConnectionStateChanged, (state) => on.linkState?.('livekit', state))
.on(RoomEvent.EncryptionError, (err) => console.warn('encryption error', err.message));
}
#muted(pub, participant, muted) {
if (participant.isLocal || sourceOf(pub) !== 'camera') return;
this.on.state?.({ peerId: participant.identity, kind: kindOf(pub), muted });
}
async setMuted(kind, muted) {
await (muted ? this.pubs[kind].mute() : this.pubs[kind].unmute());
}
replaceTrack(kind, track) { return this.pubs[kind].track.replaceTrack(track, true); } // true: we manage the track ourselves
async startScreen(track) {
this.pubs.screen = await this.room.localParticipant.publishTrack(track, { source: Track.Source.ScreenShare, simulcast: false });
}
async stopScreen() {
if (!this.pubs.screen) return;
const { track } = this.pubs.screen;
delete this.pubs.screen;
await this.room.localParticipant.unpublishTrack(track, false); // our LocalMedia already stopped the capture
}
/** With adaptiveStream off the application tells LiveKit how big each video is shown. */
setTileWidth(peerId, width) {
const participant = this.room?.remoteParticipants.get(peerId);
for (const pub of participant?.videoTrackPublications.values() ?? []) {
if (pub.source === Track.Source.Camera) pub.setVideoDimensions({ width: Math.round(width), height: Math.round((width * 9) / 16) });
}
}
async sendChat(text) {
const bytes = new TextEncoder().encode(JSON.stringify({ t: 'chat', text, ts: Date.now() }));
// With the encryption option set, the SDK encrypts data messages end to end as well.
await this.room.localParticipant.publishData(bytes, { reliable: true, topic: 'chat' });
}
async receiveData(peerId, payload) {
try {
const msg = JSON.parse(new TextDecoder().decode(payload));
if (msg.t === 'chat') this.on.chat?.({ peerId, text: String(msg.text).slice(0, 2000), ts: msg.ts });
} catch { /* ignore malformed messages */ }
}
// Recording runs on the server: LiveKit Egress composes the room, nothing is uploaded from the browser.
async startRecording() { return this.#recording('POST'); }
async stopRecording() { return this.#recording('DELETE'); }
async #recording(method) {
const res = await fetch('/api/livekit/recording', { method, headers: { authorization: `Bearer ${this.token}` } });
if (!res.ok) throw new Error(`recording request failed (${res.status})`);
}
iceRestart() { /* LiveKit restarts ICE by itself */ }
close() { this.room.disconnect(); }
}
main.js gains a mode that imports this file lazily, skips Huddle’s own signalling socket, skips the QualityMonitor when the
session has no statsSources(), and routes the record button to the session when it can record on the server:
--- a/src/main.js
+++ b/src/main.js
@@ join()
- const sig = await openSignaling(mode, name);
+ // LiveKit brings its own signalling; the other modes use this application's WebSocket.
+ const sig = mode === 'livekit' ? null : await openSignaling(mode, name);
@@
- session = mode === 'sfu' ? new SfuSession(options) : new MeshSession({ ...options, relayOnly: $('relay').checked });
+ if (mode === 'sfu') session = new SfuSession(options);
+ else if (mode === 'livekit') {
+ const { LiveKitSession } = await import('./session-livekit.js'); // loaded on demand: livekit-client is large
+ session = new LiveKitSession({ ...options, user: name, passphrase });
+ } else session = new MeshSession({ ...options, relayOnly: $('relay').checked });
@@
- monitor = new QualityMonitor(() => session.statsSources(), showQuality);
- monitor.start();
+ if (session.statsSources) {
+ monitor = new QualityMonitor(() => session.statsSources(), showQuality);
+ monitor.start();
+ }
@@ record button
+ if (session.startRecording) { // LiveKit: Egress records on the server
+ const on = $('recordButton').getAttribute('aria-pressed') !== 'true';
+ await (on ? session.startRecording() : session.stopRecording());
+ $('recordButton').setAttribute('aria-pressed', String(on));
+ $('recordState').textContent = on ? 'recording' : 'idle';
+ return;
+ }
Source: LiveKit docs: Egress
Compose. compose.livekit.yaml adds the three services and enables the mode. The LiveKit configuration is passed inline in the
LIVEKIT_CONFIG variable, so there is no separate file, and the single udp_port multiplexes all media on one UDP port, so
the firewall needs three ports (HTTP and WebSocket, ICE over TCP, ICE over UDP). Egress runs a headless Chrome to compose
the room, which is why the container needs the SYS_ADMIN capability.
# Adds a LiveKit server, Redis and Egress, and switches the LiveKit mode on in the Huddle server.
# Start with: HOST_IP=<your LAN IP> docker compose -f compose.yaml -f compose.livekit.yaml up --build
services:
huddle:
environment:
HUDDLE_MODES: mesh,sfu,livekit
LIVEKIT_URL: http://livekit:${LIVEKIT_PORT:-7880}
LIVEKIT_PUBLIC_URL: ws://localhost:${LIVEKIT_PORT:-7880}
LIVEKIT_API_KEY: huddle-dev-key
LIVEKIT_API_SECRET: change-me-change-me-change-me-32chars
redis:
image: redis:8.8.3-alpine
livekit:
image: livekit/livekit-server:v1.13.10
command: ["--node-ip", "${HOST_IP}"] # the address that goes into ICE candidates
environment:
LIVEKIT_CONFIG: |
port: ${LIVEKIT_PORT:-7880}
redis:
address: redis:6379
rtc:
tcp_port: ${LIVEKIT_TCP_PORT:-7881}
udp_port: ${LIVEKIT_RTC_PORT:-7882} # one UDP port for all media
use_external_ip: false
keys:
huddle-dev-key: change-me-change-me-change-me-32chars
room:
auto_create: true
empty_timeout: 300
turn:
enabled: false
depends_on: [redis]
ports:
- "${LIVEKIT_PORT:-7880}:${LIVEKIT_PORT:-7880}"
- "${LIVEKIT_TCP_PORT:-7881}:${LIVEKIT_TCP_PORT:-7881}"
- "${LIVEKIT_RTC_PORT:-7882}:${LIVEKIT_RTC_PORT:-7882}/udp"
egress:
image: livekit/egress:v1.15.0
cap_add: [SYS_ADMIN] # the room compositor runs headless Chrome
environment:
EGRESS_CONFIG_BODY: |
api_key: huddle-dev-key
api_secret: change-me-change-me-change-me-32chars
ws_url: ws://livekit:${LIVEKIT_PORT:-7880}
redis:
address: redis:6379
health_port: 8081
volumes:
- ./livekit-recordings:/out
depends_on: [livekit]
LiveKit is the shorter path to production for most teams, and building on mediasoup is the better path when the media server must be embedded in your own process or customised deeply. The two are compared in Videoconference Architecture, the server and SDK in LiveKit, and the Jitsi alternative in Janus and Jitsi.
What was verified
The Compose project was started with the host ports remapped through the .env file, and the checks below were run with
two to four headless Chrome 155 instances (fake camera, microphone and permission prompt, three fake cameras for the
device switch) against it. Every row passed in the last run; where a number is shown it is an observation, not a limit.
| Check | Result |
|---|---|
Mesh mode with four participants |
All four pages played the three remote videos and their own. The quality indicator reported |
Mesh mode with relay forced ( |
Three participants connected, and |
SFU mode with four participants |
All pages played all videos. The score was |
Simulcast layer selection |
For a tile 580 px wide the received frame size grew from 320x180 to 640x360 and, after roughly 40 seconds of bandwidth estimation, to 1280x720. |
LiveKit mode with three participants |
All checks of the table passed, including the Egress recording (an MP4 of about 1.3 MB for a ten second call). |
Chat, mute, screen share, camera switch, leave |
Passed in all three modes. Muting showed the badge on the other pages, screen sharing arrived at 800 px width and its tile was removed on stop, and after a camera switch all tiles kept playing. |
Active speaker |
A tile was highlighted in all three modes. The fake microphone beeps on every client at the same time, so which tile wins is not meaningful, only that the signal arrives. |
Glare |
Two participants started screen sharing at the same instant. Both screens arrived and both connections ended |
ICE restart |
After |
Recording |
A five second recording produced a WebM of about 180 to 390 kB on the server, depending on the number of simultaneous links. |
End-to-end encryption |
Two participants with the same passphrase saw each other, a third with a different passphrase decoded no remote frame and received no readable chat, in mesh, SFU and LiveKit modes. The record button was disabled. |
Security checks |
A WebSocket handshake with a foreign |
Common errors and pitfalls
| Symptom | Cause | Fix |
|---|---|---|
The page cannot capture ("NotAllowedError" or no |
Not a secure context, or the permission was denied, or the page is in an iframe without |
Use |
Media connects with |
The server announces a loopback or container address. |
Set |
WebSocket handshake fails with 403 |
The page origin is not in |
Add the exact origin (scheme, host and port). |
WebSocket handshake fails with 401 |
The ticket expired, was used twice, or the server restarted with a different |
Fetch a new ticket for every connection attempt, never reuse one. |
Join fails with |
Mesh mode is limited to four participants. |
Use the SFU mode; the limit is deliberate. |
One mesh link stays in ICE state |
Both sides added tracks at the same moment and the rollback of the colliding offer left ICE gathering without candidates (observed in Chrome 155 with four clients, roughly in every other join). |
Let the joining participant make the first offer and the existing participant answer, create the data channel and
add the tracks only after the offer, and keep a watchdog that calls |
With end-to-end encryption on, remote video stays black in mesh mode, and the worker never receives a frame |
The receiver transform was assigned in |
Assign the sender and receiver transforms as soon as the transceiver exists, which is what |
Active speaker never highlights in mesh mode |
The analyser reads silence, because a remote stream feeds Web Audio only while it is attached to a media element, or the sample is too short to see a short beep. |
Build the analyser on the stream of the playing |
Recording upload fails with 500 in the container |
The recordings volume is owned by root, and the process runs as |
Create the directory and |
The image build fails with "No module named pip" |
mediasoup compiles its worker when no prebuilt binary runs on the base image. |
Install |
The screen picker is dismissed with no effect |
|
Call it directly in the click handler and show a message on rejection. |
Remote audio is silent until a click |
Autoplay policy blocks media playback without user activation. |
Start the call from a click (the join button), which also unlocks the |
Security
-
Authenticate before upgrading. The ticket and the
Origincheck run inside the HTTP upgrade handler, so an unauthenticated or cross-site client never obtains a WebSocket. Cookies are not used for the WebSocket, which removes the cross-site request forgery risk that cookies bring, but the origin check stays because other designs (cookie-based sessions) need it. See Authentication and Authorization and Security. -
Validate everything the client sends. Room names and user names are matched against strict patterns, the payload size of a WebSocket message is capped at 64 kB, handlers are looked up with
hasOwn, binary frames are refused, and chat text is length-limited and written withtextContent. -
The signalling server sees metadata, the TURN server sees traffic. DTLS-SRTP protects media on every hop, but the SFU terminates it, so only the optional encoded-transform encryption keeps media private from the server. The shared passphrase of this build is not a key management design.
-
TURN is an open relay by default. Credentials expire, the private network ranges are denied as relay peers, quotas limit one user, and the shared secret never reaches the browser. Rotate it like any other secret.
-
Tokens are minimal. The LiveKit token allows one room, expires in ten minutes, and the recording grant is a demo shortcut. Keep the API secret on the server.
-
Recording needs consent and a policy. Tell participants when a recording starts (the example shows a state label, a production application also shows it to everyone in the room), and store recordings with the retention you promised.
-
Content security policy. The page uses one module script and one worker per transform from its own origin, so
script-src 'self'andworker-src 'self'work, withconnect-srcallowing the WebSocket origin.
Scaling and production checklist
-
Put TLS in front of the server and TURN, publish TURN/TLS on port 443 as a firewall fallback, and set
external-ip. -
Run one mediasoup worker per CPU core and distribute rooms across workers and hosts, or move to LiveKit’s multi-node mode; see Scaling WebRTC and TURN.
-
Keep room state outside the process (Redis) if the signalling server is replicated, and route a room to one media node.
-
Add metrics and logs from the media server and the clients'
getStats()samples, as in Observability, and load-test with real browsers or a SFU load tool, as in Load Testing and Capacity Planning. -
Test on phones, where capture constraints, background throttling and battery change behaviour; see Mobile and Native Clients.
-
Decide how you broadcast a call to thousands of viewers (WHIP and WHEP, see WHIP, WHEP and Live Streaming).
Version and specification notes
-
JSEP is RFC 9429, which obsoletes RFC 8829, and BUNDLE is RFC 9143, which obsoletes RFC 8843. The perfect negotiation pattern in this build relies on the argument-less
setLocalDescription()and implicit rollback of the current WebRTC 1.0 Recommendation. -
mDNS host candidates and the TURN REST API credentials are widely implemented, but both are expired or never published Internet-Drafts. Transport-wide congestion control, which drives the bandwidth estimates seen in the quality indicator, is also a draft; RFC 8888 is the published congestion feedback format.
-
RTCRtpScriptTransformis the standard way to run encoded transforms in a worker, and Chrome’screateEncodedStreams()is the legacy API it replaces. SFrame is RFC 9605, which this build does not implement; it stands in for it with a simple AES-GCM frame format. -
getDisplayMedia()requires a user gesture and a secure context, andgetUserMedia()device labels are empty before the first permission grant. adapter.js is not needed with current browsers. -
Later mediasoup 3.x releases added the
WebRtcServer, which shares one port among all transports, andmediasoup-client3 offers theonRtpSenderandonRtpReceivercallbacks that this build uses to install encoded transforms.