freehop 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PROTOCOL.md ADDED
@@ -0,0 +1,310 @@
1
+ # Freehop protocol, version 1
2
+
3
+ Freehop connects small groups with audio, video and data. `maxPeers` defaults to eight other participants; larger supported sizes require benchmarks. Its operator invariant:
4
+ **no server that the application operator runs ever carries media.** Media goes peer-to-peer.
5
+ When no direct route exists, it goes through a machine that belongs to the same session —
6
+ a participant, a participant's own gateway or the session's host node. Volunteers may be
7
+ added by configuration. The operator's servers ("gates") only introduce peers.
8
+
9
+ This document is normative for the wire formats and behaviour. `README.md` covers usage and
10
+ measured results.
11
+
12
+ ## 1. Roles
13
+
14
+ | Role | Runs on | Carries | Never carries |
15
+ |---|---|---|---|
16
+ | **Gate** | Any server (operator, community, anyone). Any number per room. | Sealed signalling envelopes (opaque bytes), STUN Binding answers | Media, plaintext SDP/candidates/credentials |
17
+ | **Peer** | Browser or desktop renderer (WebRTC) | Its own media, plus forwarded media when bridging (§9) | — |
18
+ | **Gateway** | A peer's own machine (desktop app main process) or the session host node; TURN server + router port mapping | Relayed packets of the session's peers, DTLS-SRTP encrypted end to end | Anything for peers outside its session (credentials are per room and per peer) |
19
+ | **Gateway member** | The session's host node (a participant's desktop app, or a community server hosting the session) | Joins the room through gates without media and offers its gateway | Media of its own |
20
+
21
+ Cost rule: a byte of media only crosses machines that belong to the call. These are the
22
+ two endpoints, another participant, or the session's own host. The operator's gate count
23
+ is bounded by construction (§4.5) and measured in every lab run.
24
+
25
+ ## 2. Identifiers and keys
26
+
27
+ - **Room secret**: ≥128 bits, generated by the application's room service when a room is
28
+ created. It is handed to members together with the room information they already
29
+ receive. The app need not display it, but members can extract and share it. A share link is a bearer credential.
30
+ - `salt = "peerlane/v1/" + app`, where `app` namespaces applications.
31
+ - `roomTag = base64url(HKDF-SHA256(secret, salt, info="room-tag", 32 bytes))`. This is the
32
+ only room identifier a gate sees.
33
+ - `envelopeKey = HKDF-SHA256(secret, salt, info="envelope-key")` as an AES-256-GCM key.
34
+ - **Peer id**: 16 random bytes, base64url (22 characters), fresh for every join. Gateway
35
+ members use ids that start with `gw_` (22 characters in total). The prefix is a hint;
36
+ their role is only accepted from sealed caps.
37
+
38
+ Kicking a member means the application issues a new room secret to the remaining members.
39
+ Every remaining member closes old-key links and reconnects under the new secret; previous-key envelopes are rejected immediately. Host nodes and desktop gateways revoke the entire old room epoch, including aliases. Rotation briefly interrupts media. Update every remaining member and host; any machine still using the old secret can communicate with its holders.
40
+
41
+ ## 3. Sealed envelopes
42
+
43
+ ```
44
+ box = base64url( iv[12] || AES-256-GCM(envelopeKey, iv, plaintext, aad) )
45
+ aad = "peerlane/v1|" + roomTag + "|" + from + "|" + to
46
+ plaintext = JSON { kind, n, ...body }
47
+ ```
48
+
49
+ - `to` is the recipient's peer id. The AAD binds room, sender and recipient. A gate that
50
+ re-labels or re-routes an envelope makes it fail authentication.
51
+ - `n` is a per-sender counter. Receivers keep a 1024-wide replay window per sender and drop
52
+ duplicates. The same envelope legitimately arrives via several gates and the mesh.
53
+ - Kinds: `caps`, `description`, `candidate`, `bye`, `restart-request`, `video-quality`, `bridge-request`,
54
+ `bridge-offer`, `bridge-accept`, `bridge-confirm`, `bridge-ready`, `bridge-active`, `bridge-release`, `bridge-fail`,
55
+ `forward-map`, `forward-unmap`, `app`. Unknown kinds are dropped. `app` carries application data
56
+ (`session.send`): at most 4096 characters of JSON, and each receiver accepts at most 20 per
57
+ second from one sender (burst 40). Tracker gates add a
58
+ room-broadcast `hello` (to `*`).
59
+ - **Adaptive video request.** A receiver that opted in sends `{kind:"video-quality", level:"normal"|"reduced"|"minimal"|"paused"}` to the peer sending video. Requests are sealed peer-to-peer envelopes, ordered by the envelope counter and rate-limited to one accepted change per second. `normal` releases the request; other levels cap or pause video on this link only. Audio is never changed. A peer that did not opt in ignores requests, and older implementations drop the unknown kind.
60
+ - **Admission.** Gate rosters and arrival events are unauthenticated hints. A hint only makes a
61
+ peer greet the hinted id with its sealed caps, at most once per 20 s and for at most 64
62
+ hints. A peer becomes a member, with `peer` events, a link and a slot, only after an
63
+ envelope from it authenticates. A gate cannot authenticate an invented member, but can still prevent discovery or disrupt recovery by dropping traffic. Roster presence alone does not prove membership.
64
+
65
+ ## 4. Gate protocol (WebSocket, UTF-8 JSON text frames)
66
+
67
+ ### 4.1 Messages
68
+
69
+ | Direction | Message | Notes |
70
+ |---|---|---|
71
+ | C→G | `{"t":"hello","v":1,"auth"?:string}` | Must be the first frame, within 5 s |
72
+ | G→C | `{"t":"welcome","v":1,"stun":[url],"limits":{...}}` | Informational only; clients use application/ticket STUN configuration |
73
+ | C→G | `{"t":"join","room":tag,"peer":id}` | At most 4 rooms per socket |
74
+ | G→C | `{"t":"peers","room":tag,"peers":[id]}` | Current members |
75
+ | G→C | `{"t":"peer","room":tag,"peer":id,"on":bool}` | Arrivals and departures |
76
+ | C→G | `{"t":"send","room":tag,"to":id or "*","box":string}` | The box is opaque base64url |
77
+ | G→C | `{"t":"recv","room":tag,"from":id,"box":string}` | `from` is the socket's own peer id |
78
+ | C→G | `{"t":"leave","room":tag}` / `{"t":"ping"}` | |
79
+ | G→C | `{"t":"error","code":string}` | `schema`, `rate`, `auth`, `auth-expired`, `busy`, `room-full`, `room-not-allowed`, `no-such-peer`, `not-joined`, … |
80
+
81
+ ### 4.2 Admission
82
+ A gate is either open or token-gated. A token is
83
+ `base64url(JSON claims) "." base64url(HMAC-SHA256(gateSecret, body))` with claims
84
+ `{exp, room?, aud}`. A token with `room` may only join that room tag. An application mints room-bound tokens for its admitted members. Tokens require a future safe-integer `exp`, and both parts must be canonical unpadded base64url. An expired socket is closed at its next request, heartbeat or delivery, whichever comes first. A client renews its token for the same epoch with `session.refresh(ticket)`; the new token is used at the next (re)connect.
85
+
86
+ ### 4.3 Multiple gates and tracker gates
87
+ Clients connect to every gate in their list and announce on each. Peers that share no gate
88
+ still meet through the mesh: §6 introduction. Established calls do not depend on any
89
+ gate. Lab evidence shows all gates shut down while media continues.
90
+
91
+ A gate URL `bt+wss://…` names a public WebTorrent tracker used as a gate.
92
+ - `info_hash` is the first 20 ASCII characters of the hex-encoded, decoded room tag (80 bits of tag material), and `peer_id` is a random
93
+ 20-character ASCII string.
94
+ - Hellos travel as announced offers, `"pl1:<peer id>:<sealed hello>"`. Envelopes travel as
95
+ answers addressed by `to_peer_id`.
96
+ - A peer's tracker address is bound only after its envelope authenticates. Hellos carry the
97
+ sender's counter and fall under the replay window.
98
+
99
+ ### 4.4 Liveness and proxies
100
+ - Clients send `{"t":"ping"}` every 30 s, and the gate also pings at the WebSocket level.
101
+ Each gate ping carries a fresh random nonce; only a pong that echoes it counts as activity.
102
+ - Output the gate has handed to a socket but not yet flushed is bounded: 1 MiB per socket
103
+ and 32 MiB for the whole gate. Over the gate-wide budget, the receiver that has been
104
+ behind the longest is closed first.
105
+ - Behind a local reverse proxy (`trustProxy`), per-address limits use the last
106
+ `X-Forwarded-For` hop.
107
+ - After a network change a join can briefly meet `peer-taken`; clients retry with backoff.
108
+
109
+ ### 4.5 STUN
110
+ A gate may answer RFC 8489 Binding requests on UDP, IPv4 and IPv6. It answers Binding
111
+ only, rate-limits per source address and globally, and never relays.
112
+
113
+ ### 4.6 Bounds
114
+ A gate frame is at most 64 KiB and a box at most 48 KiB. Each socket gets a token bucket
115
+ over the bytes it makes the gate forward, fan-out included: 512 KiB burst, 2 KiB/s
116
+ refill. Each socket is also limited to 40 messages/s (burst 400). Further limits: 16 peers
117
+ per room, 32 sockets per source address (an IPv6 /64 counts as one address), 1024
118
+ admitted sockets, and a 90 s idle timeout. Sockets that have not been admitted yet have
119
+ their own budget (256 in total, 8 per address, 64 frames or 64 KiB queued while admission
120
+ is pending), so silent sockets cannot lock admitted clients out; refused and timed-out
121
+ sockets are torn down within 1 s. These limits make media tunnelling through a gate
122
+ impractical, while one five-peer join needs about 100 KB.
123
+
124
+ ## 5. Capabilities (`caps` envelope)
125
+
126
+ ```
127
+ { v:1, role?:"gateway", forward:bool, peers:[id], gateway: null |
128
+ { urls:["turn:host:port?transport=udp", "turn:host:port?transport=tcp"],
129
+ username, credential, external:[ip], internal: ip|null } }
130
+ ```
131
+ - `peers` lists the sender's connected links. It is used for mesh routing and introductions.
132
+ - `forward` means the sender may bridge other pairs (§9).
133
+ - `gateway` holds credentials minted **for the recipient** (§8.2). A gateway member sets
134
+ `role:"gateway"`.
135
+
136
+ A peer sends caps to every peer it discovers (through gates), on every new data channel,
137
+ and when its link set changes. It also embeds caps in every `description` envelope.
138
+
139
+ Gate admission tokens include `aud`, the exact gate URL, and `room`. Tickets carry an `auth` map keyed by URL. Each gate rejects another audience even when signing keys are shared. Behind a reverse proxy configure the public `tokenAudience`. Clients never apply `welcome.stun`; the application or ticket pins its own STUN list.
140
+
141
+ ## 6. Signalling transport
142
+
143
+ 1. Every link carries a pre-negotiated data channel (`negotiated:true, id:0`, label
144
+ `peerlane`). Once it is open, all envelopes for that peer go over it as
145
+ `{"t":"env","from","to","box"}`.
146
+ 2. Otherwise the envelope is sent through every gate on which the recipient is present.
147
+ 3. Otherwise it goes through a connected neighbour whose caps list the recipient:
148
+ `{"t":"env",...,"hop":1}`. The neighbour forwards it once as `hop:2`, limited to 600
149
+ per minute per link. This is how peers introduce each other without a common gate.
150
+ 4. Otherwise it waits in an outbox: 32 envelopes per peer, for 20 s. The outbox is flushed
151
+ when the peer appears on a gate or its link opens.
152
+
153
+ A data channel is trusted alone only while its link is connected. A dead connection's
154
+ channel can read `open` for tens of seconds, so gates carry the envelope as well.
155
+
156
+ ## 7. Path ladder (per pair)
157
+
158
+ Negotiation follows W3C *perfect negotiation*. The peer with the lexicographically greater
159
+ id is *polite*. A link created to answer an incoming offer suppresses its own first offer.
160
+ A suppressed `negotiationneeded` is re-checked once the state is stable again.
161
+
162
+ | Phase | ICE servers | Expected route | Leaves the phase when |
163
+ |---|---|---|---|
164
+ | 0 ENDPOINT | Application-approved STUN + own gateway (internal URL) + remote peer's gateway | host/LAN, IPv6, srflx/prflx, or one endpoint's own gateway | not connected 5 s after the description, with one grace period if checks receive answers |
165
+ | 1 SESSION | + gateways of connected participants and of gateway members (max 2 added) | `relay` through a session gateway | 7 s without connection, with one extra 7 s grace period if checks receive answers |
166
+ | 2 BRIDGED | unchanged | media forwarded by a connected participant (§9) | the direct or session route later succeeds |
167
+ | unreachable | unchanged | none (no bridge candidate) | ICE restart retries with exponential backoff (30 s … 300 s) |
168
+
169
+ - Escalation adds gateways with `setConfiguration`. Only the impolite side restarts ICE.
170
+ The polite side applies the same servers and takes over after `restartFallbackMs`
171
+ (2.5 s) if no restart offer arrived. Simultaneous restarts collide, and a rolled-back
172
+ restart offer was observed to leave Chromium senders silent (lab finding, §12).
173
+ - Descriptions carry `phase` and `gateways` (the ids of extra gateways in use). The receiver
174
+ adds those servers **before** answering, so both sides gather against the same relays.
175
+ - Candidates for an unknown ICE generation are buffered until the matching description
176
+ arrives: at most 128, oldest evicted first.
177
+ - Descriptions apply in envelope-counter order. An older one never overtakes a newer one,
178
+ whatever route each took.
179
+ - An offer that receives no answer within 8 s is rolled back and sent again, and an ICE
180
+ restart is re-requested, so a lost envelope cannot deadlock the pair.
181
+ - Media watchdog: if a connected link's live track sends no packets for two 5 s checks, it is
182
+ restarted. The polite side sends `restart-request` instead of restarting itself.
183
+ - Departures: a member that has been absent from every gate for 8 s, and whose link is not
184
+ connected, is dropped as `gone`. Its replay window is kept, and it may return by
185
+ authenticating again. `bye` and kicks are final.
186
+ - On `disconnected` the link waits 4 s, then restarts. On `failed` it restarts immediately.
187
+ Both follow the single-initiator rule.
188
+
189
+ Path classification uses the selected candidate pair: `direct` (no relay on either side),
190
+ `gateway` (relay owned by one endpoint), `relay` (relay owned by another session member),
191
+ `bridged` (§9) or `unreachable`. A local peer-reflexive candidate whose `relayProtocol` is
192
+ set still rides a relay.
193
+
194
+ ## 8. Gateways
195
+
196
+ ### 8.1 Reachability
197
+ The gateway runs a TURN server (RFC 8656 subset: UDP relay allocations over UDP and TCP
198
+ client transports) on the peer's LAN address. It asks the router for mappings: PCP (RFC 6887),
199
+ then NAT-PMP (RFC 6886), then UPnP IGD v1/v2. It requests the same external port as the internal
200
+ one, for the listener (UDP and TCP) and for each relayed port on demand. A host with a public
201
+ address skips mapping. If the router reports a private or CGNAT external address, the gateway
202
+ is not offered.
203
+
204
+ ### 8.2 Credentials (TURN REST convention, room-scoped)
205
+ `username = "<unix expiry>:<room tag[0..8]>:<label>"`,
206
+ `credential = base64(HMAC-SHA1(secret, username))`. `label` is the recipient's peer id, or
207
+ `self` for the owner's own allocations via the internal URL.
208
+ - The signing key stays in the privileged gateway process. `info()` exposes no key; an origin- and room-checked broker returns bounded credentials to the renderer.
209
+ - The gateway accepts only rooms it was told to serve (`allowRoom`). Individual peers can be
210
+ revoked on kick or leave (`revokePeer`), which tears down their allocations. A whole room
211
+ is revoked on key rotation or when the session ends, tearing down every old allocation.
212
+ - Credentials are issued only to authenticated members, never in response to a hint.
213
+ - Default TTL is 2 h, renewed at half-life; configurable from 1 second to 24 hours. The server rejects even correctly signed expiry values beyond that configured horizon.
214
+ - Peer revocations are kept per room (up to 256) until previously minted credentials expire. A room that exceeds its bound is revoked as a whole; other rooms are never affected. A host member only revokes peers it issued credentials to.
215
+ - Revoking a room records a floor: credentials minted before it are refused even if the same room is allowed again later.
216
+
217
+ ### 8.3 Policy and hairpinning
218
+ - **A gateway relays only inside the session.** With its default `relayScope: 'internal'`,
219
+ a gateway relays only between allocations on itself and refuses every other destination
220
+ (403). Every path in §7 meets on the same gateway, including the owner's own `self`
221
+ allocation, so credentials cannot be used to send traffic to other internet hosts.
222
+ `relayScope: 'public'` restores general-purpose TURN behaviour; use it only when every
223
+ member is trusted.
224
+ - The TURN server's general peer policy (`peerScope: 'public'`) refuses loopback,
225
+ link-local, multicast and private peers, plus 6to4, Teredo, local-use NAT64, 192.0.0.0/24,
226
+ 198.18.0.0/15 and site-local prefixes, and unwraps IPv4-mapped, IPv4-translated and NAT64
227
+ forms before deciding.
228
+ - The gateway machine's own addresses are reachable only relay↔relay. Services on the host
229
+ stay unreachable.
230
+ - Traffic between two allocations on the same gateway is delivered internally. Many home
231
+ routers lack NAT hairpinning, and the owner's own `self` allocation therefore reaches
232
+ remote allocations without it.
233
+ - The TURN server answers unauthenticated UDP at a bounded rate per source and globally, to
234
+ bound reflection. Binding answers and challenge or error answers have separate global
235
+ budgets, and addresses that authenticated in the last 10 minutes are exempt from the global
236
+ budgets (not from their per-source limit), so a flood cannot starve renewals.
237
+ - It bounds TCP connections per source address (16), with a 10 s absolute pre-authentication deadline, unaffected by incoming bytes. A TCP connection closes when its allocation ends.
238
+ - Media stays DTLS-SRTP encrypted end to end. The gateway relays ciphertext.
239
+
240
+ ## 9. Participant bridging
241
+
242
+ When a pair (A,B) has no route, and no untried session gateway, the lower id asks
243
+ connected participants that report a link to the other peer (`bridge-request`). The first
244
+ `bridge-offer` wins (`bridge-accept`). Forwarder C then:
245
+
246
+ 1. asks B with `bridge-confirm` and waits for its `bridge-ready`; B validates that C is eligible and its A link is still unreachable;
247
+ 2. sends `bridge-active` to both endpoints to establish the forwarding relationship;
248
+ 3. sends `forward-map {origin, stream}` to each side before renegotiating;
249
+ 4. adds the origin's received tracks to its link with the other side, which re-encodes them.
250
+
251
+ Receivers attribute tracks by stream id. When the direct route later connects, an endpoint sends
252
+ `bridge-release`. C removes the forwarded tracks, stopping their transceivers so SDP does not
253
+ grow, and sends `forward-unmap`. If C leaves or its link drops, the endpoints re-escalate.
254
+
255
+ Hardening:
256
+ - A forwarder accepts `bridge-accept` only for an offer it made to that endpoint, within
257
+ 30 s, while forwarding is enabled and under its bridge limit.
258
+ - An endpoint accepts `bridge-active` only from the eligible forwarder it already consented to. Unsolicited and conflicting activations are released. Pending second-endpoint consent expires after 30 seconds. All three participants must run this consent handshake; upgrade clients together.
259
+ - `forward-map` is accepted only for an origin the receiver cannot reach itself, and only
260
+ from that pair's forwarder. A forwarder is a participant of the same call, so it
261
+ already receives both media streams. Bridging exposes nothing new to it. Forwarded video is
262
+ capped at 200 kbit/s.
263
+
264
+ ## 9a. SDK roles
265
+ `SDK.md` describes how applications consume the protocol:
266
+ - an **authority** (application backend) issues per-member **tickets**
267
+ `{v, app, roomId, epoch, gates, secret, auth?, stun?, expires}` and rotates the room on kick;
268
+ - clients `connect(ticket)` and `update(newTicket, {dropped})`;
269
+ - hosts run `hostSession(ticket)` (gateway member);
270
+ - desktop apps expose their gateway through the Electron preload (`window.freehopGateway`,
271
+ restricted by exact HTTPS origins (HTTP only on loopback) in both the preload and main-process IPC handlers; subframes are rejected).
272
+
273
+ ## 10. Media profile (defaults)
274
+ - Audio: Opus, echo cancellation, noise suppression and AGC, max 32 kbit/s.
275
+ - Video: 640×360 at up to 24 fps, max 300 kbit/s per link.
276
+ - Muting the microphone disables the track (no renegotiation). Turning the camera off
277
+ stops the track and replaces it with `null`.
278
+ - Mesh upstream at 5 participants with video ≈ 4 × 332 kbit/s before overhead.
279
+
280
+ ## 11. Security considerations
281
+ - **Gates** learn: socket IP addresses, opaque room tags and peer ids, envelope sizes and
282
+ timing. They cannot read SDP, ICE candidates, caps or credentials. They cannot forge or
283
+ re-route envelopes without detection, and cannot inject peers into a room. A hostile gate
284
+ can drop or delay traffic; using several gates mitigates that.
285
+ - **Room members** are mutually trusted for signalling: any member can forge envelopes in
286
+ the room's name. Per-peer signatures are a possible v2 addition. Removal requires a secret
287
+ rotation (§2). SDK ticket expiry does not erase copies of the shared secret or end established media; it is not a replacement for rotation.
288
+ - **Gateways**: credentials are per peer and short-lived. The peer policy prevents use as a
289
+ proxy into private networks. Quotas: 6 allocations per username, 4 Mbit/s per
290
+ allocation, 32 allocations and 20 Mbit/s per room, 40 Mbit/s in total, separately per direction; at most 64 allocations overall. Room quotas aggregate every alias in that room; global limits still apply. By default a gateway relays only between allocations on itself, so a member cannot use its credentials to reach other internet hosts (§8.3). Expiry blocks further authenticated requests; existing allocations may continue until their granted lifetime ends. Revocation ends them immediately, and a participant's desktop gateway releases a room when the participant leaves.
291
+ - **IP privacy**: direct paths expose network addresses to other participants. Gate and tracker operators also see connecting IP addresses. The SDK does not provide an IP-anonymity mode.
292
+
293
+ ## 12. Known limits
294
+ - A network that permits traffic only to the gate host cannot carry media without the gate
295
+ operator carrying it. Freehop reports `unreachable`. A gate operator who *chooses* to also
296
+ run a gateway (community gates) can serve such users; the application operator's own gates
297
+ do not.
298
+ - Two browser-only participants that are both behind hard NATs or UDP-blocking networks, with
299
+ no IPv6, no gateway in the session and no third participant, cannot connect. A session host node reachable by both endpoints can provide a TURN relay path.
300
+ - Chromium, lab finding: simultaneous ICE restarts (glare) intermittently left the polite
301
+ side's RTP senders silent after its restart offer was rolled back. Freehop avoids
302
+ simultaneous restarts (§7).
303
+ - Playwright 1.62's WebKit build rejects `?transport=` in TURN URLs (WebKit bug 320931). The
304
+ client detects this and degrades to UDP-only TURN URLs for that engine.
305
+ - Desktop apps can alternatively pin WebRTC's UDP port range
306
+ (`webContents.setWebRTCUDPPortRange`, Electron ≥ 28) and map it directly. Freehop's
307
+ gateway approach needs no Chromium cooperation.
308
+
309
+ ---
310
+ Documentation licensed under CC BY 4.0. Copyright 2026 Jolyn Studios. Provided as is, without warranty of any kind.
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ <div align="center">
2
+
3
+ # Freehop
4
+
5
+ ### Voice and video between your users, without your servers carrying the call.
6
+
7
+ **[Live demo](https://jolynstudios.github.io/freehop/demo)** ·
8
+ **[Documentation](https://jolynstudios.github.io/freehop/)** ·
9
+ **[Quickstart](#quickstart)** ·
10
+ **[Architecture](ARCHITECTURE.md)** ·
11
+ **[Protocol](PROTOCOL.md)**
12
+
13
+ ![License: Apache-2.0](https://img.shields.io/badge/code-Apache--2.0-303055)
14
+ ![Docs: CC BY 4.0](https://img.shields.io/badge/docs-CC%20BY%204.0-303055)
15
+ ![Original lab matrix: 40/40](https://img.shields.io/badge/original%20lab%20matrix-40%2F40-096e72)
16
+ ![Unit tests: 151/151](https://img.shields.io/badge/unit%20tests-151%2F151-096e72)
17
+ ![Status: alpha](https://img.shields.io/badge/status-alpha-ef3b2c)
18
+
19
+ </div>
20
+
21
+ ---
22
+
23
+ Adding voice or video to a game or app comes with a hidden bill. WebRTC connects people
24
+ directly when it can. When it can't, because of strict NATs or firewalls that block UDP, the
25
+ standard answer is a **TURN relay that you run and pay for**. Every byte of those calls flows
26
+ through your servers. Historical measurements from 2015–2017 found relay use in roughly one fifth of calls or conferences on those services; they are not a current prediction
27
+ ([callstats.io: ~22%](https://webrtchacks.com/usage-stats/),
28
+ [appear.in: ~17.7%](https://medium.com/@fippo/what-kind-of-turn-server-is-being-used-d67dbfc2ff5d)),
29
+ and managed TURN is billed per gigabyte (e.g.
30
+ [$0.05/GB](https://developers.cloudflare.com/realtime/turn/)).
31
+
32
+ **Freehop removes your servers from the media path entirely.** Your infrastructure only runs
33
+ small *gates* that introduce people to each other: tens of kilobytes of sealed signalling per
34
+ call, plus presence announcements and connection recovery. When a direct connection is impossible, the call hops through machines that
35
+ **already belong to the session**: a desktop participant's own router-mapped gateway, the machine
36
+ hosting the session, or another participant. The cost of a call stays with the people on it.
37
+
38
+ ## Why Freehop
39
+
40
+ | | |
41
+ |---|---|
42
+ | 💸 **Zero media on your servers** | Gates carry sealed signalling only. In every lab run, gate traffic for an entire scenario stayed between 30 and 210 KB, with no media at all. |
43
+ | 🧱 **Connects the "impossible" pairs** | Two strict (symmetric) NATs, or a network that blocks UDP, can't connect directly. Freehop routes them through a session member's gateway or the session host, still with no operator relay. |
44
+ | 🛰️ **No single point of failure** | Run one gate or many, operated by you, your community, or public WebTorrent trackers. Peers on different gates still find each other. **Calls keep running when every gate is down.** |
45
+ | 🔐 **Private by construction** | Signalling is sealed (HKDF + AES-256-GCM), so gates can't read or forge envelopes. Direct and gateway paths preserve end-to-end DTLS-SRTP; a forwarding participant decodes and re-encodes media. |
46
+ | ⚙️ **Automatic** | No manual room link is required in a ticket-based integration. Your backend issues a ticket, and the SDK takes the cheapest path that works: direct, then gateway, then relay, then bridge. |
47
+ | 📦 **Small and open** | Zero-dependency browser client; a gate with one dependency (`ws`); TURN gateway and PCP / NAT-PMP / UPnP port mapping in plain Node. Apache-2.0. |
48
+
49
+ ## How it works
50
+
51
+ ```mermaid
52
+ flowchart LR
53
+ subgraph session["One session (a call)"]
54
+ A["🏠 Participant A<br/>(browser)"]
55
+ B["🏠 Participant B<br/>(phone, strict NAT)"]
56
+ H["🗼 Host node or<br/>desktop gateway"]
57
+ end
58
+ G["📮 Gate<br/>(your server)"]
59
+ A -. "sealed envelopes (KB)" .-> G
60
+ B -. "sealed envelopes (KB)" .-> G
61
+ A == "media: direct when possible" ==> B
62
+ A == "or via a session gateway" ==> H
63
+ H ==> B
64
+ ```
65
+
66
+ 1. **Your backend issues tickets.** `createAuthority()` owns each room's secret. A member gets a ticket over your own authenticated channel.
67
+ 2. **Gates introduce.** Clients announce on every gate in the ticket and exchange sealed envelopes. Once linked, signalling moves onto the peers' own data channels. A gate can be your own small server or a public WebTorrent tracker: the envelopes ride in the offer and answer messages that trackers already pass between browsers ([how](https://jolynstudios.github.io/freehop/docs/concepts/trackers)).
68
+ 3. **The path ladder finds a route.** Freehop tries direct first (LAN, IPv6, STUN). If that fails it uses an endpoint's own gateway, then a gateway of another session member, then forwarding through a participant. A failure is reported honestly as `unreachable`.
69
+ 4. **Kicks rotate keys.** `authority.kick()` issues a new room secret; remaining members `update()` and drop the kicked peer.
70
+
71
+ ## Proof, not promises
72
+
73
+ The original qualification ran in an isolated Linux lab with real **Chromium 151, Firefox 153 and WebKit 26.5**.
74
+ Each browser sits behind its own kernel NAT router profile, with fake camera and microphone.
75
+ Every run asserts the path taken and that audio *and* video actually arrive.
76
+
77
+ | Network situation | Result | Route Freehop chose |
78
+ |---|---|---|
79
+ | Two home routers | ✅ 3/3 | direct |
80
+ | IPv6 available, IPv4 UDP blocked | ✅ 3/3 (+ Firefox/WebKit) | direct over IPv6 |
81
+ | Two strict/symmetric NATs + a third participant | ✅ 3/3 (+ cross-browser) | forwarded by the participant |
82
+ | Strict NAT ↔ desktop participant behind a UPnP router | ✅ 3/3 (+ cross-browser) | the desktop's own gateway |
83
+ | Two strict NATs + a desktop participant | ✅ 3/3 (+ cross-browser) | relay through that participant's gateway |
84
+ | UDP-blocking firewall ↔ desktop participant | ✅ 3/3 (+ cross-browser) | gateway over TCP |
85
+ | Two UDP-blocked peers + a desktop participant | ✅ 3/3 | relay over TCP |
86
+ | Two strict NATs, session hosted on a server node | ✅ 3/3 (+ cross-browser) | the host node's gateway |
87
+ | Two UDP-blocked peers, server-hosted session | ✅ 3/3 | the host node's gateway |
88
+ | Two strict NATs and nobody else | ✅ 3/3 | `unreachable` (correctly reported) |
89
+ | A network that can only reach the gate | ✅ 3/3 | `unreachable` (no route exists without your server) |
90
+
91
+ **40/40 lab runs passed** on the current revision, re-run on 2 October 2026 after security hardening, including gateways that relay only inside their session. Other checks:
92
+ - **151/151 unit tests**, including the RFC 5769 STUN vectors and the security regressions.
93
+ - **coturn's own test client** against Freehop's TURN server: 800/800 messages over UDP and 800/800 over TCP, 0 lost.
94
+ - **Browser suites:** multi-gate with every gate shut down mid-call, kick/rekey, a public WebTorrent tracker as the only gate, and the SDK example app.
95
+
96
+ Full details are in [RESULTS.md](RESULTS.md).
97
+
98
+ ## Example consumer: Redline Wars
99
+
100
+ [**Redline Wars**](https://redlinewars.online) is a real-time strategy game that runs in the
101
+ browser and as a desktop app ([source](https://github.com/jolynstudios/redlinewars)). It is
102
+ one planned consumer of Freehop. The game is integrating the independent SDK
103
+ **through the public SDK only**, exactly like any other app would:
104
+
105
+ - the match host acts as the session's gateway;
106
+ - desktop participants open their own front door through their router;
107
+ - browser participants simply join.
108
+
109
+ Freehop is an independent project; any app can use the same public SDK.
110
+
111
+ ## Quickstart
112
+
113
+ ```bash
114
+ npm install github:jolynstudios/freehop # npm registry release follows with 1.0
115
+ ```
116
+
117
+ **Backend:** decide who is in a room.
118
+ ```js
119
+ import { createAuthority } from 'freehop/authority';
120
+ const authority = createAuthority({
121
+ app: 'my-app',
122
+ gates: ['wss://example.com/freehop'],
123
+ gateTokenSecrets: { 'wss://example.com/freehop': process.env.FREEHOP_GATE_TOKEN_SECRET },
124
+ stun: ['stun:example.com:3478']
125
+ });
126
+ await authority.openRoom('room-42');
127
+ const ticket = await authority.ticket('room-42', userId); // send over your own channel
128
+ ```
129
+
130
+ **Client:** join the call.
131
+ ```js
132
+ import { connect } from 'freehop';
133
+ const session = await connect(ticket, { media: { audio: true } });
134
+ session.on('track', ({ peer, track }) => session.attach(track, audioElementFor(peer)));
135
+ session.on('path', ({ peer, kind }) => console.log(peer, 'is', kind)); // direct | gateway | relay | bridged | unreachable
136
+ ```
137
+
138
+ **Gate:** the signalling service. Set `FREEHOP_GATE_TOKEN_SECRET` to the same private signing key (at least 32 characters) in the backend and gate environments, and put the gate behind your TLS proxy.
139
+ ```bash
140
+ FREEHOP_GATE_PORT=8787 FREEHOP_GATE_PUBLIC_HOST=example.com \
141
+ FREEHOP_GATE_STUN='0.0.0.0:3478,[::]:3478' \
142
+ FREEHOP_GATE_TOKEN_SECRET="$FREEHOP_GATE_TOKEN_SECRET" \
143
+ FREEHOP_GATE_TOKEN_AUDIENCE=wss://example.com/freehop \
144
+ FREEHOP_GATE_TRUST_PROXY=1 ./node_modules/.bin/freehop-gate
145
+ ```
146
+ Run the installed copy, not `npx freehop-gate`: Freehop is not on npm, so npx could fetch a stranger's package and hand it your token secret.
147
+
148
+ Desktop apps (Electron) and session hosts get one call each. See [SDK.md](SDK.md). For a
149
+ complete runnable consumer, run `npm run example` and open it in two windows. Existing integrations should follow the [security model](PROTOCOL.md): gateway credentials now come from a privileged broker, tokens name their gate, and STUN servers come from application configuration. Use `authority.kick()` for membership revocation; `disconnectPeer()` is local removal only.
150
+
151
+ ## How Freehop compares
152
+
153
+ Every option below can put people in a call. What differs is where the audio and video go when
154
+ two people cannot connect directly, and who pays for that traffic.
155
+
156
+ | Option | What it is | When a direct route fails, media goes through | Your media bill | Built for | License |
157
+ |---|---|---|---|---|---|
158
+ | **Freehop** | Peer-to-peer SDK plus small signalling gates | Machines in the session: a participant's desktop gateway, the host node, or a forwarding participant | **No operator media bill; session machines carry the traffic** | 2 to 8 people (mesh) | Apache-2.0 |
159
+ | WebRTC + your own TURN | The browser API, plus the servers you build (e.g. coturn) | Your TURN server | Every relayed byte | Small groups (mesh), more with an SFU you add | coturn: BSD-3-Clause |
160
+ | [PeerJS](https://peerjs.com) | Library for one-to-one connections by peer id, with PeerServer signalling | A TURN server you supply; its free TURN service closed in December 2023 | Yours, once you add TURN | One-to-one; groups are a mesh you build | MIT |
161
+ | [Trystero](https://github.com/dmotz/trystero) | Serverless peer-to-peer matchmaking library | A TURN server you add; without one, hard-NAT pairs fail | Yours, once you add TURN | Small groups (mesh) | MIT |
162
+ | [LiveKit](https://livekit.io) | Open-source SFU server and SDKs, or LiveKit Cloud | Every stream goes through the SFU, with built-in TURN | Your servers' bandwidth, or Cloud pricing per minute and GB | Large rooms, livestreams, AI agents | Apache-2.0 |
163
+ | [Jitsi Meet](https://jitsi.org) | Complete meeting app: Videobridge SFU with XMPP signalling | With 2 people it tries a direct link; with 3 or more, every stream goes through the Videobridge | Your servers' bandwidth, or 8x8's hosted JaaS | Meetings with dozens of people | Apache-2.0 |
164
+ | [mediasoup](https://mediasoup.org) | SFU library for Node.js or Rust, with a C++ media worker | Always your SFU server | Your servers' bandwidth | Large rooms you build yourself | ISC |
165
+ | Hosted video APIs | Daily, Agora, Twilio Video, Cloudflare Realtime and others | The provider's servers: most send every stream through them | Per participant-minute, or per GB (Cloudflare) | Large rooms, nothing to run | Proprietary |
166
+
167
+ Freehop is built for products where **people in the session can help carry their own call**:
168
+ games, small-group voice and video, communities. If you need 50-person rooms, recording, or a
169
+ guarantee on networks that block everything except your server, an SFU or a paid relay is the
170
+ right tool. The [full comparison](https://jolynstudios.github.io/freehop/docs/comparison) adds
171
+ signalling and encryption, with sources.
172
+
173
+ ## Honest limits
174
+
175
+ - **Room size needs admission control.** The initial target is rooms of 2 to 8 people; eight was chosen for that target, not established by a capacity benchmark. The SDK's
176
+ `limits.maxPeers` defaults to 8 remote peers per client; it is not a room-size cap.
177
+ Your backend must limit membership before issuing tickets. Excess peers are silently
178
+ ignored, which can leave a larger room only partly connected.
179
+ - **Unrouteable networks.** A network that lets a user reach *only* your gate has no route
180
+ for media without your server carrying it. Freehop reports `unreachable` instead of
181
+ silently paying for a relay.
182
+ - **Hard NATs need someone with a reachable route.** Two browser-only users, both behind
183
+ strict NATs or UDP-blocking networks, with no IPv6 and nobody else in the session, cannot
184
+ connect.
185
+ - **Lab-qualified, not field-proven yet.** Real ISPs, 4G/5G carrier NAT, corporate networks
186
+ and mobile browsers are being tested next. Status: **alpha**.
187
+ - **Forwarded media is re-encoded.** When a participant forwards the call, it re-encodes the
188
+ media. That participant is in the call anyway.
189
+
190
+ ## Repository map
191
+
192
+ | Path | What |
193
+ |---|---|
194
+ | `src/client/` | Browser client: rooms, sealed signalling, path ladder, bridging, media |
195
+ | `src/sdk/` | SDK: `authority` (backend), `connect` (client), `host`, tickets |
196
+ | `src/gate/` + `bin/freehop-gate.mjs` | Gate service with optional STUN |
197
+ | `src/relay/` | TURN gateway, PCP / NAT-PMP / UPnP port mapper, host-node member |
198
+ | `src/electron/` | Desktop helper (main + preload) |
199
+ | `examples/minimal/` | A complete reference app |
200
+ | `lab/` | Disposable Linux NAT lab that reproduces every result |
201
+ | `website/` | The documentation site (Docusaurus) |
202
+
203
+ ## Get involved
204
+
205
+ - ⭐ **Star the repo** to follow progress toward the field-tested 1.0.
206
+ - 🎮 **Try the [live demo](https://jolynstudios.github.io/freehop/demo)**: a real call between two
207
+ browser tabs, introduced through public trackers with no server of ours.
208
+ - 🛠️ **Building a game or app on Freehop?** [Open an issue](https://github.com/jolynstudios/freehop/issues)
209
+ and tell us about your use case. Integration reports directly shape the SDK.
210
+
211
+ ## License and warranty
212
+
213
+ - Code: **Apache License 2.0** ([LICENSE](LICENSE)).
214
+ - Documentation and protocol specification: **CC BY 4.0** ([LICENSE-docs](LICENSE-docs)).
215
+ - Attributions: [NOTICE](NOTICE).
216
+
217
+ > **No warranty.** Freehop is provided "as is", without warranties or conditions of any kind.
218
+ > You use it entirely at your own risk.
219
+
220
+ Copyright 2026 Jolyn Studios. Freehop was developed under the codename *Peerlane*; some protocol
221
+ identifiers keep that name.