wireweave 0.3.40 → 0.3.42
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/README.md +32 -1
- package/package.json +3 -3
- package/src/data.js +27 -5
- package/src/index.js +1 -1
- package/src/relay-pool.js +231 -7
- package/src/servers.js +3 -3
- package/src/voice.js +8 -2
package/README.md
CHANGED
|
@@ -83,7 +83,38 @@ session.broadcast(new Uint8Array(payload)); // → all open peers
|
|
|
83
83
|
session.send(somePeerPubkey, new Uint8Array(p)); // → one peer
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
Options: `dataChannelOptions` (default `{ ordered: true }`)
|
|
86
|
+
Options: `dataChannelOptions` (default `{ ordered: true }`), `iceServers` (override the default STUN/TURN list for this session only), and `createPeerConnection` (see below). Construct multiple `DataSession`s in the same page to use distinct rooms / channel configurations.
|
|
87
|
+
|
|
88
|
+
`setIceServers(list)` / `getIceServers()` (also exported from `wireweave/data` and `wireweave/voice`) override the module-wide default ICE server list for every session created afterward — useful for pointing at your own TURN infrastructure once, instead of passing `iceServers` to each session.
|
|
89
|
+
|
|
90
|
+
## Node hosts behind NAT: `createPeerConnection`
|
|
91
|
+
|
|
92
|
+
Both `DataSession` and `VoiceSession` accept a `createPeerConnection(config)` option. It defaults to a plain `new RTCPeerConnection(config)` (browser-shaped, works as-is with any WebRTC-polyfilled `globalThis`) — wireweave itself never imports a Node-specific WebRTC binding. A Node host that is itself likely to sit behind a restrictive NAT (a CLI tool, a headless server) can instead construct its own natively-tuned peer and hand it back:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
import * as ndc from 'node-datachannel';
|
|
96
|
+
import { RTCPeerConnection as PolyfillRTCPeerConnection } from 'node-datachannel/polyfill';
|
|
97
|
+
|
|
98
|
+
const session = createDataSession({
|
|
99
|
+
fsm, xstate, relayPool: pool, auth, namespace: 'mygame',
|
|
100
|
+
createPeerConnection: (config) => {
|
|
101
|
+
// node-datachannel's native RtcConfig is richer than the W3C shape:
|
|
102
|
+
// enableIceUdpMux shares one UDP port across all peer connections
|
|
103
|
+
// (fewer ports to traverse through a firewall); portRangeBegin/End
|
|
104
|
+
// pins ICE to a fixed range you can port-forward; proxyServer routes
|
|
105
|
+
// ICE through a SOCKS5/HTTP proxy on networks that block direct UDP/TCP.
|
|
106
|
+
const nativePc = new ndc.PeerConnection('peer', {
|
|
107
|
+
iceServers: config.iceServers.map(s => s.urls),
|
|
108
|
+
enableIceUdpMux: true,
|
|
109
|
+
// portRangeBegin: 50000, portRangeEnd: 51000,
|
|
110
|
+
// proxyServer: { type: 'Socks5', ip: '127.0.0.1', port: 1080 }
|
|
111
|
+
});
|
|
112
|
+
return new PolyfillRTCPeerConnection({ peerConnection: nativePc });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
This is opt-in and additive — omit `createPeerConnection` and Node hosts behave exactly as before (a plain polyfilled `RTCPeerConnection`).
|
|
87
118
|
|
|
88
119
|
## game / 3-mode usage pattern
|
|
89
120
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wireweave",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.42",
|
|
4
4
|
"description": "nostr + webrtc voice + data SDK. networking layer for 247420 projects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"nostr-tools": "^2.7.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"nostr-tools": "^2.
|
|
59
|
-
"ws": "^8.
|
|
58
|
+
"nostr-tools": "^2.23.12",
|
|
59
|
+
"ws": "^8.21.1"
|
|
60
60
|
},
|
|
61
61
|
"publishConfig": {
|
|
62
62
|
"access": "public"
|
package/src/data.js
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
// STUN handles same-LAN / non-symmetric-NAT cases; TURN is required when both
|
|
2
|
+
// peers sit behind symmetric or restricted-cone NAT (typical home routers,
|
|
3
|
+
// most carrier-grade NAT, corporate networks). UDP, TCP, and TLS TURN variants
|
|
4
|
+
// are included so at least one path survives strict egress filtering. Kept in
|
|
5
|
+
// sync with voice.js's DEFAULT_ICE_SERVERS.
|
|
6
|
+
const DEFAULT_ICE_SERVERS = [
|
|
2
7
|
{ urls: 'stun:stun.l.google.com:19302' },
|
|
3
8
|
{ urls: 'stun:stun1.l.google.com:19302' },
|
|
4
9
|
{ urls: 'stun:stun.cloudflare.com:3478' },
|
|
5
|
-
{ urls: '
|
|
6
|
-
{ urls: 'turn:
|
|
10
|
+
{ urls: 'stun:global.stun.twilio.com:3478' },
|
|
11
|
+
{ urls: 'turn:global.relay.metered.ca:80', username: 'openrelayproject', credential: 'openrelayproject' },
|
|
12
|
+
{ urls: 'turn:global.relay.metered.ca:80?transport=tcp', username: 'openrelayproject', credential: 'openrelayproject' },
|
|
13
|
+
{ urls: 'turn:global.relay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' },
|
|
14
|
+
{ urls: 'turns:global.relay.metered.ca:443?transport=tcp', username: 'openrelayproject', credential: 'openrelayproject' },
|
|
15
|
+
// Legacy hostname kept as a low-priority fallback in case some deployments still resolve it.
|
|
7
16
|
{ urls: 'turn:openrelay.metered.ca:443?transport=tcp', username: 'openrelayproject', credential: 'openrelayproject' }
|
|
8
17
|
];
|
|
18
|
+
let ICE_SERVERS = DEFAULT_ICE_SERVERS;
|
|
19
|
+
export const setIceServers = (list) => { if (Array.isArray(list) && list.length) ICE_SERVERS = list; };
|
|
20
|
+
export const getIceServers = () => ICE_SERVERS.slice();
|
|
9
21
|
|
|
10
22
|
const PRESENCE_EXPIRY = 300000;
|
|
11
23
|
const HEARTBEAT = 30000;
|
|
@@ -17,14 +29,24 @@ const deriveRoomId = async (namespace, room) => {
|
|
|
17
29
|
return 'wwdata' + Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
|
|
18
30
|
};
|
|
19
31
|
|
|
32
|
+
// Default: a plain browser-shaped RTCPeerConnection. Node hosts that want
|
|
33
|
+
// deeper NAT-traversal tuning (ICE/UDP port muxing, a fixed port range for
|
|
34
|
+
// port-forwarded firewalls, a SOCKS5/HTTP proxy for locked-down networks)
|
|
35
|
+
// can pass createPeerConnection to construct their own native peer (e.g.
|
|
36
|
+
// node-datachannel's PeerConnection with its richer RtcConfig) and hand it
|
|
37
|
+
// back wrapped as an RTCPeerConnection-shaped object — wireweave itself never
|
|
38
|
+
// depends on any Node-specific WebRTC binding.
|
|
39
|
+
const defaultCreatePeerConnection = (config) => new RTCPeerConnection(config);
|
|
40
|
+
|
|
20
41
|
export class DataSession extends EventTarget {
|
|
21
|
-
constructor({ fsm, xstate, relayPool, auth, namespace = '', dataChannelOptions = { ordered: true }, iceServers = null }) {
|
|
42
|
+
constructor({ fsm, xstate, relayPool, auth, namespace = '', dataChannelOptions = { ordered: true }, iceServers = null, createPeerConnection = defaultCreatePeerConnection }) {
|
|
22
43
|
super();
|
|
23
44
|
if (!fsm || !xstate || !relayPool || !auth) throw new Error('DataSession: missing deps');
|
|
24
45
|
this.fsm = fsm; this.xstate = xstate; this.pool = relayPool; this.auth = auth;
|
|
25
46
|
this.namespace = namespace;
|
|
26
47
|
this.dcOptions = dataChannelOptions;
|
|
27
48
|
this.iceServers = iceServers || ICE_SERVERS;
|
|
49
|
+
this.createPeerConnection = createPeerConnection;
|
|
28
50
|
this.actor = null;
|
|
29
51
|
this.room = ''; this.roomId = '';
|
|
30
52
|
this.peers = new Map(); this.participants = new Map();
|
|
@@ -152,7 +174,7 @@ export class DataSession extends EventTarget {
|
|
|
152
174
|
fsmActor.start();
|
|
153
175
|
const peer = { pc: null, dc: null, pendingCandidates: [], bufferedCandidates: [], iceTimer: null, disconnectTimer: null, failCount: 0, state: 'new', fsm: fsmActor, remoteDescSet: false };
|
|
154
176
|
this.peers.set(peerPubkey, peer);
|
|
155
|
-
const pc =
|
|
177
|
+
const pc = this.createPeerConnection({ iceServers: this.iceServers, bundlePolicy: 'max-bundle', iceCandidatePoolSize: 4, iceTransportPolicy: 'all' });
|
|
156
178
|
peer.pc = pc;
|
|
157
179
|
const isOfferer = this.auth.pubkey > peerPubkey;
|
|
158
180
|
this._wirePeer(peer, peerPubkey, fsmActor, isOfferer);
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { RelayPool, createRelayPool } from './relay-pool.js';
|
|
1
|
+
export { RelayPool, createRelayPool, RelayHealth } from './relay-pool.js';
|
|
2
2
|
export { NostrAuth, createAuth } from './auth.js';
|
|
3
3
|
export { createFSM } from './fsm.js';
|
|
4
4
|
export { VoiceSession, createVoiceSession } from './voice.js';
|
package/src/relay-pool.js
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
|
+
import { safeSetItem } from './safe-storage.js';
|
|
2
|
+
import * as debug from './debug.js';
|
|
3
|
+
|
|
4
|
+
// Ordered fastest-first by measured connect latency; dead relays removed so
|
|
5
|
+
// signaling reaches a live relay immediately instead of racing dead hosts.
|
|
6
|
+
// (relay.nostr.band / nostr.wine / relay.current.fyi / relay.0xchat.com were
|
|
7
|
+
// unreachable — DNS-dead or refusing connections — and only added connect
|
|
8
|
+
// latency and console noise. Re-audit periodically.)
|
|
1
9
|
const DEFAULT_RELAYS = [
|
|
2
|
-
'wss://relay.damus.io',
|
|
3
10
|
'wss://relay.primal.net',
|
|
4
11
|
'wss://nos.lol',
|
|
12
|
+
'wss://offchain.pub',
|
|
13
|
+
'wss://nostr-pub.wellorder.net',
|
|
5
14
|
'wss://relay.snort.social',
|
|
15
|
+
'wss://relay.damus.io'
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// Backup pool candidates auto-rotation can promote in when a connected relay's
|
|
19
|
+
// health score falls below a healthy alternative's — never DEFAULT_RELAYS
|
|
20
|
+
// members already in play, so rotation always trades toward strictly-better.
|
|
21
|
+
const FALLBACK_RELAYS = [
|
|
6
22
|
'wss://relay.nostr.band',
|
|
7
23
|
'wss://nostr.wine',
|
|
8
24
|
'wss://relay.current.fyi',
|
|
9
|
-
'wss://offchain.pub',
|
|
10
|
-
'wss://nostr-pub.wellorder.net',
|
|
11
25
|
'wss://relay.0xchat.com'
|
|
12
26
|
];
|
|
13
27
|
|
|
@@ -17,6 +31,78 @@ const PENDING_TTL_MS = 120000;
|
|
|
17
31
|
const CONNECT_TIMEOUT_MS = 10000;
|
|
18
32
|
const jitter = (ms) => Math.round(ms * (0.75 + Math.random() * 0.5));
|
|
19
33
|
|
|
34
|
+
// EWMA smoothing for latency/EOSE-speed samples — recent samples dominate but
|
|
35
|
+
// a single slow sample doesn't crater a relay's score outright.
|
|
36
|
+
const EWMA_ALPHA = 0.3;
|
|
37
|
+
const ewma = (prev, sample) => prev === null ? sample : prev + EWMA_ALPHA * (sample - prev);
|
|
38
|
+
|
|
39
|
+
const HEALTH_STORAGE_KEY = 'ww_relay_health';
|
|
40
|
+
const HEALTH_SAVE_DEBOUNCE_MS = 2000;
|
|
41
|
+
|
|
42
|
+
// health.rank: 0..100, higher is better. Weighted blend of connect latency,
|
|
43
|
+
// EOSE response speed, and uptime ratio (successful sustained connections vs
|
|
44
|
+
// total attempts). Missing samples (relay never connected / no EOSE seen yet)
|
|
45
|
+
// score neutral (50) for that component rather than zero, so a fresh relay
|
|
46
|
+
// isn't unfairly punished before it has a chance to report real numbers.
|
|
47
|
+
const scoreLatency = (ms) => ms === null ? 50 : Math.max(0, 100 - Math.min(ms, 3000) / 30);
|
|
48
|
+
const scoreEose = (ms) => ms === null ? 50 : Math.max(0, 100 - Math.min(ms, 5000) / 50);
|
|
49
|
+
const scoreUptime = (attempts, successes) => attempts === 0 ? 50 : Math.round((successes / attempts) * 100);
|
|
50
|
+
|
|
51
|
+
const computeRank = (health) => Math.round(
|
|
52
|
+
0.35 * scoreLatency(health.connectLatencyMs) +
|
|
53
|
+
0.35 * scoreEose(health.eoseLatencyMs) +
|
|
54
|
+
0.30 * scoreUptime(health.attempts, health.successes)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
class RelayHealth {
|
|
58
|
+
constructor(url) {
|
|
59
|
+
this.url = url;
|
|
60
|
+
this.connectLatencyMs = null;
|
|
61
|
+
this.eoseLatencyMs = null;
|
|
62
|
+
this.attempts = 0;
|
|
63
|
+
this.successes = 0;
|
|
64
|
+
this.rank = 50;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
recordConnectAttempt() { this.attempts++; }
|
|
68
|
+
|
|
69
|
+
recordConnectLatency(ms) {
|
|
70
|
+
this.connectLatencyMs = ewma(this.connectLatencyMs, ms);
|
|
71
|
+
this.rank = computeRank(this);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
recordSustainedConnection() {
|
|
75
|
+
this.successes++;
|
|
76
|
+
this.rank = computeRank(this);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
recordEoseLatency(ms) {
|
|
80
|
+
this.eoseLatencyMs = ewma(this.eoseLatencyMs, ms);
|
|
81
|
+
this.rank = computeRank(this);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
toJSON() {
|
|
85
|
+
return {
|
|
86
|
+
url: this.url,
|
|
87
|
+
connectLatencyMs: this.connectLatencyMs,
|
|
88
|
+
eoseLatencyMs: this.eoseLatencyMs,
|
|
89
|
+
attempts: this.attempts,
|
|
90
|
+
successes: this.successes,
|
|
91
|
+
rank: this.rank
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static fromJSON(obj) {
|
|
96
|
+
const h = new RelayHealth(obj.url);
|
|
97
|
+
h.connectLatencyMs = obj.connectLatencyMs ?? null;
|
|
98
|
+
h.eoseLatencyMs = obj.eoseLatencyMs ?? null;
|
|
99
|
+
h.attempts = obj.attempts ?? 0;
|
|
100
|
+
h.successes = obj.successes ?? 0;
|
|
101
|
+
h.rank = obj.rank ?? computeRank(h);
|
|
102
|
+
return h;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
20
106
|
const lruTouch = (map, key) => {
|
|
21
107
|
if (map.has(key)) { map.delete(key); map.set(key, 1); return false; }
|
|
22
108
|
map.set(key, 1);
|
|
@@ -33,7 +119,7 @@ const fnv1a = (s) => {
|
|
|
33
119
|
const safeSubId = (subId) => subId.length <= 64 ? subId : subId.slice(0, 55) + '-' + fnv1a(subId);
|
|
34
120
|
|
|
35
121
|
export class RelayPool extends EventTarget {
|
|
36
|
-
constructor({ relays = DEFAULT_RELAYS, verifyEvent = null, WebSocketImpl = null } = {}) {
|
|
122
|
+
constructor({ relays = DEFAULT_RELAYS, verifyEvent = null, WebSocketImpl = null, storage = null, fallbackRelays = FALLBACK_RELAYS, autoRotate = true } = {}) {
|
|
37
123
|
super();
|
|
38
124
|
this.urls = [...relays];
|
|
39
125
|
this.relays = new Map();
|
|
@@ -47,6 +133,116 @@ export class RelayPool extends EventTarget {
|
|
|
47
133
|
this.verifyEvent = verifyEvent;
|
|
48
134
|
this.WS = WebSocketImpl || (typeof WebSocket !== 'undefined' ? WebSocket : null);
|
|
49
135
|
if (!this.WS) throw new Error('No WebSocket implementation available');
|
|
136
|
+
|
|
137
|
+
this.storage = storage;
|
|
138
|
+
this.autoRotate = autoRotate;
|
|
139
|
+
this.fallbackRelays = [...fallbackRelays];
|
|
140
|
+
this.health = new Map();
|
|
141
|
+
for (const url of this.urls) this.health.set(url, new RelayHealth(url));
|
|
142
|
+
this._loadHealth();
|
|
143
|
+
this._saveHealthTimer = null;
|
|
144
|
+
|
|
145
|
+
// Debug-panel integration: window.__wireweave.relayPool (or relayPool2,
|
|
146
|
+
// relayPool3... for additional instances) exposes healthReport() live.
|
|
147
|
+
this._debugKey = 'relayPool';
|
|
148
|
+
let n = 2;
|
|
149
|
+
while (debug.get(this._debugKey)) this._debugKey = 'relayPool' + n++;
|
|
150
|
+
debug.register(this._debugKey, this);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Health scoring / persistence -------------------------------------
|
|
154
|
+
|
|
155
|
+
_getHealth(url) {
|
|
156
|
+
let h = this.health.get(url);
|
|
157
|
+
if (!h) { h = new RelayHealth(url); this.health.set(url, h); }
|
|
158
|
+
return h;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_loadHealth() {
|
|
162
|
+
if (!this.storage) return;
|
|
163
|
+
try {
|
|
164
|
+
const raw = this.storage.getItem(HEALTH_STORAGE_KEY);
|
|
165
|
+
if (!raw) return;
|
|
166
|
+
const parsed = JSON.parse(raw);
|
|
167
|
+
if (!Array.isArray(parsed)) return;
|
|
168
|
+
for (const entry of parsed) {
|
|
169
|
+
if (!entry?.url) continue;
|
|
170
|
+
this.health.set(entry.url, RelayHealth.fromJSON(entry));
|
|
171
|
+
}
|
|
172
|
+
} catch { /* corrupt/absent persisted health is non-fatal — scores rebuild live */ }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
_scheduleSaveHealth() {
|
|
176
|
+
if (!this.storage) return;
|
|
177
|
+
if (this._saveHealthTimer) return;
|
|
178
|
+
this._saveHealthTimer = setTimeout(() => {
|
|
179
|
+
this._saveHealthTimer = null;
|
|
180
|
+
this._saveHealthNow();
|
|
181
|
+
}, HEALTH_SAVE_DEBOUNCE_MS);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_saveHealthNow() {
|
|
185
|
+
if (!this.storage) return;
|
|
186
|
+
const out = [];
|
|
187
|
+
for (const [, h] of this.health) out.push(h.toJSON());
|
|
188
|
+
safeSetItem(this.storage, this, HEALTH_STORAGE_KEY, JSON.stringify(out));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Sorted best-first snapshot for a debug panel / inspection.
|
|
192
|
+
healthReport() {
|
|
193
|
+
const out = [];
|
|
194
|
+
for (const [, h] of this.health) out.push(h.toJSON());
|
|
195
|
+
out.sort((a, b) => b.rank - a.rank);
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Swap the worst currently-active relay for the best-ranked unused
|
|
200
|
+
// candidate (fallback pool, or a previously-tried relay we disconnected
|
|
201
|
+
// from) when the gap is large enough to be worth the churn of a new
|
|
202
|
+
// connection. Never rotates below MIN_ACTIVE_RELAYS active URLs.
|
|
203
|
+
_maybeRotate() {
|
|
204
|
+
if (!this.autoRotate || this._closed) return;
|
|
205
|
+
const MIN_ACTIVE_RELAYS = 2;
|
|
206
|
+
const ROTATE_GAP = 20;
|
|
207
|
+
if (this.urls.length <= MIN_ACTIVE_RELAYS) return;
|
|
208
|
+
|
|
209
|
+
const active = this.urls
|
|
210
|
+
.map((url) => this._getHealth(url))
|
|
211
|
+
.filter((h) => h.attempts >= 2); // need a couple of samples before judging
|
|
212
|
+
if (active.length === 0) return;
|
|
213
|
+
const worst = active.reduce((a, b) => (a.rank <= b.rank ? a : b));
|
|
214
|
+
|
|
215
|
+
const candidates = this.fallbackRelays.filter((u) => !this.urls.includes(u));
|
|
216
|
+
if (candidates.length === 0) return;
|
|
217
|
+
const candidateHealth = candidates.map((u) => this._getHealth(u));
|
|
218
|
+
const best = candidateHealth.reduce((a, b) => (a.rank >= b.rank ? a : b));
|
|
219
|
+
// Only rotate toward a candidate with real observed history beating the
|
|
220
|
+
// worst active relay by a wide margin — an untested candidate (rank 50
|
|
221
|
+
// neutral default) never displaces a relay with a real track record.
|
|
222
|
+
if (best.attempts === 0) return;
|
|
223
|
+
if (best.rank - worst.rank < ROTATE_GAP) return;
|
|
224
|
+
|
|
225
|
+
this._rotate(worst.url, best.url);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
_rotate(outUrl, inUrl) {
|
|
229
|
+
const idx = this.urls.indexOf(outUrl);
|
|
230
|
+
if (idx === -1) return;
|
|
231
|
+
this.urls[idx] = inUrl;
|
|
232
|
+
this.fallbackRelays = this.fallbackRelays.filter((u) => u !== inUrl);
|
|
233
|
+
this.fallbackRelays.push(outUrl);
|
|
234
|
+
|
|
235
|
+
const old = this.relays.get(outUrl);
|
|
236
|
+
if (old) {
|
|
237
|
+
if (old._connectTimer) clearTimeout(old._connectTimer);
|
|
238
|
+
if (old.ws) { old.ws.onclose = null; old.ws.onerror = null; old.ws.onopen = null; old.ws.onmessage = null; try { old.ws.close(); } catch {} }
|
|
239
|
+
this.relays.delete(outUrl);
|
|
240
|
+
}
|
|
241
|
+
const t = this._reconnectTimers.get(outUrl);
|
|
242
|
+
if (t) { clearTimeout(t); this._reconnectTimers.delete(outUrl); }
|
|
243
|
+
|
|
244
|
+
this._emit('relay-rotated', { out: outUrl, in: inUrl, outRank: this._getHealth(outUrl).rank, inRank: this._getHealth(inUrl).rank });
|
|
245
|
+
if (!this._closed) this._open(inUrl);
|
|
50
246
|
}
|
|
51
247
|
|
|
52
248
|
connect() {
|
|
@@ -70,6 +266,9 @@ export class RelayPool extends EventTarget {
|
|
|
70
266
|
}
|
|
71
267
|
}
|
|
72
268
|
this.relays.clear();
|
|
269
|
+
if (this._saveHealthTimer) { clearTimeout(this._saveHealthTimer); this._saveHealthTimer = null; }
|
|
270
|
+
this._saveHealthNow();
|
|
271
|
+
debug.deregister(this._debugKey);
|
|
73
272
|
}
|
|
74
273
|
|
|
75
274
|
_open(url) {
|
|
@@ -77,10 +276,14 @@ export class RelayPool extends EventTarget {
|
|
|
77
276
|
this._reconnectTimers.delete(url);
|
|
78
277
|
const existing = this.relays.get(url);
|
|
79
278
|
if (existing?.ws && (existing.ws.readyState === 0 || existing.ws.readyState === 1)) return;
|
|
80
|
-
const relay = existing || { ws: null, status: 'connecting', subIds: new Set(), latencyMs: null, failCount: 0, reconnectDelay: 1000, _reqSentAt: null, _openedAt: null };
|
|
279
|
+
const relay = existing || { ws: null, status: 'connecting', subIds: new Set(), latencyMs: null, failCount: 0, reconnectDelay: 1000, _reqSentAt: null, _openedAt: null, _eoseReqSentAt: new Map() };
|
|
280
|
+
if (!relay._eoseReqSentAt) relay._eoseReqSentAt = new Map();
|
|
81
281
|
relay.status = 'connecting';
|
|
82
282
|
relay.latencyMs = null;
|
|
83
283
|
this.relays.set(url, relay);
|
|
284
|
+
const health = this._getHealth(url);
|
|
285
|
+
health.recordConnectAttempt();
|
|
286
|
+
const connectStartedAt = Date.now();
|
|
84
287
|
let ws;
|
|
85
288
|
try { ws = new this.WS(url); }
|
|
86
289
|
catch (e) { relay.status = 'error'; this._emit('relay-status', { url, status: 'error' }); return; }
|
|
@@ -94,11 +297,14 @@ export class RelayPool extends EventTarget {
|
|
|
94
297
|
clearTimeout(connectTimer);
|
|
95
298
|
relay.status = 'connected';
|
|
96
299
|
relay._openedAt = Date.now();
|
|
300
|
+
health.recordConnectLatency(relay._openedAt - connectStartedAt);
|
|
301
|
+
this._scheduleSaveHealth();
|
|
97
302
|
this._emit('relay-status', { url, status: 'connected' });
|
|
98
303
|
for (const [subId, sub] of this.subs) {
|
|
99
304
|
ws.send(JSON.stringify(['REQ', subId, ...sub.filters]));
|
|
100
305
|
relay.subIds.add(subId);
|
|
101
306
|
if (!relay._reqSentAt) relay._reqSentAt = Date.now();
|
|
307
|
+
relay._eoseReqSentAt.set(subId, Date.now());
|
|
102
308
|
}
|
|
103
309
|
this._drainPending(url, ws);
|
|
104
310
|
};
|
|
@@ -115,8 +321,16 @@ export class RelayPool extends EventTarget {
|
|
|
115
321
|
relay.status = 'closed';
|
|
116
322
|
this._emit('relay-status', { url, status: 'closed' });
|
|
117
323
|
const sustained = relay._openedAt && Date.now() - relay._openedAt > 5000;
|
|
118
|
-
if (sustained) {
|
|
119
|
-
|
|
324
|
+
if (sustained) {
|
|
325
|
+
relay.failCount = 0;
|
|
326
|
+
relay.reconnectDelay = 1000;
|
|
327
|
+
health.recordSustainedConnection();
|
|
328
|
+
this._scheduleSaveHealth();
|
|
329
|
+
this._maybeRotate();
|
|
330
|
+
} else {
|
|
331
|
+
relay.failCount++;
|
|
332
|
+
relay.reconnectDelay = Math.min(relay.reconnectDelay * 2, 30000);
|
|
333
|
+
}
|
|
120
334
|
relay._openedAt = null;
|
|
121
335
|
if (this._closed) return;
|
|
122
336
|
const t = setTimeout(() => this._open(url), jitter(relay.reconnectDelay));
|
|
@@ -141,6 +355,13 @@ export class RelayPool extends EventTarget {
|
|
|
141
355
|
sub?.onEvent?.(event);
|
|
142
356
|
this._emit('event', { subId, event });
|
|
143
357
|
} else if (type === 'EOSE') {
|
|
358
|
+
const relay = this.relays.get(url);
|
|
359
|
+
const sentAt = relay?._eoseReqSentAt?.get(subId);
|
|
360
|
+
if (sentAt) {
|
|
361
|
+
relay._eoseReqSentAt.delete(subId);
|
|
362
|
+
this._getHealth(url).recordEoseLatency(Date.now() - sentAt);
|
|
363
|
+
this._scheduleSaveHealth();
|
|
364
|
+
}
|
|
144
365
|
this.subs.get(subId)?.onEose?.();
|
|
145
366
|
this._emit('eose', { subId });
|
|
146
367
|
} else if (type === 'NOTICE') {
|
|
@@ -162,6 +383,8 @@ export class RelayPool extends EventTarget {
|
|
|
162
383
|
relay.ws.send(JSON.stringify(['REQ', subId, ...filters]));
|
|
163
384
|
relay.subIds.add(subId);
|
|
164
385
|
if (!relay._reqSentAt) relay._reqSentAt = Date.now();
|
|
386
|
+
if (!relay._eoseReqSentAt) relay._eoseReqSentAt = new Map();
|
|
387
|
+
relay._eoseReqSentAt.set(subId, Date.now());
|
|
165
388
|
}
|
|
166
389
|
}
|
|
167
390
|
return subId;
|
|
@@ -289,3 +512,4 @@ export class RelayPool extends EventTarget {
|
|
|
289
512
|
}
|
|
290
513
|
|
|
291
514
|
export const createRelayPool = (opts) => new RelayPool(opts);
|
|
515
|
+
export { RelayHealth };
|
package/src/servers.js
CHANGED
|
@@ -62,13 +62,13 @@ export class Servers extends EventTarget {
|
|
|
62
62
|
await this.switchTo(serverId);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
async join(serverId) {
|
|
65
|
+
async join(serverId, { name = null, iconColor = '#5865F2', select = true } = {}) {
|
|
66
66
|
try {
|
|
67
67
|
const joined = JSON.parse(this.storage.getItem('zn_joined_servers') || '[]');
|
|
68
68
|
if (!joined.includes(serverId)) { joined.push(serverId); safeSetItem(this.storage, this, 'zn_joined_servers', JSON.stringify(joined)); }
|
|
69
69
|
} catch {}
|
|
70
|
-
if (!this.servers.find(s => s.id === serverId)) { this.servers = [...this.servers, { id: serverId, name: serverId.slice(0, 8), iconColor
|
|
71
|
-
await this.switchTo(serverId);
|
|
70
|
+
if (!this.servers.find(s => s.id === serverId)) { this.servers = [...this.servers, { id: serverId, name: name || serverId.slice(0, 8), iconColor }]; this._persist(); this._emit('updated', { servers: this.servers }); }
|
|
71
|
+
if (select) await this.switchTo(serverId);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
async delete(serverId) {
|
package/src/voice.js
CHANGED
|
@@ -41,13 +41,19 @@ const deriveRoomId = async (serverId, channel) => {
|
|
|
41
41
|
return 'zellous' + Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
// See data.js's defaultCreatePeerConnection for why this hook exists: it lets
|
|
45
|
+
// a Node host inject a natively-tuned peer (ICE/UDP muxing, fixed port range,
|
|
46
|
+
// proxy passthrough) without wireweave depending on any Node WebRTC binding.
|
|
47
|
+
const defaultCreatePeerConnection = (config) => new RTCPeerConnection(config);
|
|
48
|
+
|
|
44
49
|
export class VoiceSession extends EventTarget {
|
|
45
|
-
constructor({ fsm, xstate, relayPool, auth, mediaDevices, bans = null, serverId = '', onAudioTrack = null, onVideoTrack = null }) {
|
|
50
|
+
constructor({ fsm, xstate, relayPool, auth, mediaDevices, bans = null, serverId = '', onAudioTrack = null, onVideoTrack = null, createPeerConnection = defaultCreatePeerConnection }) {
|
|
46
51
|
super();
|
|
47
52
|
if (!fsm || !xstate || !relayPool || !auth || !mediaDevices) throw new Error('VoiceSession: missing deps');
|
|
48
53
|
this.fsm = fsm; this.xstate = xstate; this.pool = relayPool; this.auth = auth; this.md = mediaDevices; this.bans = bans;
|
|
49
54
|
this.serverId = serverId;
|
|
50
55
|
this.onAudioTrack = onAudioTrack; this.onVideoTrack = onVideoTrack;
|
|
56
|
+
this.createPeerConnection = createPeerConnection;
|
|
51
57
|
this.actor = null;
|
|
52
58
|
this.channelName = ''; this.roomId = '';
|
|
53
59
|
this.peers = new Map(); this.participants = new Map();
|
|
@@ -520,7 +526,7 @@ export class VoiceSession extends EventTarget {
|
|
|
520
526
|
fsmActor.start();
|
|
521
527
|
const peer = { pc: null, audioEl: null, pendingCandidates: [], bufferedCandidates: [], iceTimer: null, disconnectTimer: null, failCount: 0, state: 'new', fsm: fsmActor, _stallInterval: null, remoteDescSet: false, trackEndedRestart: false };
|
|
522
528
|
this.peers.set(peerPubkey, peer);
|
|
523
|
-
const pc =
|
|
529
|
+
const pc = this.createPeerConnection({ iceServers: ICE_SERVERS, bundlePolicy: 'max-bundle', iceCandidatePoolSize: 4, iceTransportPolicy: 'all' });
|
|
524
530
|
peer.pc = pc;
|
|
525
531
|
const isOfferer = this.auth.pubkey > peerPubkey;
|
|
526
532
|
if (isOfferer) {
|