wireweave 0.3.26 → 0.3.28

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.26",
3
+ "version": "0.3.28",
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
@@ -71,7 +71,9 @@ export class Chat extends EventTarget {
71
71
 
72
72
  _addMessage(msg) {
73
73
  if (this.messages.find(m => m.id === msg.id)) return;
74
- this.messages = [...this.messages, msg];
74
+ let i = this.messages.length;
75
+ while (i > 0 && this.messages[i - 1].timestamp > msg.timestamp) i--;
76
+ this.messages = [...this.messages.slice(0, i), msg, ...this.messages.slice(i)];
75
77
  this._emit('message', { message: msg });
76
78
  this._emit('messages', { list: this.messages });
77
79
  }
@@ -87,9 +89,14 @@ export class Chat extends EventTarget {
87
89
  if (this.fetching.has(pubkey)) return;
88
90
  this.fetching.add(pubkey);
89
91
  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); });
92
+ [{ kinds: [0], authors: [pubkey] }],
93
+ (event) => {
94
+ const known = this._profileEvents?.get(pubkey);
95
+ if (known && known >= event.created_at) return;
96
+ (this._profileEvents ||= new Map()).set(pubkey, event.created_at);
97
+ try { this.profiles.set(pubkey, JSON.parse(event.content)); this._emit('profile', { pubkey, profile: this.profiles.get(pubkey) }); } catch {}
98
+ },
99
+ () => { this.fetching.delete(pubkey); });
93
100
  }
94
101
 
95
102
  updateProfile(pubkey, profile) { this.profiles.set(pubkey, profile); this._emit('profile', { pubkey, profile }); }
package/src/relay-pool.js CHANGED
@@ -38,8 +38,10 @@ export class RelayPool extends EventTarget {
38
38
  this.relays = new Map();
39
39
  this.subs = new Map();
40
40
  this.pending = [];
41
+ this._pendingIds = new Set();
41
42
  this.seen = new Map();
42
43
  this._reconnectTimers = new Map();
44
+ this._acks = new Map();
43
45
  this._closed = false;
44
46
  this.verifyEvent = verifyEvent;
45
47
  this.WS = WebSocketImpl || (typeof WebSocket !== 'undefined' ? WebSocket : null);
@@ -55,6 +57,8 @@ export class RelayPool extends EventTarget {
55
57
  this._closed = true;
56
58
  for (const [, t] of this._reconnectTimers) clearTimeout(t);
57
59
  this._reconnectTimers.clear();
60
+ for (const [, rec] of this._acks) { clearTimeout(rec.timer); rec.resolve(false); }
61
+ this._acks.clear();
58
62
  for (const [, r] of this.relays) {
59
63
  if (r.ws) {
60
64
  r.ws.onclose = null; r.ws.onerror = null; r.ws.onopen = null; r.ws.onmessage = null;
@@ -88,7 +92,7 @@ export class RelayPool extends EventTarget {
88
92
  relay.subIds.add(subId);
89
93
  if (!relay._reqSentAt) relay._reqSentAt = Date.now();
90
94
  }
91
- this._drainPending();
95
+ this._drainPending(url, ws);
92
96
  };
93
97
  ws.onmessage = (e) => {
94
98
  if (relay._reqSentAt && relay.latencyMs === null) {
@@ -132,8 +136,12 @@ export class RelayPool extends EventTarget {
132
136
  this._emit('eose', { subId });
133
137
  } else if (type === 'NOTICE') {
134
138
  this._emit('notice', { url, message: msg[1] });
135
- } else if (type === 'OK' && !msg[2]) {
136
- this._emit('reject', { id: msg[1], reason: msg[3] || '' });
139
+ } else if (type === 'OK') {
140
+ const accepted = msg[2] === true;
141
+ const id = msg[1], reason = msg[3] || '';
142
+ if (accepted) this._emit('ok', { url, id });
143
+ else this._emit('reject', { url, id, reason });
144
+ this._settleAck(id, accepted, reason);
137
145
  }
138
146
  }
139
147
 
@@ -163,23 +171,89 @@ export class RelayPool extends EventTarget {
163
171
 
164
172
  publish(event) {
165
173
  let sent = false;
166
- for (const [, relay] of this.relays) {
174
+ let anyDisconnected = false;
175
+ const sentTo = new Set();
176
+ for (const [url, relay] of this.relays) {
167
177
  if (relay.ws?.readyState === 1) {
168
178
  relay.ws.send(JSON.stringify(['EVENT', event]));
169
179
  sent = true;
180
+ sentTo.add(url);
181
+ } else {
182
+ anyDisconnected = true;
170
183
  }
171
184
  }
172
- if (!sent) {
173
- this.pending.push({ event, ts: Date.now() });
174
- if (this.pending.length > PENDING_MAX) this.pending.splice(0, this.pending.length - PENDING_MAX);
175
- }
185
+ if (anyDisconnected || !sent) this._queuePending(event, sentTo);
186
+ else if (event?.id) this._pendingIds.delete(event.id);
176
187
  return sent;
177
188
  }
178
189
 
179
- _drainPending() {
190
+ // Tracks delivery per relay URL, not just "sent to at least one" — a relay
191
+ // mid-reconnect during a partial outage otherwise never gets the event.
192
+ _queuePending(event, sentTo) {
193
+ if (event?.id && this._pendingIds.has(event.id)) {
194
+ const existing = this.pending.find((p) => p.event?.id === event.id);
195
+ if (existing) { for (const url of sentTo) existing.sentTo.add(url); }
196
+ return;
197
+ }
198
+ if (event?.id) this._pendingIds.add(event.id);
199
+ this.pending.push({ event, sentTo, ts: Date.now() });
200
+ while (this.pending.length > PENDING_MAX) {
201
+ const dropped = this.pending.shift();
202
+ if (dropped.event?.id) this._pendingIds.delete(dropped.event.id);
203
+ }
204
+ }
205
+
206
+ // Resolves true once any relay sends OK accepted, false on relay reject,
207
+ // or false on timeout. Gives callers delivery confidence beyond fire-and-forget.
208
+ publishAndWait(event, { timeoutMs = 8000 } = {}) {
209
+ const sent = this.publish(event);
210
+ if (!event?.id) return Promise.resolve(sent);
211
+ return new Promise((resolve) => {
212
+ const prior = this._acks.get(event.id);
213
+ if (prior) clearTimeout(prior.timer);
214
+ const settle = (ok) => {
215
+ const rec = this._acks.get(event.id);
216
+ if (rec) { clearTimeout(rec.timer); this._acks.delete(event.id); }
217
+ resolve(ok);
218
+ };
219
+ const timer = setTimeout(() => settle(false), timeoutMs);
220
+ this._acks.set(event.id, { resolve: settle, timer });
221
+ });
222
+ }
223
+
224
+ _settleAck(id, accepted) {
225
+ const rec = this._acks.get(id);
226
+ if (rec) rec.resolve(accepted);
227
+ }
228
+
229
+ _drainPending(url, ws) {
180
230
  const cutoff = Date.now() - PENDING_TTL_MS;
181
- const pending = this.pending.splice(0);
182
- for (const p of pending) { if (p.ts >= cutoff) this.publish(p.event); }
231
+ this.pending = this.pending.filter((entry) => {
232
+ const alive = entry.ts >= cutoff;
233
+ if (!alive && entry.event?.id) this._pendingIds.delete(entry.event.id);
234
+ return alive;
235
+ });
236
+ if (ws) {
237
+ for (const entry of this.pending) {
238
+ if (!entry.sentTo) entry.sentTo = new Set();
239
+ if (entry.sentTo.has(url)) continue;
240
+ ws.send(JSON.stringify(['EVENT', entry.event]));
241
+ entry.sentTo.add(url);
242
+ }
243
+ }
244
+ this.pending = this.pending.filter((entry) => {
245
+ let anyConnected = false;
246
+ let allKnownSent = true;
247
+ for (const [u, r] of this.relays) {
248
+ if (r.ws?.readyState === 1) {
249
+ anyConnected = true;
250
+ if (!entry.sentTo?.has(u)) { allKnownSent = false; break; }
251
+ }
252
+ }
253
+ if (!anyConnected) allKnownSent = false;
254
+ if (allKnownSent && entry.event?.id) this._pendingIds.delete(entry.event.id);
255
+ return !allKnownSent;
256
+ });
183
257
  }
184
258
 
185
259
  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 });