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 +1 -1
- package/src/main.js +474 -37
- package/src/nostr/nostrClient.js +71 -9
package/package.json
CHANGED
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="
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|