uniwrtc 1.2.0 → 1.3.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.3.0",
4
4
  "description": "A universal WebRTC signaling service",
5
5
  "main": "server.js",
6
6
  "type": "module",
package/src/main.js CHANGED
@@ -8,10 +8,28 @@ window.UniWRTCClient = UniWRTCClient;
8
8
  // Nostr is the default transport (no toggle)
9
9
  let nostrClient = null;
10
10
  let myPeerId = null;
11
+ let mySessionNonce = null;
12
+ const peerSessions = new Map();
13
+ const peerProbeState = new Map();
14
+ const readyPeers = new Set();
15
+ const deferredHelloPeers = new Set();
16
+
17
+ // Curated public relays (best-effort). Users can override in the UI.
18
+ const DEFAULT_RELAYS = [
19
+ 'wss://relay.primal.net',
20
+ 'wss://relay.nostr.band',
21
+ 'wss://nos.lol',
22
+ 'wss://relay.snort.social',
23
+ 'wss://nostr.wine',
24
+ 'wss://relay.damus.io',
25
+ ];
26
+
11
27
 
12
28
  let client = null;
13
29
  const peerConnections = new Map();
14
30
  const dataChannels = new Map();
31
+ const pendingIce = new Map();
32
+ const outboundIceBatches = new Map();
15
33
 
16
34
  // Initialize app
17
35
  document.getElementById('app').innerHTML = `
@@ -25,8 +43,8 @@ document.getElementById('app').innerHTML = `
25
43
  <h2>Connection</h2>
26
44
  <div class="connection-controls">
27
45
  <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;">
46
+ <label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">Relay URL(s)</label>
47
+ <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
48
  </div>
31
49
  <div>
32
50
  <label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">Room / Session ID</label>
@@ -94,6 +112,13 @@ if (urlRoom) {
94
112
  log(`Using default room ID: ${defaultRoom}`, 'info');
95
113
  }
96
114
 
115
+ // STUN-only ICE servers (no TURN)
116
+ const ICE_SERVERS = [
117
+ { urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] },
118
+ { urls: ['stun:stun2.l.google.com:19302', 'stun:stun3.l.google.com:19302'] },
119
+ { urls: ['stun:stun.cloudflare.com:3478'] },
120
+ ];
121
+
97
122
  function log(message, type = 'info') {
98
123
  const logContainer = document.getElementById('logContainer');
99
124
  const entry = document.createElement('div');
@@ -161,14 +186,90 @@ function shouldInitiateWith(peerId) {
161
186
  return myPeerId.localeCompare(peerId) < 0;
162
187
  }
163
188
 
189
+ function isPoliteFor(peerId) {
190
+ // In perfect negotiation, one side is "polite" (will accept/repair collisions)
191
+ return !shouldInitiateWith(peerId);
192
+ }
193
+
164
194
  function sendSignal(to, payload) {
165
195
  if (!nostrClient) throw new Error('Not connected to Nostr');
196
+
197
+ const toSession = peerSessions.get(to);
198
+ const type = payload?.type;
199
+ const needsToSession = type !== 'probe';
200
+
201
+ if (needsToSession && !toSession) throw new Error('No peer session yet');
202
+
203
+ return nostrClient.send({
204
+ ...payload,
205
+ to,
206
+ ...(needsToSession ? { toSession } : {}),
207
+ fromSession: mySessionNonce,
208
+ });
209
+ }
210
+
211
+ function sendSignalToSession(to, payload, toSession) {
212
+ if (!nostrClient) throw new Error('Not connected to Nostr');
213
+ if (!toSession) throw new Error('toSession is required');
166
214
  return nostrClient.send({
167
215
  ...payload,
168
216
  to,
217
+ toSession,
218
+ fromSession: mySessionNonce,
169
219
  });
170
220
  }
171
221
 
222
+ async function maybeProbePeer(peerId) {
223
+ if (!nostrClient) return;
224
+ const session = peerSessions.get(peerId);
225
+ if (!session) return;
226
+ if (!shouldInitiateWith(peerId)) return;
227
+
228
+ const last = peerProbeState.get(peerId);
229
+ if (last && last.session === session) return;
230
+
231
+ const probeId = Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
232
+ peerProbeState.set(peerId, { session, ts: Date.now(), probeId });
233
+ try {
234
+ await sendSignal(peerId, { type: 'probe', probeId });
235
+ log(`Probing peer ${peerId.substring(0, 6)}...`, 'info');
236
+ } catch (e) {
237
+ log(`Probe failed: ${e?.message || e}`, 'warning');
238
+ }
239
+ }
240
+
241
+ function logDrop(peerId, payload, reason) {
242
+ const t = payload?.type || 'unknown';
243
+ if (t !== 'signal-offer' && t !== 'signal-answer' && t !== 'signal-ice' && t !== 'signal-ice-batch' && t !== 'probe' && t !== 'probe-ack') return;
244
+ log(`Dropped ${t} from ${peerId.substring(0, 6)}... (${reason})`, 'warning');
245
+ }
246
+
247
+ async function resetPeerConnection(peerId) {
248
+ const existing = peerConnections.get(peerId);
249
+ if (existing instanceof RTCPeerConnection) {
250
+ try {
251
+ existing.close();
252
+ } catch {
253
+ // ignore
254
+ }
255
+ }
256
+ peerConnections.delete(peerId);
257
+ pendingIce.delete(peerId);
258
+
259
+ const dc = dataChannels.get(peerId);
260
+ if (dc) {
261
+ try {
262
+ dc.close();
263
+ } catch {
264
+ // ignore
265
+ }
266
+ }
267
+ dataChannels.delete(peerId);
268
+ updatePeerList();
269
+
270
+ return ensurePeerConnection(peerId);
271
+ }
272
+
172
273
  async function ensurePeerConnection(peerId) {
173
274
  if (!peerId || peerId === myPeerId) return null;
174
275
  if (peerConnections.has(peerId) && peerConnections.get(peerId) instanceof RTCPeerConnection) {
@@ -176,16 +277,67 @@ async function ensurePeerConnection(peerId) {
176
277
  }
177
278
 
178
279
  const initiator = shouldInitiateWith(peerId);
280
+
281
+ // Initiator must wait until the peer proves it's live (probe-ack), otherwise we end up
282
+ // negotiating with stale peers from relay history.
283
+ if (initiator && !readyPeers.has(peerId)) {
284
+ return null;
285
+ }
179
286
  const pc = await createPeerConnection(peerId, initiator);
180
287
  return pc;
181
288
  }
182
289
 
290
+ async function addIceCandidateSafely(peerId, candidate) {
291
+ const pc = peerConnections.get(peerId);
292
+ if (!(pc instanceof RTCPeerConnection)) return;
293
+
294
+ if (!pc.remoteDescription) {
295
+ const list = pendingIce.get(peerId) || [];
296
+ list.push(candidate);
297
+ pendingIce.set(peerId, list);
298
+ return;
299
+ }
300
+
301
+ await pc.addIceCandidate(new RTCIceCandidate(candidate));
302
+ }
303
+
304
+ async function flushPendingIce(peerId) {
305
+ const pc = peerConnections.get(peerId);
306
+ if (!(pc instanceof RTCPeerConnection)) return;
307
+ if (!pc.remoteDescription) return;
308
+
309
+ const list = pendingIce.get(peerId);
310
+ if (!list || list.length === 0) return;
311
+ pendingIce.delete(peerId);
312
+
313
+ for (const c of list) {
314
+ try {
315
+ await pc.addIceCandidate(new RTCIceCandidate(c));
316
+ } catch (e) {
317
+ log(`Failed to add queued ICE candidate: ${e?.message || e}`, 'warning');
318
+ }
319
+ }
320
+ }
321
+
183
322
  async function connectNostr() {
184
- const relayUrl = document.getElementById('relayUrl').value.trim();
323
+ const relayUrlRaw = document.getElementById('relayUrl').value.trim();
185
324
  const roomIdInput = document.getElementById('roomId');
186
325
  const roomId = roomIdInput.value.trim();
187
326
 
188
- if (!relayUrl) {
327
+ const relayCandidatesRaw = relayUrlRaw.toLowerCase() === 'auto'
328
+ ? DEFAULT_RELAYS
329
+ : Array.from(
330
+ new Set(
331
+ relayUrlRaw
332
+ .split(/[\s,]+/)
333
+ .map((s) => s.trim())
334
+ .filter(Boolean)
335
+ )
336
+ );
337
+
338
+ const relayCandidates = relayCandidatesRaw.length ? relayCandidatesRaw : DEFAULT_RELAYS;
339
+
340
+ if (relayCandidates.length === 0) {
189
341
  log('Please enter a relay URL', 'error');
190
342
  return;
191
343
  }
@@ -194,20 +346,69 @@ async function connectNostr() {
194
346
  if (!roomId) roomIdInput.value = effectiveRoom;
195
347
 
196
348
  try {
197
- log(`Connecting to Nostr relay: ${relayUrl}...`, 'info');
349
+ log(`Connecting to Nostr relay...`, 'info');
198
350
 
199
351
  if (nostrClient) {
200
352
  await nostrClient.disconnect();
201
353
  nostrClient = null;
202
354
  }
203
355
 
204
- nostrClient = createNostrClient({
356
+ // Reset local peer state to avoid stale sessions targeting the wrong browser tab.
357
+ myPeerId = null;
358
+ mySessionNonce = null;
359
+ peerSessions.clear();
360
+ peerProbeState.clear();
361
+ readyPeers.clear();
362
+ pendingIce.clear();
363
+ peerConnections.forEach((pc) => {
364
+ if (pc instanceof RTCPeerConnection) pc.close();
365
+ });
366
+ peerConnections.clear();
367
+ dataChannels.clear();
368
+ updatePeerList();
369
+
370
+ const wireHandlers = (client) => {
371
+ client.__handlers = {
372
+ onState: (state) => {
373
+ if (state === 'connected') updateStatus(true);
374
+ if (state === 'disconnected') updateStatus(false);
375
+ },
376
+ onNotice: (notice) => {
377
+ log(`Relay NOTICE: ${String(notice)}`, 'warning');
378
+ },
379
+ onOk: ({ id, ok, message }) => {
380
+ if (ok === false) log(`Relay rejected event ${String(id).slice(0, 8)}...: ${String(message)}`, 'error');
381
+ },
382
+ };
383
+ };
384
+
385
+ // Try relays async (in small parallel batches) and pick the first that accepts publishes.
386
+ const relayBatchSize = 3;
387
+ let lastError = null;
388
+ let selected = null;
389
+
390
+ // Identity must be ready before we connect/subscribe; inbound events can arrive immediately.
391
+ // Any client instance will derive the same per-tab keypair.
392
+ const identityClient = createNostrClient({ relayUrl: relayCandidates[0], room: effectiveRoom });
393
+ const myPubkey = identityClient.getPublicKey();
394
+ myPeerId = myPubkey;
395
+ mySessionNonce = Math.random().toString(36).slice(2) + Date.now().toString(36);
396
+ document.getElementById('clientId').textContent = myPubkey.substring(0, 16) + '...';
397
+ document.getElementById('sessionId').textContent = effectiveRoom;
398
+
399
+ const makeClient = (relayUrl) => createNostrClient({
205
400
  relayUrl,
206
401
  room: effectiveRoom,
207
402
  onState: (state) => {
208
403
  if (state === 'connected') updateStatus(true);
209
404
  if (state === 'disconnected') updateStatus(false);
210
405
  },
406
+ onNotice: (notice) => {
407
+ log(`Relay NOTICE (${relayUrl}): ${String(notice)}`, 'warning');
408
+ },
409
+ onOk: ({ id, ok, message }) => {
410
+ if (ok === false) log(`Relay rejected event ${String(id).slice(0, 8)}...: ${String(message)}`, 'error');
411
+ },
211
412
  onPayload: async ({ from, payload }) => {
212
413
  const peerId = from;
213
414
  if (!peerId || peerId === myPeerId) return;
@@ -221,23 +422,150 @@ async function connectNostr() {
221
422
 
222
423
  if (!payload || typeof payload !== 'object') return;
223
424
 
425
+ // NOTE: Do NOT learn/update peerSessions from arbitrary relay history.
426
+ // Only trust:
427
+ // - `hello` (broadcast presence)
428
+ // - messages targeted to this tab via `toSession === mySessionNonce`
429
+
224
430
  // Presence
225
431
  if (payload.type === 'hello') {
226
- await ensurePeerConnection(peerId);
432
+ if (!myPeerId || !mySessionNonce) return;
433
+ if (typeof payload.session !== 'string' || payload.session.length < 6) return;
434
+ const prev = peerSessions.get(peerId);
435
+ peerSessions.set(peerId, payload.session);
436
+ if (!prev || prev !== payload.session) {
437
+ log(`Peer session updated: ${peerId.substring(0, 6)}...`, 'info');
438
+ }
439
+
440
+ if (prev && prev !== payload.session) {
441
+ readyPeers.delete(peerId);
442
+ }
443
+
444
+ // We may receive peer presence while still selecting a relay.
445
+ // Store and probe once we have a selected/connected `nostrClient`.
446
+ deferredHelloPeers.add(peerId);
447
+ await maybeProbePeer(peerId);
448
+ return;
449
+ }
450
+
451
+ if (payload.type === 'probe') {
452
+ // Learn the peer's session from a live message.
453
+ if (typeof payload.fromSession === 'string' && payload.fromSession.length >= 6) {
454
+ const prev = peerSessions.get(peerId);
455
+ if (!prev || prev !== payload.fromSession) {
456
+ peerSessions.set(peerId, payload.fromSession);
457
+ log(`Peer session ${prev ? 'rotated' : 'learned'}: ${peerId.substring(0, 6)}...`, 'info');
458
+ readyPeers.delete(peerId);
459
+ }
460
+ }
461
+
462
+ // Reply directly to the sender's session (fromSession) so the initiator doesn't drop it.
463
+ try {
464
+ await sendSignalToSession(peerId, { type: 'probe-ack', probeId: payload.probeId }, payload.fromSession);
465
+ log(`Probe ack -> ${peerId.substring(0, 6)}...`, 'info');
466
+ } catch (e) {
467
+ log(`Probe-ack failed: ${e?.message || e}`, 'warning');
468
+ }
469
+ return;
470
+ }
471
+
472
+ // Only accept signaling intended for THIS browser session
473
+ if (payload.toSession && payload.toSession !== mySessionNonce) {
474
+ logDrop(peerId, payload, 'toSession mismatch');
227
475
  return;
228
476
  }
229
477
 
230
478
  // Signaling messages are always targeted
231
- if (payload.to && payload.to !== myPeerId) return;
479
+ if (payload.to && payload.to !== myPeerId) {
480
+ logDrop(peerId, payload, 'to mismatch');
481
+ return;
482
+ }
483
+
484
+ // Now that we know it's targeted to this session, we can safely learn peer session.
485
+ if (typeof payload.fromSession === 'string' && payload.fromSession.length >= 6) {
486
+ const prev = peerSessions.get(peerId);
487
+ if (!prev || prev !== payload.fromSession) {
488
+ peerSessions.set(peerId, payload.fromSession);
489
+ log(`Peer session ${prev ? 'rotated' : 'learned'}: ${peerId.substring(0, 6)}...`, 'info');
490
+ readyPeers.delete(peerId);
491
+ }
492
+ }
493
+
494
+ if (payload.type === 'probe-ack') {
495
+ const last = peerProbeState.get(peerId);
496
+ if (!last || !last.probeId || !payload.probeId || payload.probeId !== last.probeId) {
497
+ logDrop(peerId, payload, 'probeId mismatch');
498
+ return;
499
+ }
500
+ // Peer session can legitimately rotate between hello/probe/ack (reloads, relay history).
501
+ // Since this message is already targeted to our toSession, accept it and update our view.
502
+ if (typeof payload.fromSession === 'string' && payload.fromSession.length >= 6) {
503
+ if (peerSessions.get(peerId) !== payload.fromSession) {
504
+ peerSessions.set(peerId, payload.fromSession);
505
+ }
506
+ if (last.session !== payload.fromSession) {
507
+ last.session = payload.fromSession;
508
+ }
509
+ }
510
+ if (Date.now() - last.ts > 30000) {
511
+ logDrop(peerId, payload, 'stale probe-ack');
512
+ return;
513
+ }
514
+
515
+ readyPeers.add(peerId);
516
+ if (shouldInitiateWith(peerId)) {
517
+ log(`Probe ack <- ${peerId.substring(0, 6)}...`, 'info');
518
+ await ensurePeerConnection(peerId);
519
+ }
520
+ return;
521
+ }
232
522
 
233
523
  if (payload.type === 'signal-offer' && payload.sdp) {
234
524
  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');
525
+ let pc = await ensurePeerConnection(peerId);
526
+ if (!pc) {
527
+ // As the receiver we should always accept an offer even if probe logic didn't run.
528
+ pc = await resetPeerConnection(peerId);
529
+ }
530
+ if (!(pc instanceof RTCPeerConnection)) return;
531
+
532
+ // Offer collision handling: if we're not stable, decide whether to ignore or reset.
533
+ const offerCollision = pc.signalingState !== 'stable';
534
+ if (offerCollision && !isPoliteFor(peerId)) {
535
+ log(`Ignoring offer collision from ${peerId.substring(0, 6)}...`, 'warning');
536
+ return;
537
+ }
538
+ if (offerCollision && isPoliteFor(peerId)) {
539
+ log(`Offer collision; resetting connection with ${peerId.substring(0, 6)}...`, 'warning');
540
+ await resetPeerConnection(peerId);
541
+ const next = peerConnections.get(peerId);
542
+ if (!(next instanceof RTCPeerConnection)) return;
543
+ pc = next;
544
+ }
545
+
546
+ const pc2 = peerConnections.get(peerId);
547
+ if (!(pc2 instanceof RTCPeerConnection)) return;
548
+
549
+ // Only accept offers here.
550
+ if (payload.sdp?.type && payload.sdp.type !== 'offer') {
551
+ log(`Ignoring non-offer in signal-offer from ${peerId.substring(0, 6)}...`, 'warning');
552
+ return;
553
+ }
554
+
555
+ await pc2.setRemoteDescription(new RTCSessionDescription(payload.sdp));
556
+ await flushPendingIce(peerId);
557
+ if (pc2.signalingState !== 'have-remote-offer') {
558
+ log(`Not answering; unexpected state: ${pc2.signalingState}`, 'warning');
559
+ return;
560
+ }
561
+ const answer = await pc2.createAnswer();
562
+ await pc2.setLocalDescription(answer);
563
+ try {
564
+ await sendSignal(peerId, { type: 'signal-answer', sdp: { type: pc2.localDescription.type, sdp: pc2.localDescription.sdp } });
565
+ log(`Sent answer to ${peerId.substring(0, 6)}...`, 'success');
566
+ } catch (e) {
567
+ log(`Failed to send answer: ${e?.message || e}`, 'error');
568
+ }
241
569
  return;
242
570
  }
243
571
 
@@ -245,16 +573,31 @@ async function connectNostr() {
245
573
  log(`Received answer from ${peerId.substring(0, 6)}...`, 'info');
246
574
  const pc = peerConnections.get(peerId);
247
575
  if (pc instanceof RTCPeerConnection) {
576
+ if (payload.sdp?.type && payload.sdp.type !== 'answer') {
577
+ log(`Ignoring non-answer in signal-answer from ${peerId.substring(0, 6)}...`, 'warning');
578
+ return;
579
+ }
248
580
  await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp));
581
+ await flushPendingIce(peerId);
249
582
  }
250
583
  return;
251
584
  }
252
585
 
253
586
  if (payload.type === 'signal-ice' && payload.candidate) {
254
- const pc = peerConnections.get(peerId);
255
- if (pc instanceof RTCPeerConnection) {
587
+ log(`Received ICE candidate from ${peerId.substring(0, 6)}...`, 'info');
588
+ try {
589
+ await addIceCandidateSafely(peerId, payload.candidate);
590
+ } catch (e) {
591
+ log(`Failed to add ICE candidate: ${e?.message || e}`, 'warning');
592
+ }
593
+ return;
594
+ }
595
+
596
+ if (payload.type === 'signal-ice-batch' && Array.isArray(payload.candidates)) {
597
+ log(`Received ICE batch (${payload.candidates.length}) from ${peerId.substring(0, 6)}...`, 'info');
598
+ for (const c of payload.candidates) {
256
599
  try {
257
- await pc.addIceCandidate(new RTCIceCandidate(payload.candidate));
600
+ await addIceCandidateSafely(peerId, c);
258
601
  } catch (e) {
259
602
  log(`Failed to add ICE candidate: ${e?.message || e}`, 'warning');
260
603
  }
@@ -263,13 +606,58 @@ async function connectNostr() {
263
606
  },
264
607
  });
265
608
 
266
- await nostrClient.connect();
609
+ const tryOneRelay = async (relayUrl) => {
610
+ const candidateClient = makeClient(relayUrl);
611
+ await candidateClient.connect();
612
+ const ok = await candidateClient.sendWithOk({ type: 'relay-check', session: mySessionNonce }, { timeoutMs: 3500 });
613
+ if (ok.ok !== true) throw new Error(ok.message || 'Relay rejected publish');
614
+ return { relayUrl, client: candidateClient };
615
+ };
616
+
617
+ for (let i = 0; i < relayCandidates.length && !selected; i += relayBatchSize) {
618
+ const batch = relayCandidates.slice(i, i + relayBatchSize);
619
+ batch.forEach((u) => log(`Trying relay: ${u}`, 'info'));
620
+
621
+ const clientsInBatch = new Map();
622
+ const attempts = batch.map((relayUrl) => (async () => {
623
+ const result = await tryOneRelay(relayUrl);
624
+ clientsInBatch.set(relayUrl, result.client);
625
+ return result;
626
+ })());
627
+
628
+ try {
629
+ selected = await Promise.any(attempts);
630
+ } catch (e) {
631
+ lastError = e;
632
+ } finally {
633
+ // Close any batch clients that were created but not selected.
634
+ for (const [url, c] of clientsInBatch.entries()) {
635
+ if (selected && selected.relayUrl === url) continue;
636
+ try {
637
+ await c.disconnect();
638
+ } catch {
639
+ // ignore
640
+ }
641
+ }
642
+ }
643
+ }
644
+
645
+ if (!selected) {
646
+ throw lastError || new Error('No relay candidates available');
647
+ }
648
+
649
+ nostrClient = selected.client;
650
+ document.getElementById('relayUrl').value = selected.relayUrl;
651
+ log(`Selected relay: ${selected.relayUrl}`, 'success');
267
652
 
268
- const myPubkey = nostrClient.getPublicKey();
269
- myPeerId = myPubkey;
270
- document.getElementById('clientId').textContent = myPubkey.substring(0, 16) + '...';
271
- document.getElementById('sessionId').textContent = effectiveRoom;
272
653
  log(`Joined Nostr room: ${effectiveRoom}`, 'success');
654
+ await nostrClient.send({ type: 'hello', session: mySessionNonce });
655
+
656
+ // Kick any peers we saw while selecting relays.
657
+ for (const peerId of deferredHelloPeers) {
658
+ await maybeProbePeer(peerId);
659
+ }
660
+ deferredHelloPeers.clear();
273
661
 
274
662
  updateStatus(true);
275
663
 
@@ -406,6 +794,11 @@ window.disconnect = function() {
406
794
  nostrClient.disconnect().catch(() => {});
407
795
  nostrClient = null;
408
796
  myPeerId = null;
797
+ mySessionNonce = null;
798
+ peerSessions.clear();
799
+ peerProbeState.clear();
800
+ readyPeers.clear();
801
+ pendingIce.clear();
409
802
  peerConnections.forEach((pc) => {
410
803
  if (pc instanceof RTCPeerConnection) pc.close();
411
804
  });
@@ -427,27 +820,69 @@ window.disconnect = function() {
427
820
 
428
821
  async function createPeerConnection(peerId, shouldInitiate) {
429
822
  if (peerConnections.has(peerId)) {
823
+ const existing = peerConnections.get(peerId);
824
+ if (existing instanceof RTCPeerConnection) {
430
825
  log(`Peer connection already exists for ${peerId.substring(0, 6)}...`, 'warning');
431
- return peerConnections.get(peerId);
826
+ return existing;
827
+ }
828
+
829
+ // Placeholder entry (e.g., peer "seen" list). Replace it with a real RTCPeerConnection.
830
+ peerConnections.delete(peerId);
432
831
  }
433
832
 
434
833
  log(`Creating peer connection with ${peerId.substring(0, 6)}... (shouldInitiate: ${shouldInitiate})`, 'info');
435
834
 
436
835
  const pc = new RTCPeerConnection({
437
- iceServers: [
438
- { urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] }
439
- ]
836
+ iceServers: ICE_SERVERS,
440
837
  });
441
838
 
839
+ // Register early to avoid races where answer/ICE arrives before this function finishes.
840
+ peerConnections.set(peerId, pc);
841
+ pendingIce.delete(peerId);
842
+ updatePeerList();
843
+
442
844
  pc.onicecandidate = (event) => {
845
+ if (!nostrClient && client && event.candidate) {
846
+ client.sendIceCandidate(event.candidate, peerId);
847
+ return;
848
+ }
849
+
850
+ if (!nostrClient) return;
851
+
852
+ const entry = outboundIceBatches.get(peerId) || { candidates: [], timer: null };
853
+ outboundIceBatches.set(peerId, entry);
854
+
443
855
  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
- }
856
+ entry.candidates.push(event.candidate.toJSON?.() || event.candidate);
857
+ }
858
+
859
+ const flush = () => {
860
+ entry.timer = null;
861
+ if (!entry.candidates.length) return;
862
+ const batch = entry.candidates.splice(0, entry.candidates.length);
863
+ log(`Sending ICE batch (${batch.length}) to ${peerId.substring(0, 6)}...`, 'info');
864
+ sendSignal(peerId, { type: 'signal-ice-batch', candidates: batch }).catch((e) => {
865
+ log(`Failed to send ICE batch: ${e?.message || e}`, 'warning');
866
+ });
867
+ };
868
+
869
+ // If end-of-candidates, flush immediately; otherwise debounce.
870
+ if (!event.candidate) {
871
+ flush();
872
+ return;
450
873
  }
874
+
875
+ if (!entry.timer) {
876
+ entry.timer = setTimeout(flush, 250);
877
+ }
878
+ };
879
+
880
+ pc.oniceconnectionstatechange = () => {
881
+ log(`ICE state (${peerId.substring(0, 6)}...): ${pc.iceConnectionState}`, 'info');
882
+ };
883
+
884
+ pc.onconnectionstatechange = () => {
885
+ log(`Conn state (${peerId.substring(0, 6)}...): ${pc.connectionState}`, 'info');
451
886
  };
452
887
 
453
888
  pc.ondatachannel = (event) => {
@@ -462,17 +897,19 @@ async function createPeerConnection(peerId, shouldInitiate) {
462
897
  const offer = await pc.createOffer();
463
898
  await pc.setLocalDescription(offer);
464
899
  if (nostrClient) {
465
- await sendSignal(peerId, { type: 'signal-offer', sdp: pc.localDescription });
900
+ try {
901
+ await sendSignal(peerId, { type: 'signal-offer', sdp: { type: pc.localDescription.type, sdp: pc.localDescription.sdp } });
902
+ log(`Sent offer to ${peerId.substring(0, 6)}...`, 'success');
903
+ } catch (e) {
904
+ log(`Failed to send offer: ${e?.message || e}`, 'warning');
905
+ }
466
906
  } else if (client) {
467
907
  client.sendOffer(offer, peerId);
468
- }
469
908
  log(`Sent offer to ${peerId.substring(0, 6)}...`, 'success');
909
+ }
470
910
  } else {
471
911
  log(`Waiting for offer from ${peerId.substring(0, 6)}...`, 'info');
472
912
  }
473
-
474
- peerConnections.set(peerId, pc);
475
- updatePeerList();
476
913
  return pc;
477
914
  }
478
915
 
@@ -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
  }