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 +1 -1
- package/src/auth.js +17 -5
- package/src/channels.js +4 -2
- package/src/chat.js +11 -4
- package/src/relay-pool.js +85 -11
- package/src/safe-storage.js +17 -0
- package/src/servers.js +7 -5
- package/src/voice.js +22 -2
package/package.json
CHANGED
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
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
91
|
-
this.storage
|
|
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
|
-
|
|
38
|
-
this.
|
|
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
|
-
|
|
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]
|
|
91
|
-
(event) => {
|
|
92
|
-
|
|
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'
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
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 });
|