wireweave 0.3.41 → 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/package.json +1 -1
- package/src/index.js +1 -1
- package/src/relay-pool.js +227 -4
package/package.json
CHANGED
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,3 +1,6 @@
|
|
|
1
|
+
import { safeSetItem } from './safe-storage.js';
|
|
2
|
+
import * as debug from './debug.js';
|
|
3
|
+
|
|
1
4
|
// Ordered fastest-first by measured connect latency; dead relays removed so
|
|
2
5
|
// signaling reaches a live relay immediately instead of racing dead hosts.
|
|
3
6
|
// (relay.nostr.band / nostr.wine / relay.current.fyi / relay.0xchat.com were
|
|
@@ -12,12 +15,94 @@ const DEFAULT_RELAYS = [
|
|
|
12
15
|
'wss://relay.damus.io'
|
|
13
16
|
];
|
|
14
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 = [
|
|
22
|
+
'wss://relay.nostr.band',
|
|
23
|
+
'wss://nostr.wine',
|
|
24
|
+
'wss://relay.current.fyi',
|
|
25
|
+
'wss://relay.0xchat.com'
|
|
26
|
+
];
|
|
27
|
+
|
|
15
28
|
const SEEN_MAX = 10000;
|
|
16
29
|
const PENDING_MAX = 500;
|
|
17
30
|
const PENDING_TTL_MS = 120000;
|
|
18
31
|
const CONNECT_TIMEOUT_MS = 10000;
|
|
19
32
|
const jitter = (ms) => Math.round(ms * (0.75 + Math.random() * 0.5));
|
|
20
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
|
+
|
|
21
106
|
const lruTouch = (map, key) => {
|
|
22
107
|
if (map.has(key)) { map.delete(key); map.set(key, 1); return false; }
|
|
23
108
|
map.set(key, 1);
|
|
@@ -34,7 +119,7 @@ const fnv1a = (s) => {
|
|
|
34
119
|
const safeSubId = (subId) => subId.length <= 64 ? subId : subId.slice(0, 55) + '-' + fnv1a(subId);
|
|
35
120
|
|
|
36
121
|
export class RelayPool extends EventTarget {
|
|
37
|
-
constructor({ relays = DEFAULT_RELAYS, verifyEvent = null, WebSocketImpl = null } = {}) {
|
|
122
|
+
constructor({ relays = DEFAULT_RELAYS, verifyEvent = null, WebSocketImpl = null, storage = null, fallbackRelays = FALLBACK_RELAYS, autoRotate = true } = {}) {
|
|
38
123
|
super();
|
|
39
124
|
this.urls = [...relays];
|
|
40
125
|
this.relays = new Map();
|
|
@@ -48,6 +133,116 @@ export class RelayPool extends EventTarget {
|
|
|
48
133
|
this.verifyEvent = verifyEvent;
|
|
49
134
|
this.WS = WebSocketImpl || (typeof WebSocket !== 'undefined' ? WebSocket : null);
|
|
50
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);
|
|
51
246
|
}
|
|
52
247
|
|
|
53
248
|
connect() {
|
|
@@ -71,6 +266,9 @@ export class RelayPool extends EventTarget {
|
|
|
71
266
|
}
|
|
72
267
|
}
|
|
73
268
|
this.relays.clear();
|
|
269
|
+
if (this._saveHealthTimer) { clearTimeout(this._saveHealthTimer); this._saveHealthTimer = null; }
|
|
270
|
+
this._saveHealthNow();
|
|
271
|
+
debug.deregister(this._debugKey);
|
|
74
272
|
}
|
|
75
273
|
|
|
76
274
|
_open(url) {
|
|
@@ -78,10 +276,14 @@ export class RelayPool extends EventTarget {
|
|
|
78
276
|
this._reconnectTimers.delete(url);
|
|
79
277
|
const existing = this.relays.get(url);
|
|
80
278
|
if (existing?.ws && (existing.ws.readyState === 0 || existing.ws.readyState === 1)) return;
|
|
81
|
-
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();
|
|
82
281
|
relay.status = 'connecting';
|
|
83
282
|
relay.latencyMs = null;
|
|
84
283
|
this.relays.set(url, relay);
|
|
284
|
+
const health = this._getHealth(url);
|
|
285
|
+
health.recordConnectAttempt();
|
|
286
|
+
const connectStartedAt = Date.now();
|
|
85
287
|
let ws;
|
|
86
288
|
try { ws = new this.WS(url); }
|
|
87
289
|
catch (e) { relay.status = 'error'; this._emit('relay-status', { url, status: 'error' }); return; }
|
|
@@ -95,11 +297,14 @@ export class RelayPool extends EventTarget {
|
|
|
95
297
|
clearTimeout(connectTimer);
|
|
96
298
|
relay.status = 'connected';
|
|
97
299
|
relay._openedAt = Date.now();
|
|
300
|
+
health.recordConnectLatency(relay._openedAt - connectStartedAt);
|
|
301
|
+
this._scheduleSaveHealth();
|
|
98
302
|
this._emit('relay-status', { url, status: 'connected' });
|
|
99
303
|
for (const [subId, sub] of this.subs) {
|
|
100
304
|
ws.send(JSON.stringify(['REQ', subId, ...sub.filters]));
|
|
101
305
|
relay.subIds.add(subId);
|
|
102
306
|
if (!relay._reqSentAt) relay._reqSentAt = Date.now();
|
|
307
|
+
relay._eoseReqSentAt.set(subId, Date.now());
|
|
103
308
|
}
|
|
104
309
|
this._drainPending(url, ws);
|
|
105
310
|
};
|
|
@@ -116,8 +321,16 @@ export class RelayPool extends EventTarget {
|
|
|
116
321
|
relay.status = 'closed';
|
|
117
322
|
this._emit('relay-status', { url, status: 'closed' });
|
|
118
323
|
const sustained = relay._openedAt && Date.now() - relay._openedAt > 5000;
|
|
119
|
-
if (sustained) {
|
|
120
|
-
|
|
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
|
+
}
|
|
121
334
|
relay._openedAt = null;
|
|
122
335
|
if (this._closed) return;
|
|
123
336
|
const t = setTimeout(() => this._open(url), jitter(relay.reconnectDelay));
|
|
@@ -142,6 +355,13 @@ export class RelayPool extends EventTarget {
|
|
|
142
355
|
sub?.onEvent?.(event);
|
|
143
356
|
this._emit('event', { subId, event });
|
|
144
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
|
+
}
|
|
145
365
|
this.subs.get(subId)?.onEose?.();
|
|
146
366
|
this._emit('eose', { subId });
|
|
147
367
|
} else if (type === 'NOTICE') {
|
|
@@ -163,6 +383,8 @@ export class RelayPool extends EventTarget {
|
|
|
163
383
|
relay.ws.send(JSON.stringify(['REQ', subId, ...filters]));
|
|
164
384
|
relay.subIds.add(subId);
|
|
165
385
|
if (!relay._reqSentAt) relay._reqSentAt = Date.now();
|
|
386
|
+
if (!relay._eoseReqSentAt) relay._eoseReqSentAt = new Map();
|
|
387
|
+
relay._eoseReqSentAt.set(subId, Date.now());
|
|
166
388
|
}
|
|
167
389
|
}
|
|
168
390
|
return subId;
|
|
@@ -290,3 +512,4 @@ export class RelayPool extends EventTarget {
|
|
|
290
512
|
}
|
|
291
513
|
|
|
292
514
|
export const createRelayPool = (opts) => new RelayPool(opts);
|
|
515
|
+
export { RelayHealth };
|