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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniwrtc",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "A universal WebRTC signaling service",
5
5
  "main": "server.js",
6
6
  "type": "module",
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="wss://relay.damus.io" style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px; font-family: monospace; font-size: 12px;">
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 relayUrl = document.getElementById('relayUrl').value.trim();
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
- if (!relayUrl) {
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: ${relayUrl}...`, 'info');
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
- nostrClient = createNostrClient({
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
- await ensurePeerConnection(peerId);
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) return;
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
- const pc = await ensurePeerConnection(peerId);
236
- await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp));
237
- const answer = await pc.createAnswer();
238
- await pc.setLocalDescription(answer);
239
- await sendSignal(peerId, { type: 'signal-answer', sdp: pc.localDescription });
240
- log(`Sent answer to ${peerId.substring(0, 6)}...`, 'success');
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
- const pc = peerConnections.get(peerId);
255
- if (pc instanceof RTCPeerConnection) {
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 pc.addIceCandidate(new RTCIceCandidate(payload.candidate));
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
- await nostrClient.connect();
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
- myPeerId = null;
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
- return peerConnections.get(peerId);
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
- log(`Sending ICE candidate to ${peerId.substring(0, 6)}...`, 'info');
445
- if (nostrClient) {
446
- sendSignal(peerId, { type: 'signal-ice', candidate: event.candidate.toJSON?.() || event.candidate });
447
- } else if (client) {
448
- client.sendIceCandidate(event.candidate, peerId);
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
- await sendSignal(peerId, { type: 'signal-offer', sdp: pc.localDescription });
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
 
@@ -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
- let stored = localStorage.getItem('nostr-secret-key');
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
- localStorage.removeItem('nostr-secret-key');
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
- localStorage.setItem('nostr-secret-key', state.secretKeyHex);
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
- since: now - 3600,
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
- const signed = finalizeEvent(eventTemplate, state.secretKeyHex);
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
  }