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/ARCHITECTURE.md +204 -0
- package/LICENSE +202 -0
- package/LICENSE-docs +396 -0
- package/NOTICE +34 -0
- package/PROTOCOL.md +310 -0
- package/README.md +221 -0
- package/SDK.md +113 -0
- package/bin/freehop-gate.mjs +51 -0
- package/deploy/Caddyfile.snippet +6 -0
- package/deploy/freehop-gate.service +46 -0
- package/package.json +79 -0
- package/src/client/crypto.mjs +49 -0
- package/src/client/gate-client.mjs +95 -0
- package/src/client/ice-urls.mjs +14 -0
- package/src/client/peer.mjs +272 -0
- package/src/client/peerlane.mjs +6 -0
- package/src/client/room.mjs +1188 -0
- package/src/client/tracker-client.mjs +123 -0
- package/src/electron/main.mjs +70 -0
- package/src/electron/preload.cjs +17 -0
- package/src/gate/gate.mjs +357 -0
- package/src/gate/stun-responder.mjs +52 -0
- package/src/relay/agent.mjs +230 -0
- package/src/relay/member.mjs +169 -0
- package/src/relay/port-mapper.mjs +1058 -0
- package/src/relay/turn-server.mjs +790 -0
- package/src/sdk/authority.mjs +85 -0
- package/src/sdk/client.mjs +113 -0
- package/src/sdk/host.mjs +72 -0
- package/src/sdk/ticket.mjs +29 -0
- package/src/shared/stun.mjs +282 -0
- package/src/shared/tokens.mjs +35 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// One RTCPeerConnection per remote peer. Perfect negotiation (W3C WebRTC "perfect negotiation"
|
|
3
|
+
// example) lets either side renegotiate; the path ladder escalates only after the cheaper
|
|
4
|
+
// path demonstrably failed, so media cost stays with the two endpoints whenever possible.
|
|
5
|
+
export const PHASE = Object.freeze({ ENDPOINT: 0, SESSION: 1, BRIDGED: 2 });
|
|
6
|
+
const UFRAG = /^a=ice-ufrag:(\S+)\r?$/gm;
|
|
7
|
+
|
|
8
|
+
export class PeerLink {
|
|
9
|
+
constructor(room, id, { forOffer = false } = {}) {
|
|
10
|
+
this.room = room; this.id = id; this.polite = room.id > id;
|
|
11
|
+
// A link created to answer an incoming offer must not race it with an offer of its own.
|
|
12
|
+
this.suppress = forOffer; this.missedNegotiation = false; this.restartPending = false;
|
|
13
|
+
this.phase = PHASE.ENDPOINT; this.connected = false; this.everConnected = false; this.closed = false;
|
|
14
|
+
this.makingOffer = false; this.ignoreOffer = false; this.settingAnswer = false;
|
|
15
|
+
this.pendingCandidates = []; this.forwardMap = new Map(); this.forwardSenders = new Map();
|
|
16
|
+
this.senders = new Map(); this.remoteTracks = new Map(); this.queue = Promise.resolve();
|
|
17
|
+
this.watchdog = null; this.grace = false; this.restarts = 0; this.retries = 0; this.path = { kind: 'connecting' };
|
|
18
|
+
this.extraGateways = new Set(); // other participants' gateways added for this pair
|
|
19
|
+
this.lastDescriptionN = 0; // envelope counter of the last applied remote description
|
|
20
|
+
this.createdAt = Date.now(); this.connectedAt = null;
|
|
21
|
+
const pc = this.pc = new room.RTCPeerConnection({ iceServers: room.iceServersFor(this), bundlePolicy: 'max-bundle', rtcpMuxPolicy: 'require' });
|
|
22
|
+
this.control = pc.createDataChannel('peerlane', { negotiated: true, id: 0, ordered: true });
|
|
23
|
+
this.control.onmessage = event => room.onControlMessage(this, event.data);
|
|
24
|
+
this.control.onopen = () => room.onControlOpen(this);
|
|
25
|
+
pc.onnegotiationneeded = () => this.negotiate();
|
|
26
|
+
pc.onicecandidate = ({ candidate }) => {
|
|
27
|
+
if (candidate && candidate.candidate) room.signal(this, { kind: 'candidate', candidate: candidate.toJSON() });
|
|
28
|
+
};
|
|
29
|
+
pc.onconnectionstatechange = () => this.onConnectionState();
|
|
30
|
+
pc.onsignalingstatechange = () => { if (pc.signalingState === 'stable') this.room.applyEncodingLimits(this); };
|
|
31
|
+
pc.ontrack = event => this.onTrack(event);
|
|
32
|
+
room.attachLocalMedia(this);
|
|
33
|
+
this.armWatchdog();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
enqueue(task) {
|
|
37
|
+
this.queue = this.queue.then(() => this.closed ? undefined : task()).catch(error => this.room.count('linkErrors', error));
|
|
38
|
+
return this.queue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async negotiate() {
|
|
42
|
+
// The negotiation-needed flag only re-fires after it was cleared, so a skipped event is
|
|
43
|
+
// remembered and re-checked once the connection is stable again.
|
|
44
|
+
if (this.suppress || this.closed || this.pc.signalingState !== 'stable') { this.missedNegotiation = true; return; }
|
|
45
|
+
try {
|
|
46
|
+
this.makingOffer = true;
|
|
47
|
+
this.offerIsRestart = this.restartPending;
|
|
48
|
+
await this.pc.setLocalDescription();
|
|
49
|
+
this.sendDescription();
|
|
50
|
+
this.armOfferTimer();
|
|
51
|
+
} catch (error) { this.room.count('negotiationErrors', error); } finally { this.makingOffer = false; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// An offer or answer lost in transit would leave this side in have-local-offer forever,
|
|
55
|
+
// ignoring the other side's offers. Roll back and offer again after offerTimeoutMs.
|
|
56
|
+
armOfferTimer() {
|
|
57
|
+
clearTimeout(this.offerTimer);
|
|
58
|
+
this.offerTimer = setTimeout(() => this.enqueue(async () => {
|
|
59
|
+
if (this.closed || this.pc.signalingState !== 'have-local-offer') return;
|
|
60
|
+
this.room.count('offerTimeouts');
|
|
61
|
+
await this.pc.setLocalDescription({ type: 'rollback' });
|
|
62
|
+
if (this.offerIsRestart) this.restart(); else await this.negotiate();
|
|
63
|
+
}), this.room.timing.offerTimeoutMs);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
needsNegotiation() {
|
|
67
|
+
return this.restartPending || this.pc.getTransceivers().some(t => !t.stopped && t.currentDirection !== 'stopped' &&
|
|
68
|
+
(t.mid === null || t.sender.track && !['sendrecv', 'sendonly'].includes(t.currentDirection)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async sendDescription() {
|
|
72
|
+
const d = this.pc.localDescription;
|
|
73
|
+
if (!d) return;
|
|
74
|
+
if (d.type === 'offer') this.restartPending = false;
|
|
75
|
+
const description = { type: d.type, sdp: d.sdp };
|
|
76
|
+
const epoch = this.room.crypto, caps = await this.room.capsFor(this.id);
|
|
77
|
+
// A rotation during the await closed this link: never seal its old description under the new key.
|
|
78
|
+
if (this.closed || this.room.crypto !== epoch) return;
|
|
79
|
+
this.room.signal(this, { kind: 'description', description, phase: Math.min(this.phase, PHASE.SESSION),
|
|
80
|
+
gateways: [...this.extraGateways], caps });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
remoteUfrags() { return [...(this.pc.remoteDescription?.sdp ?? '').matchAll(UFRAG)].map(m => m[1]); }
|
|
84
|
+
|
|
85
|
+
async onDescription({ description, phase, gateways, caps, n }) {
|
|
86
|
+
const pc = this.pc;
|
|
87
|
+
// Envelopes may take different routes (data channel, several gates); never let an older
|
|
88
|
+
// description overtake a newer one.
|
|
89
|
+
if (Number.isSafeInteger(n)) { if (n <= this.lastDescriptionN) return; this.lastDescriptionN = n; }
|
|
90
|
+
if (caps) this.room.updateCaps(this.id, caps);
|
|
91
|
+
// A restart offer may name gateways the other side now uses: align our ICE servers before
|
|
92
|
+
// answering so our fresh gathering includes the same relays.
|
|
93
|
+
const added = (Array.isArray(gateways) ? gateways : []).filter(id => typeof id === 'string' && id !== this.room.id &&
|
|
94
|
+
id !== this.id && !this.extraGateways.has(id) && this.room.caps.get(id)?.gateway).slice(0, 2);
|
|
95
|
+
for (const id of added) this.extraGateways.add(id);
|
|
96
|
+
if (Number.isInteger(phase) && phase > this.phase && phase <= PHASE.SESSION) this.setPhase(phase);
|
|
97
|
+
if (added.length) this.applyServers(false);
|
|
98
|
+
const readyForOffer = !this.makingOffer && (pc.signalingState === 'stable' || this.settingAnswer);
|
|
99
|
+
const collision = description.type === 'offer' && !readyForOffer;
|
|
100
|
+
this.ignoreOffer = !this.polite && collision;
|
|
101
|
+
if (this.ignoreOffer) return;
|
|
102
|
+
if (description.type === 'offer') this.lastRemoteOfferAt = Date.now();
|
|
103
|
+
this.settingAnswer = description.type === 'answer';
|
|
104
|
+
try { await pc.setRemoteDescription(description); } finally { this.settingAnswer = false; }
|
|
105
|
+
if (description.type === 'answer') clearTimeout(this.offerTimer);
|
|
106
|
+
if (description.type === 'offer') { clearTimeout(this.offerTimer); await pc.setLocalDescription(); this.sendDescription(); }
|
|
107
|
+
await this.flushCandidates();
|
|
108
|
+
this.suppress = false;
|
|
109
|
+
if (this.missedNegotiation && pc.signalingState === 'stable') {
|
|
110
|
+
this.missedNegotiation = false;
|
|
111
|
+
if (this.needsNegotiation()) this.negotiate();
|
|
112
|
+
}
|
|
113
|
+
this.armWatchdog();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async onCandidate({ candidate }) {
|
|
117
|
+
if (!candidate?.candidate) return;
|
|
118
|
+
const ufrags = this.remoteUfrags();
|
|
119
|
+
if (!this.pc.remoteDescription || candidate.usernameFragment && !ufrags.includes(candidate.usernameFragment)) {
|
|
120
|
+
// Candidates for a future ICE generation wait; the oldest are evicted first.
|
|
121
|
+
this.pendingCandidates.push(candidate);
|
|
122
|
+
if (this.pendingCandidates.length > 128) this.pendingCandidates.shift();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
try { await this.pc.addIceCandidate(candidate); } catch (error) { if (!this.ignoreOffer) this.room.count('candidateErrors', error); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async flushCandidates() {
|
|
129
|
+
const pending = this.pendingCandidates; this.pendingCandidates = [];
|
|
130
|
+
const ufrags = this.remoteUfrags();
|
|
131
|
+
for (const c of pending) {
|
|
132
|
+
if (c.usernameFragment && !ufrags.includes(c.usernameFragment)) { this.pendingCandidates.push(c); continue; }
|
|
133
|
+
await this.onCandidate({ candidate: c });
|
|
134
|
+
}
|
|
135
|
+
if (this.pendingCandidates.length > 128) this.pendingCandidates.splice(0, this.pendingCandidates.length - 128);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
setPhase(phase) {
|
|
139
|
+
if (phase <= this.phase || this.closed) return false;
|
|
140
|
+
this.phase = phase;
|
|
141
|
+
this.grace = false; this.armWatchdog(true);
|
|
142
|
+
this.room.emitPath(this);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
applyServers(initiate) {
|
|
147
|
+
if (this.closed) return;
|
|
148
|
+
this.pc.setConfiguration({ ...this.pc.getConfiguration(), iceServers: this.room.iceServersFor(this) });
|
|
149
|
+
if (initiate) this.requestRestart();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
restart() { if (!this.closed) { this.restarts++; this.restartPending = true; this.pc.restartIce(); } }
|
|
153
|
+
|
|
154
|
+
// ICE restarts come from the impolite side only: simultaneous restarts collide, and a rolled
|
|
155
|
+
// back restart offer has been observed to leave Chromium's RTP senders silent. The polite
|
|
156
|
+
// side takes over only if no restart offer arrives within restartFallbackMs.
|
|
157
|
+
requestRestart() {
|
|
158
|
+
if (this.closed) return;
|
|
159
|
+
if (!this.polite) { this.restart(); return; }
|
|
160
|
+
const since = Date.now();
|
|
161
|
+
clearTimeout(this.restartFallback);
|
|
162
|
+
this.restartFallback = setTimeout(() => {
|
|
163
|
+
if (!this.closed && !this.connected && (this.lastRemoteOfferAt ?? 0) < since) this.restart();
|
|
164
|
+
}, this.room.timing.restartFallbackMs);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
refreshServers() {
|
|
168
|
+
if (this.closed || this.connected) return;
|
|
169
|
+
this.applyServers(true);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
armWatchdog(reset = false) {
|
|
173
|
+
if (this.closed || this.connected) return;
|
|
174
|
+
if (this.watchdog && !reset) return;
|
|
175
|
+
clearTimeout(this.watchdog);
|
|
176
|
+
const t = this.room.timing;
|
|
177
|
+
const ms = this.phase === PHASE.ENDPOINT ? t.endpointMs : this.phase === PHASE.SESSION ? t.sessionMs : t.bridgedRetryMs;
|
|
178
|
+
this.watchdog = setTimeout(() => { this.watchdog = null; this.enqueue(() => this.checkProgress()); }, ms);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async checkProgress() {
|
|
182
|
+
if (this.closed || this.connected) return;
|
|
183
|
+
if (!this.grace && this.phase < PHASE.BRIDGED) {
|
|
184
|
+
// Connectivity checks that get answers mean a route is forming: allow one grace period.
|
|
185
|
+
let responses = 0;
|
|
186
|
+
for (const s of (await this.pc.getStats()).values()) if (s.type === 'candidate-pair') responses += s.responsesReceived ?? 0;
|
|
187
|
+
if (responses > 0) { this.grace = true; this.armWatchdog(true); return; }
|
|
188
|
+
}
|
|
189
|
+
this.room.escalate(this);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
onConnectionState() {
|
|
193
|
+
const state = this.pc.connectionState;
|
|
194
|
+
if (state === 'connected') {
|
|
195
|
+
const first = !this.connected; this.connected = true; this.everConnected = true; this.connectedAt ??= Date.now();
|
|
196
|
+
clearTimeout(this.watchdog); this.watchdog = null; clearTimeout(this.recovery); this.recovery = null;
|
|
197
|
+
if (first) this.room.onLinkConnected(this);
|
|
198
|
+
this.room.updatePath(this);
|
|
199
|
+
} else if (state === 'disconnected' || state === 'failed') {
|
|
200
|
+
const was = this.connected; this.connected = false;
|
|
201
|
+
if (was) this.room.onLinkDown(this);
|
|
202
|
+
clearTimeout(this.recovery);
|
|
203
|
+
// Transient loss heals by itself (consent freshness); a failure needs fresh candidates.
|
|
204
|
+
this.recovery = setTimeout(() => this.enqueue(async () => {
|
|
205
|
+
if (this.closed || this.connected) return;
|
|
206
|
+
this.requestRestart();
|
|
207
|
+
this.grace = false; this.armWatchdog(true);
|
|
208
|
+
}), state === 'failed' ? 0 : this.room.timing.recoveryMs);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
onTrack(event) {
|
|
213
|
+
const stream = event.streams[0] ?? null;
|
|
214
|
+
const origin = stream && this.forwardMap.get(stream.id) || this.id;
|
|
215
|
+
if (origin === this.id) {
|
|
216
|
+
const set = this.remoteTracks.get(origin) ?? new Set(); set.add(event.track); this.remoteTracks.set(origin, set);
|
|
217
|
+
event.track.addEventListener('ended', () => set.delete(event.track));
|
|
218
|
+
}
|
|
219
|
+
this.room.onRemoteTrack(this, origin, event.track, stream);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
ownTracks() { return [...(this.remoteTracks.get(this.id) ?? [])].filter(t => t.readyState === 'live'); }
|
|
223
|
+
|
|
224
|
+
addForward(origin, tracks) {
|
|
225
|
+
let entry = this.forwardSenders.get(origin);
|
|
226
|
+
if (!entry) { entry = { stream: new MediaStream(), senders: new Map() }; this.forwardSenders.set(origin, entry); }
|
|
227
|
+
for (const track of tracks) {
|
|
228
|
+
if (entry.senders.has(track.id)) continue;
|
|
229
|
+
entry.stream.addTrack(track);
|
|
230
|
+
entry.senders.set(track.id, this.pc.addTrack(track, entry.stream));
|
|
231
|
+
}
|
|
232
|
+
return entry.stream.id;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
removeForward(origin) {
|
|
236
|
+
const entry = this.forwardSenders.get(origin);
|
|
237
|
+
if (!entry) return;
|
|
238
|
+
this.forwardSenders.delete(origin);
|
|
239
|
+
// Stopping the transceiver frees its m-line for reuse; removeTrack would leave it in the
|
|
240
|
+
// SDP forever and grow every later offer.
|
|
241
|
+
for (const sender of entry.senders.values()) {
|
|
242
|
+
const transceiver = this.pc.getTransceivers().find(t => t.sender === sender);
|
|
243
|
+
try { if (transceiver?.stop) transceiver.stop(); else this.pc.removeTrack(sender); } catch {}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async classify(report) {
|
|
248
|
+
if (this.closed) return null;
|
|
249
|
+
const stats = report ?? await this.pc.getStats();
|
|
250
|
+
let pair;
|
|
251
|
+
for (const s of stats.values()) if (s.type === 'transport' && s.selectedCandidatePairId) pair = stats.get(s.selectedCandidatePairId);
|
|
252
|
+
if (!pair) for (const s of stats.values()) if (s.type === 'candidate-pair' && s.state === 'succeeded' && (s.selected || s.nominated)) { pair = s; break; }
|
|
253
|
+
if (!pair) return null;
|
|
254
|
+
const local = stats.get(pair.localCandidateId), remote = stats.get(pair.remoteCandidateId);
|
|
255
|
+
return {
|
|
256
|
+
local: local?.candidateType, remote: remote?.candidateType,
|
|
257
|
+
protocol: local?.protocol, relayProtocol: local?.relayProtocol ?? null,
|
|
258
|
+
localAddress: local?.address ?? local?.ip ?? null, remoteAddress: remote?.address ?? remote?.ip ?? null,
|
|
259
|
+
relayUrl: local?.url ?? null, rtt: pair.currentRoundTripTime ?? null,
|
|
260
|
+
bytesSent: pair.bytesSent ?? 0, bytesReceived: pair.bytesReceived ?? 0
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
close() {
|
|
265
|
+
if (this.closed) return;
|
|
266
|
+
this.closed = true; this.connected = false;
|
|
267
|
+
clearTimeout(this.watchdog); clearTimeout(this.recovery); clearTimeout(this.restartFallback); clearTimeout(this.offerTimer);
|
|
268
|
+
try { this.control.close(); } catch {}
|
|
269
|
+
try { this.pc.close(); } catch {}
|
|
270
|
+
this.pendingCandidates = []; this.forwardMap.clear(); this.forwardSenders.clear(); this.remoteTracks.clear();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Freehop browser client. An application calls join() automatically when a user enters a room or
|
|
3
|
+
// call; the room secret comes from the application's own room service, never from the user.
|
|
4
|
+
export { join, Room, DEFAULT_TIMING, DEFAULT_LIMITS } from './room.mjs';
|
|
5
|
+
export { PHASE } from './peer.mjs';
|
|
6
|
+
export { deriveRoom, randomId } from './crypto.mjs';
|