uniwrtc 1.2.0 → 1.4.0
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/main.js +516 -38
- package/src/nostr/nostrClient.js +71 -9
package/package.json
CHANGED
package/src/main.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import './style.css';
|
|
2
2
|
import UniWRTCClient from '../client-browser.js';
|
|
3
3
|
import { createNostrClient } from './nostr/nostrClient.js';
|
|
4
|
+
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
|
|
4
5
|
|
|
5
6
|
// Make UniWRTCClient available globally for backwards compatibility
|
|
6
7
|
window.UniWRTCClient = UniWRTCClient;
|
|
@@ -8,10 +9,57 @@ window.UniWRTCClient = UniWRTCClient;
|
|
|
8
9
|
// Nostr is the default transport (no toggle)
|
|
9
10
|
let nostrClient = null;
|
|
10
11
|
let myPeerId = null;
|
|
12
|
+
let mySessionNonce = null;
|
|
13
|
+
const peerSessions = new Map();
|
|
14
|
+
const peerProbeState = new Map();
|
|
15
|
+
const readyPeers = new Set();
|
|
16
|
+
const deferredHelloPeers = new Set();
|
|
17
|
+
|
|
18
|
+
// Curated public relays (best-effort). Users can override in the UI.
|
|
19
|
+
const DEFAULT_RELAYS = [
|
|
20
|
+
'wss://relay.primal.net',
|
|
21
|
+
'wss://relay.nostr.band',
|
|
22
|
+
'wss://nos.lol',
|
|
23
|
+
'wss://relay.snort.social',
|
|
24
|
+
'wss://nostr.wine',
|
|
25
|
+
'wss://relay.damus.io',
|
|
26
|
+
];
|
|
27
|
+
|
|
11
28
|
|
|
12
29
|
let client = null;
|
|
13
30
|
const peerConnections = new Map();
|
|
14
31
|
const dataChannels = new Map();
|
|
32
|
+
const pendingIce = new Map();
|
|
33
|
+
const outboundIceBatches = new Map();
|
|
34
|
+
|
|
35
|
+
function bytesToHex(bytes) {
|
|
36
|
+
return Array.from(bytes)
|
|
37
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
38
|
+
.join('');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isHex64(s) {
|
|
42
|
+
return typeof s === 'string' && /^[0-9a-fA-F]{64}$/.test(s);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ensureIdentity() {
|
|
46
|
+
// Must match the key used in createNostrClient() so the pubkey stays consistent.
|
|
47
|
+
const storageKey = 'nostr-secret-key-tab';
|
|
48
|
+
let stored = sessionStorage.getItem(storageKey);
|
|
49
|
+
|
|
50
|
+
if (stored && stored.includes(',')) {
|
|
51
|
+
sessionStorage.removeItem(storageKey);
|
|
52
|
+
stored = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!isHex64(stored)) {
|
|
56
|
+
const secretBytes = generateSecretKey();
|
|
57
|
+
stored = bytesToHex(secretBytes);
|
|
58
|
+
sessionStorage.setItem(storageKey, stored);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return getPublicKey(stored);
|
|
62
|
+
}
|
|
15
63
|
|
|
16
64
|
// Initialize app
|
|
17
65
|
document.getElementById('app').innerHTML = `
|
|
@@ -25,8 +73,8 @@ document.getElementById('app').innerHTML = `
|
|
|
25
73
|
<h2>Connection</h2>
|
|
26
74
|
<div class="connection-controls">
|
|
27
75
|
<div>
|
|
28
|
-
<label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">Relay URL</label>
|
|
29
|
-
<input type="text" id="relayUrl" placeholder="wss://relay.damus.io" value="
|
|
76
|
+
<label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">Relay URL(s)</label>
|
|
77
|
+
<input type="text" id="relayUrl" placeholder="wss://relay.damus.io" value="${DEFAULT_RELAYS.join(', ')}" style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px; font-family: monospace; font-size: 12px;">
|
|
30
78
|
</div>
|
|
31
79
|
<div>
|
|
32
80
|
<label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">Room / Session ID</label>
|
|
@@ -94,6 +142,26 @@ if (urlRoom) {
|
|
|
94
142
|
log(`Using default room ID: ${defaultRoom}`, 'info');
|
|
95
143
|
}
|
|
96
144
|
|
|
145
|
+
// Client/session identity should not depend on relay connectivity.
|
|
146
|
+
try {
|
|
147
|
+
myPeerId = ensureIdentity();
|
|
148
|
+
document.getElementById('clientId').textContent = myPeerId.substring(0, 16) + '...';
|
|
149
|
+
} catch {
|
|
150
|
+
// Leave as-is if identity init fails.
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
document.getElementById('sessionId').textContent = roomInput.value || 'Not joined';
|
|
154
|
+
roomInput.addEventListener('input', () => {
|
|
155
|
+
document.getElementById('sessionId').textContent = roomInput.value.trim() || 'Not joined';
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// STUN-only ICE servers (no TURN)
|
|
159
|
+
const ICE_SERVERS = [
|
|
160
|
+
{ urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] },
|
|
161
|
+
{ urls: ['stun:stun2.l.google.com:19302', 'stun:stun3.l.google.com:19302'] },
|
|
162
|
+
{ urls: ['stun:stun.cloudflare.com:3478'] },
|
|
163
|
+
];
|
|
164
|
+
|
|
97
165
|
function log(message, type = 'info') {
|
|
98
166
|
const logContainer = document.getElementById('logContainer');
|
|
99
167
|
const entry = document.createElement('div');
|
|
@@ -161,14 +229,90 @@ function shouldInitiateWith(peerId) {
|
|
|
161
229
|
return myPeerId.localeCompare(peerId) < 0;
|
|
162
230
|
}
|
|
163
231
|
|
|
232
|
+
function isPoliteFor(peerId) {
|
|
233
|
+
// In perfect negotiation, one side is "polite" (will accept/repair collisions)
|
|
234
|
+
return !shouldInitiateWith(peerId);
|
|
235
|
+
}
|
|
236
|
+
|
|
164
237
|
function sendSignal(to, payload) {
|
|
165
238
|
if (!nostrClient) throw new Error('Not connected to Nostr');
|
|
239
|
+
|
|
240
|
+
const toSession = peerSessions.get(to);
|
|
241
|
+
const type = payload?.type;
|
|
242
|
+
const needsToSession = type !== 'probe';
|
|
243
|
+
|
|
244
|
+
if (needsToSession && !toSession) throw new Error('No peer session yet');
|
|
245
|
+
|
|
246
|
+
return nostrClient.send({
|
|
247
|
+
...payload,
|
|
248
|
+
to,
|
|
249
|
+
...(needsToSession ? { toSession } : {}),
|
|
250
|
+
fromSession: mySessionNonce,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function sendSignalToSession(to, payload, toSession) {
|
|
255
|
+
if (!nostrClient) throw new Error('Not connected to Nostr');
|
|
256
|
+
if (!toSession) throw new Error('toSession is required');
|
|
166
257
|
return nostrClient.send({
|
|
167
258
|
...payload,
|
|
168
259
|
to,
|
|
260
|
+
toSession,
|
|
261
|
+
fromSession: mySessionNonce,
|
|
169
262
|
});
|
|
170
263
|
}
|
|
171
264
|
|
|
265
|
+
async function maybeProbePeer(peerId) {
|
|
266
|
+
if (!nostrClient) return;
|
|
267
|
+
const session = peerSessions.get(peerId);
|
|
268
|
+
if (!session) return;
|
|
269
|
+
if (!shouldInitiateWith(peerId)) return;
|
|
270
|
+
|
|
271
|
+
const last = peerProbeState.get(peerId);
|
|
272
|
+
if (last && last.session === session) return;
|
|
273
|
+
|
|
274
|
+
const probeId = Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
275
|
+
peerProbeState.set(peerId, { session, ts: Date.now(), probeId });
|
|
276
|
+
try {
|
|
277
|
+
await sendSignal(peerId, { type: 'probe', probeId });
|
|
278
|
+
log(`Probing peer ${peerId.substring(0, 6)}...`, 'info');
|
|
279
|
+
} catch (e) {
|
|
280
|
+
log(`Probe failed: ${e?.message || e}`, 'warning');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function logDrop(peerId, payload, reason) {
|
|
285
|
+
const t = payload?.type || 'unknown';
|
|
286
|
+
if (t !== 'signal-offer' && t !== 'signal-answer' && t !== 'signal-ice' && t !== 'signal-ice-batch' && t !== 'probe' && t !== 'probe-ack') return;
|
|
287
|
+
log(`Dropped ${t} from ${peerId.substring(0, 6)}... (${reason})`, 'warning');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function resetPeerConnection(peerId) {
|
|
291
|
+
const existing = peerConnections.get(peerId);
|
|
292
|
+
if (existing instanceof RTCPeerConnection) {
|
|
293
|
+
try {
|
|
294
|
+
existing.close();
|
|
295
|
+
} catch {
|
|
296
|
+
// ignore
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
peerConnections.delete(peerId);
|
|
300
|
+
pendingIce.delete(peerId);
|
|
301
|
+
|
|
302
|
+
const dc = dataChannels.get(peerId);
|
|
303
|
+
if (dc) {
|
|
304
|
+
try {
|
|
305
|
+
dc.close();
|
|
306
|
+
} catch {
|
|
307
|
+
// ignore
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
dataChannels.delete(peerId);
|
|
311
|
+
updatePeerList();
|
|
312
|
+
|
|
313
|
+
return ensurePeerConnection(peerId);
|
|
314
|
+
}
|
|
315
|
+
|
|
172
316
|
async function ensurePeerConnection(peerId) {
|
|
173
317
|
if (!peerId || peerId === myPeerId) return null;
|
|
174
318
|
if (peerConnections.has(peerId) && peerConnections.get(peerId) instanceof RTCPeerConnection) {
|
|
@@ -176,16 +320,67 @@ async function ensurePeerConnection(peerId) {
|
|
|
176
320
|
}
|
|
177
321
|
|
|
178
322
|
const initiator = shouldInitiateWith(peerId);
|
|
323
|
+
|
|
324
|
+
// Initiator must wait until the peer proves it's live (probe-ack), otherwise we end up
|
|
325
|
+
// negotiating with stale peers from relay history.
|
|
326
|
+
if (initiator && !readyPeers.has(peerId)) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
179
329
|
const pc = await createPeerConnection(peerId, initiator);
|
|
180
330
|
return pc;
|
|
181
331
|
}
|
|
182
332
|
|
|
333
|
+
async function addIceCandidateSafely(peerId, candidate) {
|
|
334
|
+
const pc = peerConnections.get(peerId);
|
|
335
|
+
if (!(pc instanceof RTCPeerConnection)) return;
|
|
336
|
+
|
|
337
|
+
if (!pc.remoteDescription) {
|
|
338
|
+
const list = pendingIce.get(peerId) || [];
|
|
339
|
+
list.push(candidate);
|
|
340
|
+
pendingIce.set(peerId, list);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function flushPendingIce(peerId) {
|
|
348
|
+
const pc = peerConnections.get(peerId);
|
|
349
|
+
if (!(pc instanceof RTCPeerConnection)) return;
|
|
350
|
+
if (!pc.remoteDescription) return;
|
|
351
|
+
|
|
352
|
+
const list = pendingIce.get(peerId);
|
|
353
|
+
if (!list || list.length === 0) return;
|
|
354
|
+
pendingIce.delete(peerId);
|
|
355
|
+
|
|
356
|
+
for (const c of list) {
|
|
357
|
+
try {
|
|
358
|
+
await pc.addIceCandidate(new RTCIceCandidate(c));
|
|
359
|
+
} catch (e) {
|
|
360
|
+
log(`Failed to add queued ICE candidate: ${e?.message || e}`, 'warning');
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
183
365
|
async function connectNostr() {
|
|
184
|
-
const
|
|
366
|
+
const relayUrlRaw = document.getElementById('relayUrl').value.trim();
|
|
185
367
|
const roomIdInput = document.getElementById('roomId');
|
|
186
368
|
const roomId = roomIdInput.value.trim();
|
|
187
369
|
|
|
188
|
-
|
|
370
|
+
const relayCandidatesRaw = relayUrlRaw.toLowerCase() === 'auto'
|
|
371
|
+
? DEFAULT_RELAYS
|
|
372
|
+
: Array.from(
|
|
373
|
+
new Set(
|
|
374
|
+
relayUrlRaw
|
|
375
|
+
.split(/[\s,]+/)
|
|
376
|
+
.map((s) => s.trim())
|
|
377
|
+
.filter(Boolean)
|
|
378
|
+
)
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const relayCandidates = relayCandidatesRaw.length ? relayCandidatesRaw : DEFAULT_RELAYS;
|
|
382
|
+
|
|
383
|
+
if (relayCandidates.length === 0) {
|
|
189
384
|
log('Please enter a relay URL', 'error');
|
|
190
385
|
return;
|
|
191
386
|
}
|
|
@@ -194,20 +389,68 @@ async function connectNostr() {
|
|
|
194
389
|
if (!roomId) roomIdInput.value = effectiveRoom;
|
|
195
390
|
|
|
196
391
|
try {
|
|
197
|
-
log(`Connecting to Nostr relay
|
|
392
|
+
log(`Connecting to Nostr relay...`, 'info');
|
|
198
393
|
|
|
199
394
|
if (nostrClient) {
|
|
200
395
|
await nostrClient.disconnect();
|
|
201
396
|
nostrClient = null;
|
|
202
397
|
}
|
|
203
398
|
|
|
204
|
-
|
|
399
|
+
// Reset local peer state to avoid stale sessions targeting the wrong browser tab.
|
|
400
|
+
myPeerId = myPeerId || ensureIdentity();
|
|
401
|
+
mySessionNonce = null;
|
|
402
|
+
peerSessions.clear();
|
|
403
|
+
peerProbeState.clear();
|
|
404
|
+
readyPeers.clear();
|
|
405
|
+
pendingIce.clear();
|
|
406
|
+
peerConnections.forEach((pc) => {
|
|
407
|
+
if (pc instanceof RTCPeerConnection) pc.close();
|
|
408
|
+
});
|
|
409
|
+
peerConnections.clear();
|
|
410
|
+
dataChannels.clear();
|
|
411
|
+
updatePeerList();
|
|
412
|
+
|
|
413
|
+
const wireHandlers = (client) => {
|
|
414
|
+
client.__handlers = {
|
|
415
|
+
onState: (state) => {
|
|
416
|
+
if (state === 'connected') updateStatus(true);
|
|
417
|
+
if (state === 'disconnected') updateStatus(false);
|
|
418
|
+
},
|
|
419
|
+
onNotice: (notice) => {
|
|
420
|
+
log(`Relay NOTICE: ${String(notice)}`, 'warning');
|
|
421
|
+
},
|
|
422
|
+
onOk: ({ id, ok, message }) => {
|
|
423
|
+
if (ok === false) log(`Relay rejected event ${String(id).slice(0, 8)}...: ${String(message)}`, 'error');
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// Try relays async (in small parallel batches) and pick the first that accepts publishes.
|
|
429
|
+
const relayBatchSize = 3;
|
|
430
|
+
let lastError = null;
|
|
431
|
+
let selected = null;
|
|
432
|
+
|
|
433
|
+
// Identity must be ready before we connect/subscribe; inbound events can arrive immediately.
|
|
434
|
+
// Any client instance will derive the same per-tab keypair.
|
|
435
|
+
const myPubkey = myPeerId || ensureIdentity();
|
|
436
|
+
myPeerId = myPubkey;
|
|
437
|
+
mySessionNonce = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
438
|
+
document.getElementById('clientId').textContent = myPubkey.substring(0, 16) + '...';
|
|
439
|
+
document.getElementById('sessionId').textContent = effectiveRoom;
|
|
440
|
+
|
|
441
|
+
const makeClient = (relayUrl) => createNostrClient({
|
|
205
442
|
relayUrl,
|
|
206
443
|
room: effectiveRoom,
|
|
207
444
|
onState: (state) => {
|
|
208
445
|
if (state === 'connected') updateStatus(true);
|
|
209
446
|
if (state === 'disconnected') updateStatus(false);
|
|
210
447
|
},
|
|
448
|
+
onNotice: (notice) => {
|
|
449
|
+
log(`Relay NOTICE (${relayUrl}): ${String(notice)}`, 'warning');
|
|
450
|
+
},
|
|
451
|
+
onOk: ({ id, ok, message }) => {
|
|
452
|
+
if (ok === false) log(`Relay rejected event ${String(id).slice(0, 8)}...: ${String(message)}`, 'error');
|
|
453
|
+
},
|
|
211
454
|
onPayload: async ({ from, payload }) => {
|
|
212
455
|
const peerId = from;
|
|
213
456
|
if (!peerId || peerId === myPeerId) return;
|
|
@@ -221,23 +464,150 @@ async function connectNostr() {
|
|
|
221
464
|
|
|
222
465
|
if (!payload || typeof payload !== 'object') return;
|
|
223
466
|
|
|
467
|
+
// NOTE: Do NOT learn/update peerSessions from arbitrary relay history.
|
|
468
|
+
// Only trust:
|
|
469
|
+
// - `hello` (broadcast presence)
|
|
470
|
+
// - messages targeted to this tab via `toSession === mySessionNonce`
|
|
471
|
+
|
|
224
472
|
// Presence
|
|
225
473
|
if (payload.type === 'hello') {
|
|
226
|
-
|
|
474
|
+
if (!myPeerId || !mySessionNonce) return;
|
|
475
|
+
if (typeof payload.session !== 'string' || payload.session.length < 6) return;
|
|
476
|
+
const prev = peerSessions.get(peerId);
|
|
477
|
+
peerSessions.set(peerId, payload.session);
|
|
478
|
+
if (!prev || prev !== payload.session) {
|
|
479
|
+
log(`Peer session updated: ${peerId.substring(0, 6)}...`, 'info');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (prev && prev !== payload.session) {
|
|
483
|
+
readyPeers.delete(peerId);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// We may receive peer presence while still selecting a relay.
|
|
487
|
+
// Store and probe once we have a selected/connected `nostrClient`.
|
|
488
|
+
deferredHelloPeers.add(peerId);
|
|
489
|
+
await maybeProbePeer(peerId);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (payload.type === 'probe') {
|
|
494
|
+
// Learn the peer's session from a live message.
|
|
495
|
+
if (typeof payload.fromSession === 'string' && payload.fromSession.length >= 6) {
|
|
496
|
+
const prev = peerSessions.get(peerId);
|
|
497
|
+
if (!prev || prev !== payload.fromSession) {
|
|
498
|
+
peerSessions.set(peerId, payload.fromSession);
|
|
499
|
+
log(`Peer session ${prev ? 'rotated' : 'learned'}: ${peerId.substring(0, 6)}...`, 'info');
|
|
500
|
+
readyPeers.delete(peerId);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Reply directly to the sender's session (fromSession) so the initiator doesn't drop it.
|
|
505
|
+
try {
|
|
506
|
+
await sendSignalToSession(peerId, { type: 'probe-ack', probeId: payload.probeId }, payload.fromSession);
|
|
507
|
+
log(`Probe ack -> ${peerId.substring(0, 6)}...`, 'info');
|
|
508
|
+
} catch (e) {
|
|
509
|
+
log(`Probe-ack failed: ${e?.message || e}`, 'warning');
|
|
510
|
+
}
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Only accept signaling intended for THIS browser session
|
|
515
|
+
if (payload.toSession && payload.toSession !== mySessionNonce) {
|
|
516
|
+
logDrop(peerId, payload, 'toSession mismatch');
|
|
227
517
|
return;
|
|
228
518
|
}
|
|
229
519
|
|
|
230
520
|
// Signaling messages are always targeted
|
|
231
|
-
if (payload.to && payload.to !== myPeerId)
|
|
521
|
+
if (payload.to && payload.to !== myPeerId) {
|
|
522
|
+
logDrop(peerId, payload, 'to mismatch');
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Now that we know it's targeted to this session, we can safely learn peer session.
|
|
527
|
+
if (typeof payload.fromSession === 'string' && payload.fromSession.length >= 6) {
|
|
528
|
+
const prev = peerSessions.get(peerId);
|
|
529
|
+
if (!prev || prev !== payload.fromSession) {
|
|
530
|
+
peerSessions.set(peerId, payload.fromSession);
|
|
531
|
+
log(`Peer session ${prev ? 'rotated' : 'learned'}: ${peerId.substring(0, 6)}...`, 'info');
|
|
532
|
+
readyPeers.delete(peerId);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (payload.type === 'probe-ack') {
|
|
537
|
+
const last = peerProbeState.get(peerId);
|
|
538
|
+
if (!last || !last.probeId || !payload.probeId || payload.probeId !== last.probeId) {
|
|
539
|
+
logDrop(peerId, payload, 'probeId mismatch');
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
// Peer session can legitimately rotate between hello/probe/ack (reloads, relay history).
|
|
543
|
+
// Since this message is already targeted to our toSession, accept it and update our view.
|
|
544
|
+
if (typeof payload.fromSession === 'string' && payload.fromSession.length >= 6) {
|
|
545
|
+
if (peerSessions.get(peerId) !== payload.fromSession) {
|
|
546
|
+
peerSessions.set(peerId, payload.fromSession);
|
|
547
|
+
}
|
|
548
|
+
if (last.session !== payload.fromSession) {
|
|
549
|
+
last.session = payload.fromSession;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (Date.now() - last.ts > 30000) {
|
|
553
|
+
logDrop(peerId, payload, 'stale probe-ack');
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
readyPeers.add(peerId);
|
|
558
|
+
if (shouldInitiateWith(peerId)) {
|
|
559
|
+
log(`Probe ack <- ${peerId.substring(0, 6)}...`, 'info');
|
|
560
|
+
await ensurePeerConnection(peerId);
|
|
561
|
+
}
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
232
564
|
|
|
233
565
|
if (payload.type === 'signal-offer' && payload.sdp) {
|
|
234
566
|
log(`Received offer from ${peerId.substring(0, 6)}...`, 'info');
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
567
|
+
let pc = await ensurePeerConnection(peerId);
|
|
568
|
+
if (!pc) {
|
|
569
|
+
// As the receiver we should always accept an offer even if probe logic didn't run.
|
|
570
|
+
pc = await resetPeerConnection(peerId);
|
|
571
|
+
}
|
|
572
|
+
if (!(pc instanceof RTCPeerConnection)) return;
|
|
573
|
+
|
|
574
|
+
// Offer collision handling: if we're not stable, decide whether to ignore or reset.
|
|
575
|
+
const offerCollision = pc.signalingState !== 'stable';
|
|
576
|
+
if (offerCollision && !isPoliteFor(peerId)) {
|
|
577
|
+
log(`Ignoring offer collision from ${peerId.substring(0, 6)}...`, 'warning');
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (offerCollision && isPoliteFor(peerId)) {
|
|
581
|
+
log(`Offer collision; resetting connection with ${peerId.substring(0, 6)}...`, 'warning');
|
|
582
|
+
await resetPeerConnection(peerId);
|
|
583
|
+
const next = peerConnections.get(peerId);
|
|
584
|
+
if (!(next instanceof RTCPeerConnection)) return;
|
|
585
|
+
pc = next;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const pc2 = peerConnections.get(peerId);
|
|
589
|
+
if (!(pc2 instanceof RTCPeerConnection)) return;
|
|
590
|
+
|
|
591
|
+
// Only accept offers here.
|
|
592
|
+
if (payload.sdp?.type && payload.sdp.type !== 'offer') {
|
|
593
|
+
log(`Ignoring non-offer in signal-offer from ${peerId.substring(0, 6)}...`, 'warning');
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
await pc2.setRemoteDescription(new RTCSessionDescription(payload.sdp));
|
|
598
|
+
await flushPendingIce(peerId);
|
|
599
|
+
if (pc2.signalingState !== 'have-remote-offer') {
|
|
600
|
+
log(`Not answering; unexpected state: ${pc2.signalingState}`, 'warning');
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const answer = await pc2.createAnswer();
|
|
604
|
+
await pc2.setLocalDescription(answer);
|
|
605
|
+
try {
|
|
606
|
+
await sendSignal(peerId, { type: 'signal-answer', sdp: { type: pc2.localDescription.type, sdp: pc2.localDescription.sdp } });
|
|
607
|
+
log(`Sent answer to ${peerId.substring(0, 6)}...`, 'success');
|
|
608
|
+
} catch (e) {
|
|
609
|
+
log(`Failed to send answer: ${e?.message || e}`, 'error');
|
|
610
|
+
}
|
|
241
611
|
return;
|
|
242
612
|
}
|
|
243
613
|
|
|
@@ -245,16 +615,31 @@ async function connectNostr() {
|
|
|
245
615
|
log(`Received answer from ${peerId.substring(0, 6)}...`, 'info');
|
|
246
616
|
const pc = peerConnections.get(peerId);
|
|
247
617
|
if (pc instanceof RTCPeerConnection) {
|
|
618
|
+
if (payload.sdp?.type && payload.sdp.type !== 'answer') {
|
|
619
|
+
log(`Ignoring non-answer in signal-answer from ${peerId.substring(0, 6)}...`, 'warning');
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
248
622
|
await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp));
|
|
623
|
+
await flushPendingIce(peerId);
|
|
249
624
|
}
|
|
250
625
|
return;
|
|
251
626
|
}
|
|
252
627
|
|
|
253
628
|
if (payload.type === 'signal-ice' && payload.candidate) {
|
|
254
|
-
|
|
255
|
-
|
|
629
|
+
log(`Received ICE candidate from ${peerId.substring(0, 6)}...`, 'info');
|
|
630
|
+
try {
|
|
631
|
+
await addIceCandidateSafely(peerId, payload.candidate);
|
|
632
|
+
} catch (e) {
|
|
633
|
+
log(`Failed to add ICE candidate: ${e?.message || e}`, 'warning');
|
|
634
|
+
}
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (payload.type === 'signal-ice-batch' && Array.isArray(payload.candidates)) {
|
|
639
|
+
log(`Received ICE batch (${payload.candidates.length}) from ${peerId.substring(0, 6)}...`, 'info');
|
|
640
|
+
for (const c of payload.candidates) {
|
|
256
641
|
try {
|
|
257
|
-
await
|
|
642
|
+
await addIceCandidateSafely(peerId, c);
|
|
258
643
|
} catch (e) {
|
|
259
644
|
log(`Failed to add ICE candidate: ${e?.message || e}`, 'warning');
|
|
260
645
|
}
|
|
@@ -263,13 +648,58 @@ async function connectNostr() {
|
|
|
263
648
|
},
|
|
264
649
|
});
|
|
265
650
|
|
|
266
|
-
|
|
651
|
+
const tryOneRelay = async (relayUrl) => {
|
|
652
|
+
const candidateClient = makeClient(relayUrl);
|
|
653
|
+
await candidateClient.connect();
|
|
654
|
+
const ok = await candidateClient.sendWithOk({ type: 'relay-check', session: mySessionNonce }, { timeoutMs: 3500 });
|
|
655
|
+
if (ok.ok !== true) throw new Error(ok.message || 'Relay rejected publish');
|
|
656
|
+
return { relayUrl, client: candidateClient };
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
for (let i = 0; i < relayCandidates.length && !selected; i += relayBatchSize) {
|
|
660
|
+
const batch = relayCandidates.slice(i, i + relayBatchSize);
|
|
661
|
+
batch.forEach((u) => log(`Trying relay: ${u}`, 'info'));
|
|
662
|
+
|
|
663
|
+
const clientsInBatch = new Map();
|
|
664
|
+
const attempts = batch.map((relayUrl) => (async () => {
|
|
665
|
+
const result = await tryOneRelay(relayUrl);
|
|
666
|
+
clientsInBatch.set(relayUrl, result.client);
|
|
667
|
+
return result;
|
|
668
|
+
})());
|
|
669
|
+
|
|
670
|
+
try {
|
|
671
|
+
selected = await Promise.any(attempts);
|
|
672
|
+
} catch (e) {
|
|
673
|
+
lastError = e;
|
|
674
|
+
} finally {
|
|
675
|
+
// Close any batch clients that were created but not selected.
|
|
676
|
+
for (const [url, c] of clientsInBatch.entries()) {
|
|
677
|
+
if (selected && selected.relayUrl === url) continue;
|
|
678
|
+
try {
|
|
679
|
+
await c.disconnect();
|
|
680
|
+
} catch {
|
|
681
|
+
// ignore
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (!selected) {
|
|
688
|
+
throw lastError || new Error('No relay candidates available');
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
nostrClient = selected.client;
|
|
692
|
+
document.getElementById('relayUrl').value = selected.relayUrl;
|
|
693
|
+
log(`Selected relay: ${selected.relayUrl}`, 'success');
|
|
267
694
|
|
|
268
|
-
const myPubkey = nostrClient.getPublicKey();
|
|
269
|
-
myPeerId = myPubkey;
|
|
270
|
-
document.getElementById('clientId').textContent = myPubkey.substring(0, 16) + '...';
|
|
271
|
-
document.getElementById('sessionId').textContent = effectiveRoom;
|
|
272
695
|
log(`Joined Nostr room: ${effectiveRoom}`, 'success');
|
|
696
|
+
await nostrClient.send({ type: 'hello', session: mySessionNonce });
|
|
697
|
+
|
|
698
|
+
// Kick any peers we saw while selecting relays.
|
|
699
|
+
for (const peerId of deferredHelloPeers) {
|
|
700
|
+
await maybeProbePeer(peerId);
|
|
701
|
+
}
|
|
702
|
+
deferredHelloPeers.clear();
|
|
273
703
|
|
|
274
704
|
updateStatus(true);
|
|
275
705
|
|
|
@@ -405,7 +835,11 @@ window.disconnect = function() {
|
|
|
405
835
|
if (nostrClient) {
|
|
406
836
|
nostrClient.disconnect().catch(() => {});
|
|
407
837
|
nostrClient = null;
|
|
408
|
-
|
|
838
|
+
mySessionNonce = null;
|
|
839
|
+
peerSessions.clear();
|
|
840
|
+
peerProbeState.clear();
|
|
841
|
+
readyPeers.clear();
|
|
842
|
+
pendingIce.clear();
|
|
409
843
|
peerConnections.forEach((pc) => {
|
|
410
844
|
if (pc instanceof RTCPeerConnection) pc.close();
|
|
411
845
|
});
|
|
@@ -427,29 +861,71 @@ window.disconnect = function() {
|
|
|
427
861
|
|
|
428
862
|
async function createPeerConnection(peerId, shouldInitiate) {
|
|
429
863
|
if (peerConnections.has(peerId)) {
|
|
864
|
+
const existing = peerConnections.get(peerId);
|
|
865
|
+
if (existing instanceof RTCPeerConnection) {
|
|
430
866
|
log(`Peer connection already exists for ${peerId.substring(0, 6)}...`, 'warning');
|
|
431
|
-
|
|
867
|
+
return existing;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Placeholder entry (e.g., peer "seen" list). Replace it with a real RTCPeerConnection.
|
|
871
|
+
peerConnections.delete(peerId);
|
|
432
872
|
}
|
|
433
873
|
|
|
434
874
|
log(`Creating peer connection with ${peerId.substring(0, 6)}... (shouldInitiate: ${shouldInitiate})`, 'info');
|
|
435
875
|
|
|
436
876
|
const pc = new RTCPeerConnection({
|
|
437
|
-
iceServers:
|
|
438
|
-
{ urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] }
|
|
439
|
-
]
|
|
877
|
+
iceServers: ICE_SERVERS,
|
|
440
878
|
});
|
|
441
879
|
|
|
880
|
+
// Register early to avoid races where answer/ICE arrives before this function finishes.
|
|
881
|
+
peerConnections.set(peerId, pc);
|
|
882
|
+
pendingIce.delete(peerId);
|
|
883
|
+
updatePeerList();
|
|
884
|
+
|
|
442
885
|
pc.onicecandidate = (event) => {
|
|
886
|
+
if (!nostrClient && client && event.candidate) {
|
|
887
|
+
client.sendIceCandidate(event.candidate, peerId);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (!nostrClient) return;
|
|
892
|
+
|
|
893
|
+
const entry = outboundIceBatches.get(peerId) || { candidates: [], timer: null };
|
|
894
|
+
outboundIceBatches.set(peerId, entry);
|
|
895
|
+
|
|
443
896
|
if (event.candidate) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
897
|
+
entry.candidates.push(event.candidate.toJSON?.() || event.candidate);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const flush = () => {
|
|
901
|
+
entry.timer = null;
|
|
902
|
+
if (!entry.candidates.length) return;
|
|
903
|
+
const batch = entry.candidates.splice(0, entry.candidates.length);
|
|
904
|
+
log(`Sending ICE batch (${batch.length}) to ${peerId.substring(0, 6)}...`, 'info');
|
|
905
|
+
sendSignal(peerId, { type: 'signal-ice-batch', candidates: batch }).catch((e) => {
|
|
906
|
+
log(`Failed to send ICE batch: ${e?.message || e}`, 'warning');
|
|
907
|
+
});
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
// If end-of-candidates, flush immediately; otherwise debounce.
|
|
911
|
+
if (!event.candidate) {
|
|
912
|
+
flush();
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (!entry.timer) {
|
|
917
|
+
entry.timer = setTimeout(flush, 250);
|
|
450
918
|
}
|
|
451
919
|
};
|
|
452
920
|
|
|
921
|
+
pc.oniceconnectionstatechange = () => {
|
|
922
|
+
log(`ICE state (${peerId.substring(0, 6)}...): ${pc.iceConnectionState}`, 'info');
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
pc.onconnectionstatechange = () => {
|
|
926
|
+
log(`Conn state (${peerId.substring(0, 6)}...): ${pc.connectionState}`, 'info');
|
|
927
|
+
};
|
|
928
|
+
|
|
453
929
|
pc.ondatachannel = (event) => {
|
|
454
930
|
log(`Received data channel from ${peerId.substring(0, 6)}`, 'info');
|
|
455
931
|
setupDataChannel(peerId, event.channel);
|
|
@@ -462,17 +938,19 @@ async function createPeerConnection(peerId, shouldInitiate) {
|
|
|
462
938
|
const offer = await pc.createOffer();
|
|
463
939
|
await pc.setLocalDescription(offer);
|
|
464
940
|
if (nostrClient) {
|
|
465
|
-
|
|
941
|
+
try {
|
|
942
|
+
await sendSignal(peerId, { type: 'signal-offer', sdp: { type: pc.localDescription.type, sdp: pc.localDescription.sdp } });
|
|
943
|
+
log(`Sent offer to ${peerId.substring(0, 6)}...`, 'success');
|
|
944
|
+
} catch (e) {
|
|
945
|
+
log(`Failed to send offer: ${e?.message || e}`, 'warning');
|
|
946
|
+
}
|
|
466
947
|
} else if (client) {
|
|
467
948
|
client.sendOffer(offer, peerId);
|
|
468
|
-
}
|
|
469
949
|
log(`Sent offer to ${peerId.substring(0, 6)}...`, 'success');
|
|
950
|
+
}
|
|
470
951
|
} else {
|
|
471
952
|
log(`Waiting for offer from ${peerId.substring(0, 6)}...`, 'info');
|
|
472
953
|
}
|
|
473
|
-
|
|
474
|
-
peerConnections.set(peerId, pc);
|
|
475
|
-
updatePeerList();
|
|
476
954
|
return pc;
|
|
477
955
|
}
|
|
478
956
|
|
package/src/nostr/nostrClient.js
CHANGED
|
@@ -15,7 +15,7 @@ function isHex64(s) {
|
|
|
15
15
|
* - Publishes kind:1 events tagged with ['t', room] and ['room', room]
|
|
16
16
|
* - Subscribes to kind:1 events filtered by #t
|
|
17
17
|
*/
|
|
18
|
-
export function createNostrClient({ relayUrl, room, onPayload, onState } = {}) {
|
|
18
|
+
export function createNostrClient({ relayUrl, room, onPayload, onState, onNotice, onOk } = {}) {
|
|
19
19
|
if (!relayUrl) throw new Error('relayUrl is required');
|
|
20
20
|
if (!room) throw new Error('room is required');
|
|
21
21
|
|
|
@@ -29,20 +29,27 @@ export function createNostrClient({ relayUrl, room, onPayload, onState } = {}) {
|
|
|
29
29
|
seen: new Set(),
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
// Track NIP-20 OK responses by event id
|
|
33
|
+
const okWaiters = new Map();
|
|
34
|
+
|
|
32
35
|
function ensureKeys() {
|
|
33
36
|
if (state.pubkey && state.secretKeyHex) return;
|
|
34
37
|
|
|
35
|
-
|
|
38
|
+
// IMPORTANT: For this demo we want each browser tab to have a distinct peer ID.
|
|
39
|
+
// sessionStorage is per-tab, while localStorage is shared across tabs.
|
|
40
|
+
const storageKey = 'nostr-secret-key-tab';
|
|
41
|
+
let stored = sessionStorage.getItem(storageKey);
|
|
42
|
+
|
|
36
43
|
// If stored value looks like an array string from prior buggy storage, clear it
|
|
37
44
|
if (stored && stored.includes(',')) {
|
|
38
|
-
|
|
45
|
+
sessionStorage.removeItem(storageKey);
|
|
39
46
|
stored = null;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
if (!isHex64(stored)) {
|
|
43
50
|
const secretBytes = generateSecretKey();
|
|
44
51
|
state.secretKeyHex = bytesToHex(secretBytes);
|
|
45
|
-
|
|
52
|
+
sessionStorage.setItem(storageKey, state.secretKeyHex);
|
|
46
53
|
} else {
|
|
47
54
|
state.secretKeyHex = stored;
|
|
48
55
|
}
|
|
@@ -74,6 +81,34 @@ export function createNostrClient({ relayUrl, room, onPayload, onState } = {}) {
|
|
|
74
81
|
if (!Array.isArray(msg) || msg.length < 2) return;
|
|
75
82
|
const [type] = msg;
|
|
76
83
|
|
|
84
|
+
if (type === 'NOTICE') {
|
|
85
|
+
try {
|
|
86
|
+
onNotice?.(msg[1]);
|
|
87
|
+
} catch {
|
|
88
|
+
// ignore
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// NIP-20: ["OK", <event_id>, <true|false>, <message>]
|
|
94
|
+
if (type === 'OK') {
|
|
95
|
+
const id = msg[1];
|
|
96
|
+
const ok = msg[2];
|
|
97
|
+
const message = msg[3];
|
|
98
|
+
|
|
99
|
+
const waiter = okWaiters.get(id);
|
|
100
|
+
if (waiter) {
|
|
101
|
+
okWaiters.delete(id);
|
|
102
|
+
waiter.resolve({ id, ok, message });
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
onOk?.({ id, ok, message });
|
|
106
|
+
} catch {
|
|
107
|
+
// ignore
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
77
112
|
if (type === 'EVENT') {
|
|
78
113
|
const nostrEvent = msg[2];
|
|
79
114
|
if (!nostrEvent || typeof nostrEvent !== 'object') return;
|
|
@@ -83,6 +118,7 @@ export function createNostrClient({ relayUrl, room, onPayload, onState } = {}) {
|
|
|
83
118
|
// Ignore our own events
|
|
84
119
|
if (nostrEvent.pubkey && nostrEvent.pubkey === state.pubkey) return;
|
|
85
120
|
|
|
121
|
+
|
|
86
122
|
// Ensure it's for our room
|
|
87
123
|
const tags = Array.isArray(nostrEvent.tags) ? nostrEvent.tags : [];
|
|
88
124
|
const roomTag = tags.find((t) => Array.isArray(t) && t[0] === 'room');
|
|
@@ -149,19 +185,24 @@ export function createNostrClient({ relayUrl, room, onPayload, onState } = {}) {
|
|
|
149
185
|
const filter = {
|
|
150
186
|
kinds: [1],
|
|
151
187
|
'#t': [state.room],
|
|
152
|
-
|
|
188
|
+
// Use a wider window to tolerate clock skew; app layer filters stale via session nonces.
|
|
189
|
+
since: now - 600,
|
|
153
190
|
limit: 200,
|
|
154
191
|
};
|
|
155
192
|
|
|
156
193
|
sendRaw(['REQ', state.subId, filter]);
|
|
157
194
|
|
|
158
|
-
// Announce presence
|
|
159
|
-
await send({ type: 'hello' });
|
|
160
|
-
|
|
161
195
|
setState('connected');
|
|
162
196
|
}
|
|
163
197
|
|
|
164
198
|
async function send(payload) {
|
|
199
|
+
ensureKeys();
|
|
200
|
+
const signed = buildSignedEvent(payload);
|
|
201
|
+
sendRaw(['EVENT', signed]);
|
|
202
|
+
return signed.id;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function buildSignedEvent(payload) {
|
|
165
206
|
ensureKeys();
|
|
166
207
|
const created_at = Math.floor(Date.now() / 1000);
|
|
167
208
|
const tags = [
|
|
@@ -182,8 +223,28 @@ export function createNostrClient({ relayUrl, room, onPayload, onState } = {}) {
|
|
|
182
223
|
pubkey: state.pubkey,
|
|
183
224
|
};
|
|
184
225
|
|
|
185
|
-
|
|
226
|
+
return finalizeEvent(eventTemplate, state.secretKeyHex);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function sendWithOk(payload, { timeoutMs = 4000 } = {}) {
|
|
230
|
+
const signed = buildSignedEvent(payload);
|
|
231
|
+
|
|
232
|
+
const okPromise = new Promise((resolve, reject) => {
|
|
233
|
+
const timer = setTimeout(() => {
|
|
234
|
+
okWaiters.delete(signed.id);
|
|
235
|
+
reject(new Error('Timed out waiting for relay OK'));
|
|
236
|
+
}, timeoutMs);
|
|
237
|
+
|
|
238
|
+
okWaiters.set(signed.id, {
|
|
239
|
+
resolve: (v) => {
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
resolve(v);
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
186
246
|
sendRaw(['EVENT', signed]);
|
|
247
|
+
return await okPromise;
|
|
187
248
|
}
|
|
188
249
|
|
|
189
250
|
async function disconnect() {
|
|
@@ -208,6 +269,7 @@ export function createNostrClient({ relayUrl, room, onPayload, onState } = {}) {
|
|
|
208
269
|
connect,
|
|
209
270
|
disconnect,
|
|
210
271
|
send,
|
|
272
|
+
sendWithOk,
|
|
211
273
|
getPublicKey: getPublicKeyHex,
|
|
212
274
|
};
|
|
213
275
|
}
|