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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wireweave",
3
- "version": "0.3.41",
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",
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) { relay.failCount = 0; relay.reconnectDelay = 1000; }
120
- else { relay.failCount++; relay.reconnectDelay = Math.min(relay.reconnectDelay * 2, 30000); }
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 };