Quickstart
The examples below show where each piece runs. A ticket-based integration uses a backend authority, a browser client and at least one gate. You can use public trackers instead of running your own gate. Desktop gateways and host nodes are optional; their owners contribute bandwidth when a direct path fails.
your backend your gate host every client hosts (optional)
──────────── ────────────── ──────────── ────────────────
createAuthority() ───► freehop-gate (gate) ◄─── connect(ticket) hostSession(ticket)
openRoom / ticket signalling only media peer to peer, desktop app or server
kick (rotate) never media or via a session gateway that hosts the session
│ ▲
└───── tickets over YOUR authenticated channel ─────┘ desktop apps: installFreehopGateway()
0. Install
Freehop alpha is published on npm. Install the current alpha with the command below. The backend, gate and host helpers need Node.js 22 or newer. The client runs in the browser; bundle its package import with your frontend build tool.
npm install freehop@alpha
All 15 public entry points include declarations. See the TypeScript guide for typed sessions, events and backend configuration.
The package has these entry points:
| Import | Use it in | What it gives you |
|---|---|---|
freehop (also freehop/sdk) | Browser or desktop renderer | connect(ticket, options) |
freehop/authority | Your backend (Node.js) | createAuthority() |
freehop/ticket | Anywhere | encodeTicket(), decodeTicket(), validTicket() |
freehop/host | The machine that hosts a session (Node.js) | hostSession(ticket) |
freehop/electron and freehop/electron/preload | Electron main process and preload | installFreehopGateway() and window.freehopGateway |
freehop/gate | A gate host (Node.js) | createGate(), also available as the freehop-gate command |
freehop/client | Browser, low level | join() with a raw secret instead of a ticket |
freehop/relay | Node.js or Electron main | startGateway() and gateway lifecycle |
freehop/member | Node.js | A gateway member that joins a room without media |
freehop/turn | Node.js | The TURN server |
freehop/port-mapper | Node.js or Electron main | PCP, NAT-PMP and UPnP router mapping |
freehop/stun | Node.js | STUN/TURN codec helpers |
freehop/tokens | Backend or gate (Node.js) | Gate token signing and verification |
1. Backend: decide who is in a room
Your backend embeds an authority. It creates a secret for each room, and hands a ticket to each member your application admits. Only your own authentication decides who gets a ticket.
import { createAuthority } from 'freehop/authority';
const authority = createAuthority({
app: 'my-app', // namespaces room keys
gates: ['wss://gate.example.com/freehop'], // one or more gates
gateTokenSecrets: {
'wss://gate.example.com/freehop': process.env.FREEHOP_GATE_TOKEN_SECRET
},
stun: ['stun:gate.example.com:3478'] // approved by your application
});
await authority.openRoom('room-42'); // creates the room secret
const ticket = await authority.ticket('room-42', 'user-7'); // only for members you admitted
// Send `ticket` to user-7 over your own authenticated channel (your API or websocket).
Use the same private gate signing key in the backend and gate environments. The authority sends each token only to its intended gate; clients use the approved stun list and ignore gate-supplied STUN hints.
A ticket carries the room secret, so it is as sensitive as room membership itself. Deliver it only to that member. Authority and tickets covers kicks and the ticket format.
2. Gate: run the signalling service
A gate is a small WebSocket service that passes sealed envelopes. It never carries media, so it needs no TURN ports and no media bandwidth.
FREEHOP_GATE_PORT=8787 \
FREEHOP_GATE_PUBLIC_HOST=gate.example.com \
FREEHOP_GATE_STUN='0.0.0.0:3478,[::]:3478' \
FREEHOP_GATE_TOKEN_SECRET=… \
FREEHOP_GATE_TOKEN_AUDIENCE=wss://gate.example.com/freehop \
FREEHOP_GATE_TRUST_PROXY=1 \
./node_modules/.bin/freehop-gate
In a project that depends on freehop, ./node_modules/.bin/freehop-gate runs the installed service. Avoid an unpinned npx freehop-gate, which can resolve a different package from the registry. From a checkout, use node bin/freehop-gate.mjs. Put the gate behind your TLS proxy so clients reach it as wss://gate.example.com/freehop. Gate service has a systemd unit and a Caddy snippet.
For experiments you can skip this step: a public WebTorrent tracker works as a gate. Replace the authority's gates list with ['bt+wss://tracker.openwebtorrent.com'] and remove gateTokenSecrets when no private gate remains. Keep an application-approved public STUN server in stun, such as stun:stun.cloudflare.com:3478, if you are not running your own. Trackers carry sealed envelopes but still see IP addresses and other connection metadata.
3. Client: join with the ticket
This is browser JavaScript. Fetch ticket from your authenticated backend, then call connect() from a Join button on an HTTPS page (localhost works for development). addTile, mediaElementFor, showRoute and removeTile are functions you supply in your UI. The microphone, camera and leave calls below are separate examples for your controls; do not run them all immediately after joining.
To let Freehop adjust video per peer when it detects sustained congestion, opt in with adaptiveVideo: true; this remains off by default.
import { connect } from 'freehop';
const session = await connect(ticket, { media: { audio: true, video: false } });
session.on('peer', ({ id }) => addTile(id)); // an authenticated member appeared
session.on('track', ({ peer, track }) =>
session.attach(track, mediaElementFor(peer, track.kind))); // <audio> or <video> per peer
session.on('path', ({ peer, kind, via }) => showRoute(peer, kind, via));
session.on('peer-left', ({ id, reason }) => removeTile(id));
await session.setMicrophone(false); // mute without renegotiation
await session.setCamera(true); // asks for the camera on demand
const levels = await session.levels(); // { [peerId]: 0..1 } for speaking indicators
await session.leave();
Optional: a last resort before unreachable
Both options below are off by default; without them nothing changes. Give tickets your own TURN servers, and let clients try predicted ports first:
const authority = createAuthority({
app: 'my-app',
gates: ['wss://gate.example.com/freehop'],
// Your TURN provider's short-lived credentials, minted per ticket: [{ urls, username, credential }]
turn: async ({ roomId, member, expires }) => mintTurnCredentials(expires)
});
const session = await connect(ticket, { media: { audio: true }, portPrediction: true });
session.on('path', ({ kind, via }) => {
if (via === 'turn') showNote('Connected through your TURN relay');
});
The relay carries only calls that would otherwise be unreachable, still encrypted end to end, and its bandwidth is yours. A provider's free tier, or Freehop's own TURN server (freehop/turn) on a machine you already run, can keep that at zero. Port prediction needs at least two stun: servers and stays off by default until it is verified on real carrier networks. See connect() options and paths.
kind is one of direct, gateway, relay, bridged or unreachable once a route is known, and connecting before that. Show it to your users: it is honest about what happened. Report session.id to your backend so that it can tell the other members which peer to drop after a kick. See Client: connect for every option and event.
4. Desktop apps: open the participant's front door
A desktop app can run a gateway on the participant's own machine: a TURN server made reachable through the participant's router (PCP, NAT-PMP or UPnP). Participants behind hard NATs or UDP-blocking networks can then reach that participant without any third party.
import { app, BrowserWindow, ipcMain } from 'electron';
import { createRequire } from 'node:module';
import { installFreehopGateway } from 'freehop/electron';
const require = createRequire(import.meta.url);
const freehop = installFreehopGateway({ ipcMain, allowedOrigins: ['https://app.example.com'] });
app.on('will-quit', () => freehop.close());
app.whenReady().then(() => {
const win = new BrowserWindow({
webPreferences: {
preload: require.resolve('freehop/electron/preload'),
additionalArguments: ['--freehop-origins=https://app.example.com'],
contextIsolation: true
}
});
win.loadURL('https://app.example.com');
});
connect() finds window.freehopGateway automatically. The gateway starts when connect() first requests it, including for receive-only sessions. Desktop (Electron) explains the origin restriction and the options.
5. Hosts: let the session's own host relay for it
Whoever hosts a session (your application server, a community server or a participant's desktop app) can make that machine the session's gateway. It joins the room without media and offers its gateway to the members.
import { hostSession } from 'freehop/host';
const host = await hostSession(await authority.ticket('room-42')); // a ticket without a member
if (!host.available) console.log('Not reachable from outside: no public address, no router mapping.');
// After a kick, follow the rotation and revoke the entire old room epoch:
await host.update(nextTicket, { dropped: [peerId] });
await host.close();
A host node contributes upload when it relays media. If you run it on your own infrastructure, budget for that bandwidth and compute. A participant-owned host uses that participant’s connection. Gates remain signalling-only in either setup.
Run the reference example
examples/minimal/ is a complete consumer in one process: a backend with an authority, a gate on the same origin, and a page with join, microphone and camera toggles, a path label and level meter per peer, and kicks through the app's own API.
git clone https://github.com/jolynstudios/freehop
cd freehop
npm ci --ignore-scripts
node examples/minimal/server.mjs # prints http://127.0.0.1:8790
Open the address in two or more browser windows and join the same room. test/browser/example-app.mjs drives the same app with three real browsers and asserts the mesh, the kick rotation and the removal.
To try the opt-in TURN last resort, start the example with your relay. Every ticket then carries it, but clients use it only when no other route works, so two windows on one machine still connect directly; a call it does carry reads "relay via turn":
FREEHOP_TURN_URLS=turn:turn.example.com:3478 FREEHOP_TURN_USERNAME=… FREEHOP_TURN_CREDENTIAL=… \
node examples/minimal/server.mjs
Next
Read Gates to see what your server sees, or try a live call between two browser tabs.