wireweave 0.3.27 → 0.3.29

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.27",
3
+ "version": "0.3.29",
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/auth.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { safeSetItem } from './safe-storage.js';
2
+
1
3
  const b2hex = (bytes) => Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
2
4
  const hex2b = (hex) => {
3
5
  const a = new Uint8Array(hex.length / 2);
@@ -39,9 +41,14 @@ export class NostrAuth extends EventTarget {
39
41
  }
40
42
 
41
43
  importKey(input) {
42
- const sk = input.startsWith('nsec')
43
- ? (() => { const d = this.NT.nip19.decode(input); if (d.type !== 'nsec') throw new Error('not nsec'); return d.data; })()
44
- : hex2b(input);
44
+ const trimmed = (input || '').trim();
45
+ if (!trimmed) throw new Error('Enter a key');
46
+ const sk = trimmed.startsWith('nsec')
47
+ ? (() => { const d = this.NT.nip19.decode(trimmed); if (d.type !== 'nsec') throw new Error('not nsec'); return d.data; })()
48
+ : (() => {
49
+ if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) throw new Error('Expected an nsec1… key or a 64-character hex secret key');
50
+ return hex2b(trimmed);
51
+ })();
45
52
  const pk = this.NT.getPublicKey(sk);
46
53
  this._persist(sk, pk);
47
54
  this._emit('login', { pubkey: pk });
@@ -87,8 +94,13 @@ export class NostrAuth extends EventTarget {
87
94
  _persist(sk, pk) {
88
95
  this.privkey = sk;
89
96
  this.pubkey = pk;
90
- this.storage?.setItem('zn_sk', b2hex(sk));
91
- this.storage?.setItem('zn_pk', pk);
97
+ if (!this.storage) return;
98
+ const okSk = safeSetItem(this.storage, this, 'zn_sk', b2hex(sk));
99
+ const okPk = safeSetItem(this.storage, this, 'zn_pk', pk);
100
+ if (!okSk || !okPk) {
101
+ try { this.storage.removeItem('zn_sk'); this.storage.removeItem('zn_pk'); } catch {}
102
+ this._emit('persist-failed', { pubkey: pk });
103
+ }
92
104
  }
93
105
 
94
106
  _emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail })); }
package/src/channels.js CHANGED
@@ -30,12 +30,14 @@ export class Channels extends EventTarget {
30
30
  this.pool.subscribe('channels-' + serverId,
31
31
  [{ kinds: [30078], authors: [ownerPubkey], '#d': [dTag] }],
32
32
  (event) => {
33
+ if (event.pubkey !== ownerPubkey) return;
33
34
  const hasTag = event.tags?.some(t => t[0] === 'd' && t[1] === dTag);
34
35
  if (!hasTag) return;
35
36
  try {
36
37
  const data = JSON.parse(event.content);
37
- this.channels = data.channels || [];
38
- this.categories = data.categories || [];
38
+ if (!data || typeof data !== 'object' || !Array.isArray(data.channels) || !Array.isArray(data.categories)) return;
39
+ this.channels = data.channels;
40
+ this.categories = data.categories;
39
41
  this._emit('updated', { channels: this.channels, categories: this.categories });
40
42
  } catch {}
41
43
  },
package/src/chat.js CHANGED
@@ -12,6 +12,16 @@ export class Chat extends EventTarget {
12
12
  this.activeChannelId = null;
13
13
  this.messages = [];
14
14
  this.profiles = new Map(); this.fetching = new Set();
15
+ this._sendTimes = [];
16
+ this.rateLimitMax = 5;
17
+ this.rateLimitWindowMs = 10000;
18
+ }
19
+
20
+ rateLimitRetryAfterMs() {
21
+ const now = Date.now();
22
+ this._sendTimes = (this._sendTimes || []).filter(t => now - t < this.rateLimitWindowMs);
23
+ if (this._sendTimes.length < this.rateLimitMax) return 0;
24
+ return this._sendTimes[0] + this.rateLimitWindowMs - now;
15
25
  }
16
26
 
17
27
  async send(content, { announcement = false } = {}) {
@@ -19,6 +29,12 @@ export class Chat extends EventTarget {
19
29
  if (!this.auth.isLoggedIn() || !channelId) return;
20
30
  if (announcement && !this.isAdmin(serverId)) return;
21
31
  const trimmed = content.trim(); if (!trimmed) return;
32
+ const retryAfter = this.rateLimitRetryAfterMs();
33
+ if (retryAfter > 0) {
34
+ this._emit('rate-limited', { retryAfterMs: retryAfter });
35
+ return;
36
+ }
37
+ this._sendTimes.push(Date.now());
22
38
  const chanHex = await hexChannelId(channelId, serverId);
23
39
  const tags = [['e', chanHex, '', 'root']];
24
40
  if (announcement) tags.push(['t', 'announcement']);
@@ -71,7 +87,9 @@ export class Chat extends EventTarget {
71
87
 
72
88
  _addMessage(msg) {
73
89
  if (this.messages.find(m => m.id === msg.id)) return;
74
- this.messages = [...this.messages, msg];
90
+ let i = this.messages.length;
91
+ while (i > 0 && this.messages[i - 1].timestamp > msg.timestamp) i--;
92
+ this.messages = [...this.messages.slice(0, i), msg, ...this.messages.slice(i)];
75
93
  this._emit('message', { message: msg });
76
94
  this._emit('messages', { list: this.messages });
77
95
  }
@@ -87,9 +105,14 @@ export class Chat extends EventTarget {
87
105
  if (this.fetching.has(pubkey)) return;
88
106
  this.fetching.add(pubkey);
89
107
  this.pool.subscribe('profile-' + pubkey,
90
- [{ kinds: [0], authors: [pubkey], limit: 1 }],
91
- (event) => { try { this.profiles.set(pubkey, JSON.parse(event.content)); this._emit('profile', { pubkey, profile: this.profiles.get(pubkey) }); } catch {} },
92
- () => { this.pool.unsubscribe('profile-' + pubkey); this.fetching.delete(pubkey); });
108
+ [{ kinds: [0], authors: [pubkey] }],
109
+ (event) => {
110
+ const known = this._profileEvents?.get(pubkey);
111
+ if (known && known >= event.created_at) return;
112
+ (this._profileEvents ||= new Map()).set(pubkey, event.created_at);
113
+ try { this.profiles.set(pubkey, JSON.parse(event.content)); this._emit('profile', { pubkey, profile: this.profiles.get(pubkey) }); } catch {}
114
+ },
115
+ () => { this.fetching.delete(pubkey); });
93
116
  }
94
117
 
95
118
  updateProfile(pubkey, profile) { this.profiles.set(pubkey, profile); this._emit('profile', { pubkey, profile }); }
package/src/relay-pool.js CHANGED
@@ -14,6 +14,7 @@ const DEFAULT_RELAYS = [
14
14
  const SEEN_MAX = 10000;
15
15
  const PENDING_MAX = 500;
16
16
  const PENDING_TTL_MS = 120000;
17
+ const CONNECT_TIMEOUT_MS = 10000;
17
18
  const jitter = (ms) => Math.round(ms * (0.75 + Math.random() * 0.5));
18
19
 
19
20
  const lruTouch = (map, key) => {
@@ -60,6 +61,7 @@ export class RelayPool extends EventTarget {
60
61
  for (const [, rec] of this._acks) { clearTimeout(rec.timer); rec.resolve(false); }
61
62
  this._acks.clear();
62
63
  for (const [, r] of this.relays) {
64
+ if (r._connectTimer) clearTimeout(r._connectTimer);
63
65
  if (r.ws) {
64
66
  r.ws.onclose = null; r.ws.onerror = null; r.ws.onopen = null; r.ws.onmessage = null;
65
67
  if (typeof r.ws.removeAllListeners === 'function') r.ws.removeAllListeners();
@@ -83,7 +85,13 @@ export class RelayPool extends EventTarget {
83
85
  try { ws = new this.WS(url); }
84
86
  catch (e) { relay.status = 'error'; this._emit('relay-status', { url, status: 'error' }); return; }
85
87
  relay.ws = ws;
88
+ const connectTimer = setTimeout(() => {
89
+ if (relay.ws !== ws || relay.status !== 'connecting') return;
90
+ try { ws.close(); } catch {}
91
+ }, CONNECT_TIMEOUT_MS);
92
+ relay._connectTimer = connectTimer;
86
93
  ws.onopen = () => {
94
+ clearTimeout(connectTimer);
87
95
  relay.status = 'connected';
88
96
  relay._openedAt = Date.now();
89
97
  this._emit('relay-status', { url, status: 'connected' });
@@ -92,7 +100,7 @@ export class RelayPool extends EventTarget {
92
100
  relay.subIds.add(subId);
93
101
  if (!relay._reqSentAt) relay._reqSentAt = Date.now();
94
102
  }
95
- this._drainPending();
103
+ this._drainPending(url, ws);
96
104
  };
97
105
  ws.onmessage = (e) => {
98
106
  if (relay._reqSentAt && relay.latencyMs === null) {
@@ -101,8 +109,9 @@ export class RelayPool extends EventTarget {
101
109
  }
102
110
  try { this._handle(url, typeof e.data === 'string' ? e.data : e.data.toString()); } catch {}
103
111
  };
104
- ws.onerror = () => { relay.status = 'error'; this._emit('relay-status', { url, status: 'error' }); };
112
+ ws.onerror = () => { clearTimeout(connectTimer); relay.status = 'error'; this._emit('relay-status', { url, status: 'error' }); };
105
113
  ws.onclose = () => {
114
+ clearTimeout(connectTimer);
106
115
  relay.status = 'closed';
107
116
  this._emit('relay-status', { url, status: 'closed' });
108
117
  const sustained = relay._openedAt && Date.now() - relay._openedAt > 5000;
@@ -171,24 +180,32 @@ export class RelayPool extends EventTarget {
171
180
 
172
181
  publish(event) {
173
182
  let sent = false;
174
- for (const [, relay] of this.relays) {
183
+ let anyDisconnected = false;
184
+ const sentTo = new Set();
185
+ for (const [url, relay] of this.relays) {
175
186
  if (relay.ws?.readyState === 1) {
176
187
  relay.ws.send(JSON.stringify(['EVENT', event]));
177
188
  sent = true;
189
+ sentTo.add(url);
190
+ } else {
191
+ anyDisconnected = true;
178
192
  }
179
193
  }
180
- if (sent) {
181
- if (event?.id) this._pendingIds.delete(event.id);
182
- } else {
183
- this._queuePending(event);
184
- }
194
+ if (anyDisconnected || !sent) this._queuePending(event, sentTo);
195
+ else if (event?.id) this._pendingIds.delete(event.id);
185
196
  return sent;
186
197
  }
187
198
 
188
- _queuePending(event) {
189
- if (event?.id && this._pendingIds.has(event.id)) return;
199
+ // Tracks delivery per relay URL, not just "sent to at least one" — a relay
200
+ // mid-reconnect during a partial outage otherwise never gets the event.
201
+ _queuePending(event, sentTo) {
202
+ if (event?.id && this._pendingIds.has(event.id)) {
203
+ const existing = this.pending.find((p) => p.event?.id === event.id);
204
+ if (existing) { for (const url of sentTo) existing.sentTo.add(url); }
205
+ return;
206
+ }
190
207
  if (event?.id) this._pendingIds.add(event.id);
191
- this.pending.push({ event, ts: Date.now() });
208
+ this.pending.push({ event, sentTo, ts: Date.now() });
192
209
  while (this.pending.length > PENDING_MAX) {
193
210
  const dropped = this.pending.shift();
194
211
  if (dropped.event?.id) this._pendingIds.delete(dropped.event.id);
@@ -218,11 +235,34 @@ export class RelayPool extends EventTarget {
218
235
  if (rec) rec.resolve(accepted);
219
236
  }
220
237
 
221
- _drainPending() {
238
+ _drainPending(url, ws) {
222
239
  const cutoff = Date.now() - PENDING_TTL_MS;
223
- const pending = this.pending.splice(0);
224
- this._pendingIds.clear();
225
- for (const p of pending) { if (p.ts >= cutoff) this.publish(p.event); }
240
+ this.pending = this.pending.filter((entry) => {
241
+ const alive = entry.ts >= cutoff;
242
+ if (!alive && entry.event?.id) this._pendingIds.delete(entry.event.id);
243
+ return alive;
244
+ });
245
+ if (ws) {
246
+ for (const entry of this.pending) {
247
+ if (!entry.sentTo) entry.sentTo = new Set();
248
+ if (entry.sentTo.has(url)) continue;
249
+ ws.send(JSON.stringify(['EVENT', entry.event]));
250
+ entry.sentTo.add(url);
251
+ }
252
+ }
253
+ this.pending = this.pending.filter((entry) => {
254
+ let anyConnected = false;
255
+ let allKnownSent = true;
256
+ for (const [u, r] of this.relays) {
257
+ if (r.ws?.readyState === 1) {
258
+ anyConnected = true;
259
+ if (!entry.sentTo?.has(u)) { allKnownSent = false; break; }
260
+ }
261
+ }
262
+ if (!anyConnected) allKnownSent = false;
263
+ if (allKnownSent && entry.event?.id) this._pendingIds.delete(entry.event.id);
264
+ return !allKnownSent;
265
+ });
226
266
  }
227
267
 
228
268
  isConnected() {
@@ -0,0 +1,17 @@
1
+ const isQuotaError = (err) =>
2
+ err && (err.name === 'QuotaExceededError' || err.name === 'NS_ERROR_DOM_QUOTA_REACHED' || err.code === 22 || err.code === 1014);
3
+
4
+ export function safeSetItem(storage, target, key, value) {
5
+ if (!storage) return false;
6
+ try {
7
+ storage.setItem(key, value);
8
+ return true;
9
+ } catch (err) {
10
+ if (isQuotaError(err)) {
11
+ target?.dispatchEvent?.(new CustomEvent('storage-error', { detail: { key, reason: 'quota', message: 'Storage full — some data may not be saved' } }));
12
+ } else {
13
+ target?.dispatchEvent?.(new CustomEvent('storage-error', { detail: { key, reason: 'unknown', message: err?.message || String(err) } }));
14
+ }
15
+ return false;
16
+ }
17
+ }
package/src/servers.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { safeSetItem } from './safe-storage.js';
2
+
1
3
  export class Servers extends EventTarget {
2
4
  constructor({ relayPool, auth, storage, onSwitch = null }) {
3
5
  super();
@@ -63,7 +65,7 @@ export class Servers extends EventTarget {
63
65
  async join(serverId) {
64
66
  try {
65
67
  const joined = JSON.parse(this.storage.getItem('zn_joined_servers') || '[]');
66
- if (!joined.includes(serverId)) { joined.push(serverId); this.storage.setItem('zn_joined_servers', JSON.stringify(joined)); }
68
+ if (!joined.includes(serverId)) { joined.push(serverId); safeSetItem(this.storage, this, 'zn_joined_servers', JSON.stringify(joined)); }
67
69
  } catch {}
68
70
  if (!this.servers.find(s => s.id === serverId)) { this.servers = [...this.servers, { id: serverId, name: serverId.slice(0, 8), iconColor: '#5865F2' }]; this._persist(); }
69
71
  await this.switchTo(serverId);
@@ -74,7 +76,7 @@ export class Servers extends EventTarget {
74
76
  this._persist();
75
77
  try {
76
78
  const joined = JSON.parse(this.storage.getItem('zn_joined_servers') || '[]');
77
- this.storage.setItem('zn_joined_servers', JSON.stringify(joined.filter(id => id !== serverId)));
79
+ safeSetItem(this.storage, this, 'zn_joined_servers', JSON.stringify(joined.filter(id => id !== serverId)));
78
80
  } catch {}
79
81
  if (this.currentServerId === serverId) await this.switchTo(null);
80
82
  else this._emit('updated', { servers: this.servers });
@@ -84,7 +86,7 @@ export class Servers extends EventTarget {
84
86
 
85
87
  async switchTo(serverId) {
86
88
  this.currentServerId = serverId;
87
- if (serverId) this.storage.setItem('zn_lastServer', serverId); else this.storage.removeItem('zn_lastServer');
89
+ if (serverId) safeSetItem(this.storage, this, 'zn_lastServer', serverId); else this.storage.removeItem('zn_lastServer');
88
90
  this._emit('switched', { serverId });
89
91
  await this.onSwitch?.(serverId);
90
92
  }
@@ -97,7 +99,7 @@ export class Servers extends EventTarget {
97
99
  }
98
100
 
99
101
  getOrder() { try { return JSON.parse(this.storage.getItem('zn_serverOrder') || '[]'); } catch { return []; } }
100
- saveOrder(ids) { this.storage.setItem('zn_serverOrder', JSON.stringify(ids)); this._emit('updated', { servers: this.servers }); }
102
+ saveOrder(ids) { safeSetItem(this.storage, this, 'zn_serverOrder', JSON.stringify(ids)); this._emit('updated', { servers: this.servers }); }
101
103
  sorted() {
102
104
  const order = this.getOrder();
103
105
  if (!order.length) return this.servers;
@@ -105,7 +107,7 @@ export class Servers extends EventTarget {
105
107
  return this.servers.slice().sort((a, b) => (idx[a.id] ?? Infinity) - (idx[b.id] ?? Infinity));
106
108
  }
107
109
 
108
- _persist() { this.storage.setItem('zn_servers', JSON.stringify(this.servers)); }
110
+ _persist() { safeSetItem(this.storage, this, 'zn_servers', JSON.stringify(this.servers)); }
109
111
  _emit(t, d) { this.dispatchEvent(new CustomEvent(t, { detail: d })); }
110
112
  }
111
113
 
package/src/voice.js CHANGED
@@ -56,6 +56,7 @@ export class VoiceSession extends EventTarget {
56
56
  this.muted = false; this.deafened = false;
57
57
  this.sfu = { mode: 'mesh', hub: null, hubLostAt: null, rttMatrix: new Map(), electionTimer: null, statsInterval: null, actor: null };
58
58
  this.retrySchedule = {};
59
+ this._epoch = 0;
59
60
  }
60
61
 
61
62
  _initActor() {
@@ -64,16 +65,31 @@ export class VoiceSession extends EventTarget {
64
65
  this.actor.start();
65
66
  }
66
67
 
68
+ // Reentrancy guard: connect()/disconnect() are async and mutate shared
69
+ // instance state (peers, participants, localStream, roomId) across several
70
+ // await points. Rapid join→leave→rejoin (or a double-click join) used to
71
+ // interleave two in-flight calls: a stale connect() resuming after a newer
72
+ // disconnect() had already torn everything down would re-populate roomId/
73
+ // participants and re-send 'connected' into an actor the fresh call had
74
+ // already returned to idle, leaking the stale heartbeat/presence
75
+ // subscriptions and getUserMedia stream. Every call captures the epoch at
76
+ // entry and re-checks it after each await; a superseded call unwinds
77
+ // whatever it acquired instead of mutating shared state.
67
78
  async connect(channelName, { displayName = 'Guest' } = {}) {
68
79
  if (!this.actor) this._initActor();
69
80
  if (!this.actor.getSnapshot().can({ type: 'connect' })) await this.disconnect();
81
+ const epoch = ++this._epoch;
70
82
  this.actor.send({ type: 'connect' });
71
83
  this.channelName = channelName;
72
84
  this.joinTs = Math.floor(Date.now() / 1000);
73
85
  this.displayName = displayName;
74
86
  try {
75
- this.roomId = await deriveRoomId(this.serverId, channelName);
76
- this.localStream = await this.md.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
87
+ const roomId = await deriveRoomId(this.serverId, channelName);
88
+ if (epoch !== this._epoch) return;
89
+ const stream = await this.md.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
90
+ if (epoch !== this._epoch) { stream.getTracks().forEach(t => t.stop()); return; }
91
+ this.roomId = roomId;
92
+ this.localStream = stream;
77
93
  // PTT default: gate closed at join. Apps that want always-on call setMuted(false).
78
94
  this.muted = true;
79
95
  this.localStream.getAudioTracks().forEach(t => t.enabled = false);
@@ -84,10 +100,12 @@ export class VoiceSession extends EventTarget {
84
100
  this._subscribeSignals();
85
101
  this._subscribePresence();
86
102
  await this._publishPresence('join');
103
+ if (epoch !== this._epoch) return;
87
104
  this._startHeartbeat();
88
105
  this._sfuStart();
89
106
  this._emit('connected', { roomId: this.roomId, channelName });
90
107
  } catch (e) {
108
+ if (epoch !== this._epoch) return;
91
109
  this.actor.send({ type: 'fail' });
92
110
  this._emit('error', { message: 'connect failed: ' + e.message });
93
111
  throw e;
@@ -97,6 +115,7 @@ export class VoiceSession extends EventTarget {
97
115
  async disconnect() {
98
116
  if (!this.actor || this.actor.getSnapshot().matches('idle')) return;
99
117
  if (!this.actor.getSnapshot().can({ type: 'disconnect' })) return;
118
+ ++this._epoch;
100
119
  this.actor.send({ type: 'disconnect' });
101
120
  await this._publishPresence('leave');
102
121
  this._stopHeartbeat();
@@ -659,6 +678,7 @@ export class VoiceSession extends EventTarget {
659
678
  try { peer.dc?.close(); } catch {}
660
679
  try { peer.pc?.close(); } catch {}
661
680
  if (peer.audioEl) { peer.audioEl.srcObject = null; peer.audioEl.remove(); }
681
+ try { peer.fsm?.stop?.(); } catch {}
662
682
  this._detachAnalyzer(peerPubkey);
663
683
  this.peers.delete(peerPubkey);
664
684
  this._emit('peer-closed', { peerPubkey });