Skip to main content

Client: connect

connect() is what your front end calls. Give it the ticket your backend issued; it joins the room through the ticket's gates, captures media when you ask for it, uses the desktop gateway automatically inside the Electron helper, and follows ticket rotations after kicks.

call.js
import { connect } from 'freehop';

const session = await connect(ticket, { media: { audio: true, video: true } });

session.on('peer', ({ id }) => tileFor(id));
session.on('track', ({ peer, track }) =>
session.attach(track, tileFor(peer).querySelector(track.kind === 'video' ? 'video' : 'audio')));
session.on('path', ({ peer, kind, via }) => {
tileFor(peer).dataset.path = via ? `${kind} via ${via.slice(0, 6)}` : kind;
});
session.on('peer-left', ({ id }) => tileFor(id).remove());

reportPeerIdToBackend(session.id); // lets your backend name this peer in a kick

This is browser code: bundle the package import with your frontend build tool and call it from your Join control. Serve the page over HTTPS (localhost works for development). The browser client has no runtime dependencies; it needs WebRTC, WebSocket and Web Crypto. tileFor and reportPeerIdToBackend are helpers supplied by your application.

TypeScript consumers get inferred session and event types. See the TypeScript guide for the typed release and setup.

connect(ticket, options)​

ticket is a ticket object or the string from encodeTicket(). An invalid ticket throws a TypeError.

OptionDefaultMeaning
medianone{ audio, video } to capture the microphone and camera now, or a MediaStream you captured yourself. Without it, you join receive-only and can turn devices on later.
desktopGatewaywindow.freehopGatewayThe desktop gateway bridge exposed by the Electron preload. Pass null to ignore it.
gatewaynonePublic gateway metadata plus a credentialsFor(tag, peer) broker, or the gateway object itself in privileged Node code. Never expose a signing key to the page.
stunnoneApplication-approved STUN URLs, overriding ticket.stun. Gate-supplied URLs are ignored.
forwardtrueWhether this participant may forward media for pairs that cannot connect.
adaptiveVideofalseOpt into per-peer video quality adjustment when sustained congestion or processing pressure is measured.
classifyNatfalseLearn this endpoint's NAT behaviour (eim, sequential, random) from the candidates the browser gathers, share it with peers and report it as stats().nat. Needs at least two stun: servers.
portPredictionfalseBefore reporting unreachable, try the next ports of a peer whose NAT allocates them in order (implies classifyNat). Random NATs are never predicted.
turnticket.turnYour own TURN servers [{ urls, username, credential }], tried last before unreachable; null keeps them off. Their bandwidth is yours. Gates and peers can never supply them.
lognone(event, details) => void for diagnostics.
timing, limitsbuilt-inOverrides for the protocol's timers and limits. Leave them alone unless you test the protocol itself.

Media is captured with echo cancellation, noise suppression and automatic gain control for audio, and at 640×360 (up to 24 fps ideal) for video. Each link sends audio at up to 32 kbit/s and video at up to 300 kbit/s. Rooms are a full mesh with an initial target of 2 to 8 participants, not a benchmarked capacity limit. See room limits.

The session​

connect() resolves with a session object:

MemberWhat it does
idThis peer's id: 22 base64url characters, fresh for every join (stable across reconnects and ticket rotations within the session). If your backend keeps its own member ids, correlate them by announcing session.id to your backend after connect — a roster must speak these ids, not control-channel nonces.
on(event, handler)Subscribes to an event and returns an unsubscribe function. off(event, handler) also works.
setMicrophone(enabled)Turns the microphone on (capturing it on first use) or mutes it. Muting disables the track without renegotiation.
setCamera(enabled)Turns the camera on, or stops it and frees the device.
setAdaptiveVideo(enabled)Enables or disables automatic per-link video quality changes at runtime. Audio is unchanged.
attach(track, element)Plays a remote track in an <audio> or <video> element (sets srcObject, autoplay, inline playback).
setPeerMuted(peerId, muted)Silences one member locally: their current and future audio tracks stop rendering (video unchanged). Works before the peer sends audio; they cannot tell they are muted. levels() keeps reporting their speaking level. State clears when the peer leaves for good, not on a temporary disconnect (gone).
levels()Resolves to { [peerId]: 0..1 }: the current speaking level of each origin peer, from inbound audio statistics.
update(ticket, { dropped })Applies an unexpired newer ticket with a new secret for the same app and room. Closes old links, revokes the old desktop gateway epoch, adopts the new ticket's gates, STUN servers and TURN servers (unless connect() was given turn), and reconnects approved members. A failed gateway revocation never blocks the rotation; it is retried on leave(). Resolves to false for an invalid update.
switchDevice(kind, deviceId)Uses another microphone ('audio') or camera ('video'), by deviceId from navigator.mediaDevices.enumerateDevices(), or null for the default. A running track is replaced on every link without renegotiation and keeps its mute state; otherwise the choice applies to the next setMicrophone(true) or setCamera(true). Initial choices can be passed as connect(ticket, { devices: { audio, video } }).
send(data, { to })Sends application data (any JSON value, at most 4096 characters) to one member, or to every member without to. It is sealed like signalling and travels over the peers' own data channel once they are linked. Receivers get a message event { from, data } and accept at most 20 messages per second from one sender. Treat the data as untrusted input. Resolves to the number of members it was sent to.
refresh(ticket)Applies a reissued ticket for the same epoch and secret with a later expiry, for example fresh gate tokens before a long call outlives them. The new tokens are used at the next gate (re)connect. Resolves to false for an invalid refresh.
disconnectPeer(peerId)Disconnects a peer locally and revokes its local gateway credentials. Does not revoke membership or exclude aliases.
kick()Throws migration guidance. Use backend authority.kick() and distribute replacement tickets to revoke membership.
drop(peerId)Disconnects a peer locally, without touching a gateway.
ticket()The current ticket.
stats()Resolves to a snapshot of gates, links, paths, media counters and bridges (see below).
setTurn(servers)Replaces the application TURN servers, for example with fresh short-lived credentials, or removes them with null. Links already using the relay pick them up at their next ICE restart. refresh(ticket) does this for you when the reissued ticket carries turn.
leave()Says goodbye to every member, closes all links and gates, stops the devices it captured, and releases the room on this participant's desktop gateway.

Mute a member​

setPeerMuted(peerId, muted) silences one member locally. It applies to the tracks they are already sending and to every track they send later, so you can mute somebody before they speak:

session.setPeerMuted(peerId, true); // their audio stops rendering for you
session.setPeerMuted(peerId, false); // back to normal

Mute is one-way and audio-only: media still arrives and counts against bandwidth, levels() keeps reporting their speaking level, and nothing is signalled to the muted peer. The state clears when the peer leaves for good and survives a temporary disconnect (gone), because they may come back with the same id.

Events​

EventPayloadWhen
peer{ id }A member authenticated with its first sealed envelope.
peer-left{ id, reason }reason is bye (it left), dropped (local removal), rekey (an old-epoch link closed) or gone (absent from every gate for 8 s without a connected link; it may come back).
message{ from, data }Application data a member sent with send(). Untrusted input: render it as text.
track{ peer, via, track, stream }A remote track. peer is the origin. via is the forwarding participant when the pair is bridged, otherwise null.
track-ended{ peer, via, stream }A forwarded stream was withdrawn.
path{ peer, kind, via, ... }The route to a peer changed. See below.
video-quality{ peer, direction, level, reason }Adaptive video changed the local send level or requested a receive level from this peer. Levels are normal, reduced, minimal or paused.
link{ peer, connected }The connection to a peer came up or went down.
gate{ url, state, code }A gate's state: joined, reconnecting or error (with an error code).
gateway{ id, available }A host node (gateway member) appeared or left.
closed{}The session ended after leave().

Path kinds​

kindMeaningExtra fields
connectingStill negotiatingphase: 0 endpoint, 1 session, 2 bridged
directNo relay on either sidelocal, remote (candidate types), protocol, localAddress, remoteAddress, rtt, bytesSent, bytesReceived
gatewayThrough one endpoint's own gateway; via is its ownerAs above, plus relayProtocol and relayUrl
relayThrough another session member's gateway; via is its owner, or 'turn' for your own TURN relayAs for gateway
bridgedForwarded by a participant; via is the forwarder
unreachableNo route inside the session right now

via is a peer id, or 'turn' for your own opt-in TURN relay. It is 'unknown' in the rare case where a relay cannot be matched to a member. Show users the path: "voice unavailable on this network" is a better message than silence.

Adaptive video waits for two consecutive five-second samples before stepping down. It looks at video packet loss, dropped frames, decode time, encoder limitation reasons and the per-link outgoing estimate; a high RTT alone does not trigger a change. Five healthy samples allow one step back up. If video is paused, Freehop probes at minimal quality after 25 seconds to check for recovery. Stats and encoding controls vary by browser; unavailable measurements leave the current level unchanged. Manual camera-off always wins.

stats()​

const s = await session.stats();
// s.gates -> [{ url, state, sent, received, bytesOut, bytesIn, errors, connects }]
// s.links -> [{ peer, connected, phase, path, restarts, connectMs, media: { inbound, outbound } }]
// s.bridges, s.relaying, s.counters
// s.nat -> { type: 'eim' | 'sequential' | 'random' | 'unknown', delta } (only with classifyNat or portPrediction)

With the opt-in options, s.counters also reports natSamples, predictAttempts, predictCandidates, turnAttempts and any natErrors, predictErrors, turnErrors or rungErrors.

bytesOut and bytesIn per gate are what your gate actually carried for this client: a quick way to see that media never touches it.

Audio playback in the browser​

Receiving audio packets does not guarantee audible playback. Browser autoplay rules can reject HTMLMediaElement.play(), and a muted tab or the wrong output device can also cause silence. attach() sets up the element and attempts playback; your UI should check playback and offer a button when it is blocked:

session.attach(track, audioElement);
audioElement.play().catch(() => { enableSoundButton.hidden = false; });
enableSoundButton.onclick = () => {
audioElement.play()
.then(() => { enableSoundButton.hidden = true; })
.catch(showPlaybackError);
};

The element, button and showPlaybackError are supplied by your app. Call play() directly from the button handler so it retains user activation. Keep the same audio attachment when only camera tracks change. The live demo shows playback status and an Enable sound control separately from network counters. See Chrome's autoplay guidance.

Without a backend: join()​

freehop/client exports the lower-level join(), which takes the gates and a shared secret directly. It is what connect() uses internally, and what the live call on this site uses with public trackers:

import { join } from 'freehop/client';

const room = await join({
gates: ['bt+wss://tracker.openwebtorrent.com', 'bt+wss://tracker.webtorrent.dev'],
stun: ['stun:stun.l.google.com:19302', 'stun:stun.cloudflare.com:3478'],
secret: roomCode, // at least 16 bytes of entropy, shared by every member
app: 'my-app',
media: { audio: true, video: true }
});

Without an authority there are no tickets or managed membership revocations: everyone who knows the secret can join. Your application would have to rotate and redistribute the secret itself to remove someone. Use connect() with tickets for anything beyond a demo.

Next​

Desktop (Electron) adds a participant's own gateway to the same session.