@hashtree/nostr 0.1.3 → 0.1.5

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.
Files changed (46) hide show
  1. package/README.md +51 -0
  2. package/dist/events.d.ts +66 -0
  3. package/dist/events.d.ts.map +1 -0
  4. package/dist/events.js +426 -0
  5. package/dist/events.js.map +1 -0
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +6 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/resolver/nostr.d.ts +2 -0
  11. package/dist/resolver/nostr.d.ts.map +1 -1
  12. package/dist/resolver/nostr.js +122 -31
  13. package/dist/resolver/nostr.js.map +1 -1
  14. package/dist/snapshot.d.ts +30 -0
  15. package/dist/snapshot.d.ts.map +1 -0
  16. package/dist/snapshot.js +157 -0
  17. package/dist/snapshot.js.map +1 -0
  18. package/dist/treeEventSnapshots.d.ts +30 -0
  19. package/dist/treeEventSnapshots.d.ts.map +1 -0
  20. package/dist/treeEventSnapshots.js +186 -0
  21. package/dist/treeEventSnapshots.js.map +1 -0
  22. package/dist/webrtc/index.d.ts +3 -2
  23. package/dist/webrtc/index.d.ts.map +1 -1
  24. package/dist/webrtc/index.js +3 -2
  25. package/dist/webrtc/index.js.map +1 -1
  26. package/dist/webrtc/peer.d.ts +12 -1
  27. package/dist/webrtc/peer.d.ts.map +1 -1
  28. package/dist/webrtc/peer.js +39 -5
  29. package/dist/webrtc/peer.js.map +1 -1
  30. package/dist/webrtc/peerSelector.d.ts +31 -0
  31. package/dist/webrtc/peerSelector.d.ts.map +1 -0
  32. package/dist/webrtc/peerSelector.js +348 -0
  33. package/dist/webrtc/peerSelector.js.map +1 -0
  34. package/dist/webrtc/protocol.d.ts +15 -4
  35. package/dist/webrtc/protocol.d.ts.map +1 -1
  36. package/dist/webrtc/protocol.js +31 -17
  37. package/dist/webrtc/protocol.js.map +1 -1
  38. package/dist/webrtc/store.d.ts +31 -4
  39. package/dist/webrtc/store.d.ts.map +1 -1
  40. package/dist/webrtc/store.js +362 -99
  41. package/dist/webrtc/store.js.map +1 -1
  42. package/dist/webrtc/types.d.ts +66 -4
  43. package/dist/webrtc/types.d.ts.map +1 -1
  44. package/dist/webrtc/types.js +71 -9
  45. package/dist/webrtc/types.js.map +1 -1
  46. package/package.json +8 -2
@@ -14,14 +14,15 @@
14
14
  * - 'other' pool: Everyone else (randos)
15
15
  * Each pool has its own connection limits.
16
16
  */
17
- import { SimplePool } from 'nostr-tools';
18
- import { toHex } from '@hashtree/core';
19
- import { PeerId, generateUuid, } from './types.js';
17
+ import { SimplePool, verifyEvent } from 'nostr-tools';
18
+ import { fromHex, sha256, toHex } from '@hashtree/core';
19
+ import { PeerId, createMeshNostrEventFrame, validateMeshNostrFrame, MESH_EVENT_POLICY, } from './types.js';
20
+ import { decrementHTLWithPolicy, shouldForwardHTL } from './protocol.js';
21
+ import { PeerSelector, buildHedgedWavePlan, normalizeDispatchConfig, syncSelectorPeers, } from './peerSelector.js';
20
22
  import { Peer } from './peer.js';
21
23
  export const DEFAULT_RELAYS = [
22
24
  'wss://relay.damus.io',
23
25
  'wss://relay.primal.net',
24
- 'wss://nos.lol',
25
26
  'wss://relay.nostr.band',
26
27
  'wss://temp.iris.to',
27
28
  'wss://relay.snort.social',
@@ -31,8 +32,21 @@ export const DEFAULT_RELAYS = [
31
32
  // Directed messages use #p tag with gift wrap
32
33
  const SIGNALING_KIND = 25050;
33
34
  const HELLO_TAG = 'hello';
35
+ const SEEN_FRAME_CAP = 4096;
36
+ const SEEN_FRAME_TTL_MS = 120_000;
37
+ const SEEN_EVENT_CAP = 8192;
38
+ const SEEN_EVENT_TTL_MS = 600_000;
39
+ const PEER_METADATA_POINTER_SLOT_KEY = 'hashtree-webrtc/peer-metadata/latest/v1';
40
+ const DEFAULT_REQUEST_DISPATCH = {
41
+ initialFanout: 2,
42
+ hedgeFanout: 1,
43
+ maxFanout: 8,
44
+ hedgeIntervalMs: 120,
45
+ };
34
46
  export class WebRTCStore {
35
47
  config;
48
+ routing;
49
+ peerSelector;
36
50
  pools;
37
51
  peerClassifier;
38
52
  getFollowedPubkeys;
@@ -60,13 +74,20 @@ export class WebRTCStore {
60
74
  blossomFetches = 0;
61
75
  // Track current hello subscription authors for change detection
62
76
  currentHelloAuthors = null;
77
+ // Relayless mesh dedupe
78
+ seenFrameIds = new Map();
79
+ seenEventIds = new Map();
80
+ // Relayless mesh stats
81
+ meshReceived = 0;
82
+ meshForwarded = 0;
83
+ meshDroppedDuplicate = 0;
63
84
  constructor(config) {
64
85
  this.signer = config.signer;
65
86
  this.encrypt = config.encrypt;
66
87
  this.decrypt = config.decrypt;
67
88
  this.giftWrap = config.giftWrap;
68
89
  this.giftUnwrap = config.giftUnwrap;
69
- this.myPeerId = new PeerId(config.pubkey, generateUuid());
90
+ this.myPeerId = new PeerId(config.pubkey);
70
91
  // Default classifier: everyone is 'other' unless classifier provided
71
92
  this.peerClassifier = config.peerClassifier ?? (() => 'other');
72
93
  // Function to get followed pubkeys for subscription filtering
@@ -87,7 +108,7 @@ export class WebRTCStore {
87
108
  };
88
109
  }
89
110
  this.config = {
90
- helloInterval: config.helloInterval ?? 10000,
111
+ helloInterval: config.helloInterval ?? 3000,
91
112
  messageTimeout: config.messageTimeout ?? 60000, // 60 seconds for relay propagation
92
113
  requestTimeout: config.requestTimeout ?? 500,
93
114
  peerQueryDelay: config.peerQueryDelay ?? 500,
@@ -96,6 +117,21 @@ export class WebRTCStore {
96
117
  fallbackStores: config.fallbackStores ?? [],
97
118
  debug: config.debug ?? false,
98
119
  };
120
+ const dispatch = config.requestDispatch ?? (config.peerQueryDelay !== undefined
121
+ ? {
122
+ initialFanout: 1,
123
+ hedgeFanout: 1,
124
+ maxFanout: Number.MAX_SAFE_INTEGER,
125
+ hedgeIntervalMs: Math.max(0, config.peerQueryDelay),
126
+ }
127
+ : DEFAULT_REQUEST_DISPATCH);
128
+ this.routing = {
129
+ selectionStrategy: config.requestSelectionStrategy ?? 'titForTat',
130
+ fairnessEnabled: config.requestFairnessEnabled ?? true,
131
+ dispatch,
132
+ };
133
+ this.peerSelector = PeerSelector.withStrategy(this.routing.selectionStrategy);
134
+ this.peerSelector.setFairness(this.routing.fairnessEnabled);
99
135
  this.pool = new SimplePool();
100
136
  }
101
137
  log(...args) {
@@ -193,11 +229,14 @@ export class WebRTCStore {
193
229
  }
194
230
  this.subscriptions = [];
195
231
  // Close all peer connections
196
- for (const { peer } of this.peers.values()) {
232
+ for (const [peerId, { peer }] of this.peers.entries()) {
233
+ this.peerSelector.removePeer(peerId);
197
234
  peer.close();
198
235
  }
199
236
  this.peers.clear();
200
237
  this.pendingOtherPubkeys.clear();
238
+ this.seenFrameIds.clear();
239
+ this.seenEventIds.clear();
201
240
  }
202
241
  /**
203
242
  * Set relays and reconnect. Useful for runtime relay configuration changes.
@@ -214,7 +253,8 @@ export class WebRTCStore {
214
253
  }
215
254
  this.subscriptions = [];
216
255
  // Clear existing peers (they were discovered via old relays)
217
- for (const { peer } of this.peers.values()) {
256
+ for (const [peerId, { peer }] of this.peers.entries()) {
257
+ this.peerSelector.removePeer(peerId);
218
258
  peer.close();
219
259
  }
220
260
  this.peers.clear();
@@ -345,9 +385,8 @@ export class WebRTCStore {
345
385
  // Check if this is a hello message (#l: hello tag)
346
386
  const lTag = event.tags.find(t => t[0] === 'l')?.[1];
347
387
  if (lTag === HELLO_TAG) {
348
- const peerIdTag = event.tags.find(t => t[0] === 'peerId')?.[1];
349
- if (peerIdTag) {
350
- await this.handleHello(peerIdTag, event.pubkey);
388
+ if (event.tags.some((tag) => tag[0] === 'peerId' && typeof tag[1] === 'string')) {
389
+ await this.handleHello(event.pubkey);
351
390
  }
352
391
  return;
353
392
  }
@@ -369,26 +408,32 @@ export class WebRTCStore {
369
408
  }
370
409
  }
371
410
  async handleSignalingMessage(msg, senderPubkey) {
411
+ const targetPeerId = PeerId.fromString(msg.targetPeerId).toString();
372
412
  // Directed message - check if it's for us
373
- if (msg.targetPeerId !== this.myPeerId.toString()) {
413
+ if (targetPeerId !== this.myPeerId.toString()) {
374
414
  return;
375
415
  }
376
- const peerId = new PeerId(senderPubkey, msg.peerId);
416
+ const peerId = new PeerId(senderPubkey);
377
417
  const peerIdStr = peerId.toString();
378
- if (msg.type === 'offer') {
379
- await this.handleOffer(peerId, msg);
418
+ const normalizedMsg = {
419
+ ...msg,
420
+ peerId: peerIdStr,
421
+ targetPeerId,
422
+ };
423
+ if (normalizedMsg.type === 'offer') {
424
+ await this.handleOffer(peerId, normalizedMsg);
380
425
  }
381
426
  else {
382
427
  // answer or candidate
383
428
  const peerInfo = this.peers.get(peerIdStr);
384
429
  if (peerInfo) {
385
- await peerInfo.peer.handleSignaling(msg);
430
+ await peerInfo.peer.handleSignaling(normalizedMsg);
386
431
  }
387
432
  }
388
433
  }
389
- async handleHello(peerUuid, senderPubkey) {
390
- const peerId = new PeerId(senderPubkey, peerUuid);
391
- // Skip self (exact same peerId = same session)
434
+ async handleHello(senderPubkey) {
435
+ const peerId = new PeerId(senderPubkey);
436
+ // Skip self
392
437
  if (peerId.toString() === this.myPeerId.toString()) {
393
438
  return;
394
439
  }
@@ -459,26 +504,30 @@ export class WebRTCStore {
459
504
  // Clean up existing connection if any
460
505
  const existing = this.peers.get(peerIdStr);
461
506
  if (existing) {
507
+ this.peerSelector.removePeer(peerIdStr);
462
508
  existing.peer.close();
463
509
  this.peers.delete(peerIdStr);
464
510
  }
465
511
  const peer = new Peer({
466
512
  peerId,
467
- myPeerId: this.myPeerId.uuid,
513
+ myPeerId: this.myPeerId.toString(),
468
514
  direction: 'inbound',
469
515
  localStore: this.config.localStore,
470
- sendSignaling: (m) => this.sendSignaling(m, peerId.pubkey),
516
+ sendSignaling: (m) => this.dispatchSignaling(m, peerId.pubkey),
471
517
  onClose: () => this.handlePeerClose(peerIdStr),
472
518
  onConnected: () => {
473
519
  this.emit({ type: 'peer-connected', peerId: peerIdStr });
474
520
  this.emit({ type: 'update' });
521
+ this.maybeSendHello();
475
522
  this.tryPendingReqs(peer);
476
523
  },
477
524
  onForwardRequest: (hash, exclude, htl) => this.forwardRequest(hash, exclude, htl),
525
+ onMeshFrame: (fromPeerId, frame) => this.handleMeshFrame(fromPeerId, frame),
478
526
  requestTimeout: this.config.requestTimeout,
479
527
  debug: this.config.debug,
480
528
  });
481
529
  this.peers.set(peerIdStr, { peer, pool });
530
+ this.peerSelector.addPeer(peerIdStr);
482
531
  // Clear pending now that peer is in the map
483
532
  this.pendingOtherPubkeys.delete(peerId.pubkey);
484
533
  await peer.handleSignaling(msg);
@@ -491,70 +540,149 @@ export class WebRTCStore {
491
540
  this.log('Initiating connection to', peerId.short(), 'pool:', pool);
492
541
  const peer = new Peer({
493
542
  peerId,
494
- myPeerId: this.myPeerId.uuid,
543
+ myPeerId: this.myPeerId.toString(),
495
544
  direction: 'outbound',
496
545
  localStore: this.config.localStore,
497
- sendSignaling: (m) => this.sendSignaling(m, peerId.pubkey),
546
+ sendSignaling: (m) => this.dispatchSignaling(m, peerId.pubkey),
498
547
  onClose: () => this.handlePeerClose(peerIdStr),
499
548
  onConnected: () => {
500
549
  this.emit({ type: 'peer-connected', peerId: peerIdStr });
501
550
  this.emit({ type: 'update' });
551
+ this.maybeSendHello();
502
552
  this.tryPendingReqs(peer);
503
553
  },
504
554
  onForwardRequest: (hash, exclude, htl) => this.forwardRequest(hash, exclude, htl),
555
+ onMeshFrame: (fromPeerId, frame) => this.handleMeshFrame(fromPeerId, frame),
505
556
  requestTimeout: this.config.requestTimeout,
506
557
  debug: this.config.debug,
507
558
  });
508
559
  this.peers.set(peerIdStr, { peer, pool });
560
+ this.peerSelector.addPeer(peerIdStr);
509
561
  await peer.connect();
510
562
  }
511
563
  handlePeerClose(peerIdStr) {
564
+ this.peerSelector.removePeer(peerIdStr);
512
565
  this.peers.delete(peerIdStr);
513
566
  this.emit({ type: 'peer-disconnected', peerId: peerIdStr });
514
567
  this.emit({ type: 'update' });
515
568
  }
516
- /**
517
- * Forward a request to other peers (excluding the requester)
518
- * Called by Peer when it receives a request it can't fulfill locally
519
- * Uses sequential queries with delays between attempts
520
- * @param htl - Hops To Live (already decremented by calling peer)
521
- */
522
- async forwardRequest(hash, excludePeerId, htl) {
523
- // Try all connected peers except the one who requested
524
- const otherPeers = Array.from(this.peers.values())
525
- .filter(({ peer }) => peer.isConnected && peer.peerId !== excludePeerId);
526
- // Sort: follows first
527
- otherPeers.sort((a, b) => {
569
+ orderedConnectedPeers(excludePeerId) {
570
+ const connectedAll = Array.from(this.peers.values())
571
+ .filter(({ peer }) => peer.isConnected);
572
+ if (connectedAll.length === 0)
573
+ return [];
574
+ const currentPeerIds = connectedAll.map(({ peer }) => peer.peerId);
575
+ syncSelectorPeers(this.peerSelector, currentPeerIds);
576
+ const connected = connectedAll
577
+ .filter(({ peer }) => !excludePeerId || peer.peerId !== excludePeerId);
578
+ const order = this.peerSelector.selectPeers();
579
+ const rank = new Map(order.map((peerId, idx) => [peerId, idx]));
580
+ connected.sort((a, b) => {
528
581
  if (a.pool === 'follows' && b.pool !== 'follows')
529
582
  return -1;
530
583
  if (a.pool !== 'follows' && b.pool === 'follows')
531
584
  return 1;
532
- return 0;
585
+ return (rank.get(a.peer.peerId) ?? Number.MAX_SAFE_INTEGER) -
586
+ (rank.get(b.peer.peerId) ?? Number.MAX_SAFE_INTEGER);
533
587
  });
534
- // Query peers sequentially with delay between attempts
535
- for (let i = 0; i < otherPeers.length; i++) {
536
- const { peer } = otherPeers[i];
537
- // Start request to this peer with the decremented HTL
538
- const requestPromise = peer.request(hash, htl);
539
- // Race between request completing and delay timeout
540
- const result = await Promise.race([
541
- requestPromise.then(data => ({ type: 'data', data })),
542
- this.delay(this.config.peerQueryDelay).then(() => ({ type: 'timeout' })),
543
- ]);
544
- if (result.type === 'data' && result.data) {
545
- // Got data from this peer
588
+ return connected.map(({ peer }) => peer);
589
+ }
590
+ createInFlightPeerRequest(peer, hash, htl) {
591
+ const startedAt = Date.now();
592
+ this.peerSelector.recordRequest(peer.peerId, 40);
593
+ const promise = peer.request(hash, htl).then((data) => ({
594
+ peerId: peer.peerId,
595
+ data,
596
+ elapsedMs: Math.max(1, Date.now() - startedAt),
597
+ })).catch(() => ({
598
+ peerId: peer.peerId,
599
+ data: null,
600
+ elapsedMs: Math.max(1, Date.now() - startedAt),
601
+ }));
602
+ return { peerId: peer.peerId, settled: false, promise };
603
+ }
604
+ async waitForNextPeerResult(inFlight, waitMs) {
605
+ const active = inFlight.filter((task) => !task.settled);
606
+ if (active.length === 0 || waitMs <= 0)
607
+ return null;
608
+ const timeout = this.delay(waitMs).then(() => null);
609
+ const outcome = await Promise.race([
610
+ timeout,
611
+ ...active.map((task) => task.promise.then((result) => ({
612
+ task,
613
+ data: result.data,
614
+ elapsedMs: result.elapsedMs,
615
+ }))),
616
+ ]);
617
+ if (!outcome)
618
+ return null;
619
+ outcome.task.settled = true;
620
+ return outcome;
621
+ }
622
+ async queryPeersWithDispatch(hash, orderedPeers, triedPeers, htl) {
623
+ if (orderedPeers.length === 0)
624
+ return null;
625
+ const dispatch = normalizeDispatchConfig(this.routing.dispatch, orderedPeers.length);
626
+ const wavePlan = buildHedgedWavePlan(orderedPeers.length, dispatch);
627
+ if (wavePlan.length === 0)
628
+ return null;
629
+ const deadline = Date.now() + this.config.requestTimeout;
630
+ const inFlight = [];
631
+ let nextPeerIdx = 0;
632
+ const expectedHashHex = toHex(hash);
633
+ for (let waveIdx = 0; waveIdx < wavePlan.length; waveIdx++) {
634
+ const waveSize = wavePlan[waveIdx];
635
+ const from = nextPeerIdx;
636
+ const to = Math.min(from + waveSize, orderedPeers.length);
637
+ nextPeerIdx = to;
638
+ for (const peer of orderedPeers.slice(from, to)) {
639
+ triedPeers.add(peer.peerId);
640
+ inFlight.push(this.createInFlightPeerRequest(peer, hash, htl));
641
+ }
642
+ const isLastWave = waveIdx === wavePlan.length - 1 || nextPeerIdx >= orderedPeers.length;
643
+ const windowEnd = isLastWave
644
+ ? deadline
645
+ : Math.min(deadline, Date.now() + dispatch.hedgeIntervalMs);
646
+ while (Date.now() < windowEnd) {
647
+ const remaining = windowEnd - Date.now();
648
+ const result = await this.waitForNextPeerResult(inFlight, remaining);
649
+ if (!result)
650
+ break;
651
+ if (!result.data) {
652
+ this.peerSelector.recordTimeout(result.task.peerId);
653
+ continue;
654
+ }
655
+ const computedHash = await sha256(result.data);
656
+ if (toHex(computedHash) !== expectedHashHex) {
657
+ this.peerSelector.recordFailure(result.task.peerId);
658
+ continue;
659
+ }
660
+ this.peerSelector.recordSuccess(result.task.peerId, result.elapsedMs, result.data.length);
546
661
  if (this.config.localStore) {
547
662
  await this.config.localStore.put(hash, result.data);
548
663
  }
549
664
  return result.data;
550
665
  }
551
- // If timeout, continue to next peer
552
- if (result.type === 'timeout') {
553
- this.log('Forward: peer', peer.peerId.slice(0, 12), 'timeout, trying next');
554
- }
666
+ if (Date.now() >= deadline)
667
+ break;
668
+ }
669
+ for (const task of inFlight) {
670
+ if (!task.settled)
671
+ this.peerSelector.recordTimeout(task.peerId);
555
672
  }
556
673
  return null;
557
674
  }
675
+ /**
676
+ * Forward a request to other peers (excluding the requester)
677
+ * Called by Peer when it receives a request it can't fulfill locally
678
+ * Uses selector ordering + staged hedged dispatch
679
+ * @param htl - Hops To Live (already decremented by calling peer)
680
+ */
681
+ async forwardRequest(hash, excludePeerId, htl) {
682
+ const triedPeers = new Set();
683
+ const orderedPeers = this.orderedConnectedPeers(excludePeerId);
684
+ return this.queryPeersWithDispatch(hash, orderedPeers, triedPeers, htl);
685
+ }
558
686
  /**
559
687
  * Send data to all peers who have requested this hash
560
688
  * Called when we receive data that peers may be waiting for
@@ -571,10 +699,101 @@ export class WebRTCStore {
571
699
  }
572
700
  return sendCount;
573
701
  }
702
+ pruneSeenSet(seen, ttlMs, cap) {
703
+ const now = Date.now();
704
+ for (const [key, ts] of seen) {
705
+ if (now - ts >= ttlMs) {
706
+ seen.delete(key);
707
+ }
708
+ }
709
+ while (seen.size > cap) {
710
+ const oldest = seen.keys().next().value;
711
+ if (!oldest)
712
+ break;
713
+ seen.delete(oldest);
714
+ }
715
+ }
716
+ markSeenFrameId(frameId) {
717
+ this.pruneSeenSet(this.seenFrameIds, SEEN_FRAME_TTL_MS, SEEN_FRAME_CAP);
718
+ if (this.seenFrameIds.has(frameId))
719
+ return false;
720
+ this.seenFrameIds.set(frameId, Date.now());
721
+ return true;
722
+ }
723
+ markSeenEventId(eventId) {
724
+ this.pruneSeenSet(this.seenEventIds, SEEN_EVENT_TTL_MS, SEEN_EVENT_CAP);
725
+ if (this.seenEventIds.has(eventId))
726
+ return false;
727
+ this.seenEventIds.set(eventId, Date.now());
728
+ return true;
729
+ }
730
+ forwardMeshFrame(frame, excludePeerId) {
731
+ let forwarded = 0;
732
+ for (const { peer } of this.peers.values()) {
733
+ if (!peer.isConnected)
734
+ continue;
735
+ if (excludePeerId && peer.peerId === excludePeerId)
736
+ continue;
737
+ const nextHtl = decrementHTLWithPolicy(frame.htl, MESH_EVENT_POLICY, peer.getHTLConfig());
738
+ if (!shouldForwardHTL(nextHtl))
739
+ continue;
740
+ const outbound = {
741
+ ...frame,
742
+ htl: nextHtl,
743
+ };
744
+ if (peer.sendMeshFrameText(outbound)) {
745
+ forwarded++;
746
+ }
747
+ }
748
+ return forwarded;
749
+ }
750
+ async handleMeshFrame(fromPeerId, frame) {
751
+ const validationError = validateMeshNostrFrame(frame);
752
+ if (validationError) {
753
+ return;
754
+ }
755
+ if (!this.markSeenFrameId(frame.frame_id)) {
756
+ this.meshDroppedDuplicate++;
757
+ return;
758
+ }
759
+ const event = frame.payload.event;
760
+ if (!this.markSeenEventId(event.id)) {
761
+ this.meshDroppedDuplicate++;
762
+ return;
763
+ }
764
+ if (!verifyEvent(event)) {
765
+ return;
766
+ }
767
+ this.meshReceived++;
768
+ await this.handleSignalingEvent(event);
769
+ const forwarded = this.forwardMeshFrame(frame, fromPeerId);
770
+ if (forwarded > 0) {
771
+ this.meshForwarded += forwarded;
772
+ }
773
+ }
774
+ async dispatchSignaling(msg, recipientPubkey) {
775
+ const event = await this.sendSignaling(msg, recipientPubkey);
776
+ if (!event) {
777
+ return;
778
+ }
779
+ const frame = createMeshNostrEventFrame(event, this.myPeerId.toString(), MESH_EVENT_POLICY.maxHtl);
780
+ if (!this.markSeenFrameId(frame.frame_id)) {
781
+ this.meshDroppedDuplicate++;
782
+ return;
783
+ }
784
+ if (!this.markSeenEventId(frame.payload.event.id)) {
785
+ this.meshDroppedDuplicate++;
786
+ return;
787
+ }
788
+ const forwarded = this.forwardMeshFrame(frame);
789
+ if (forwarded > 0) {
790
+ this.meshForwarded += forwarded;
791
+ }
792
+ }
574
793
  async sendSignaling(msg, recipientPubkey) {
575
794
  // Fill in our peer ID
576
795
  if ('peerId' in msg && msg.peerId === '') {
577
- msg.peerId = this.myPeerId.uuid;
796
+ msg.peerId = this.myPeerId.toString();
578
797
  }
579
798
  if (recipientPubkey) {
580
799
  // Directed message (offer, answer, candidate, candidates)
@@ -586,6 +805,7 @@ export class WebRTCStore {
586
805
  };
587
806
  const wrappedEvent = await this.giftWrap(innerEvent, recipientPubkey);
588
807
  await this.pool.publish(this.config.relays, wrappedEvent);
808
+ return wrappedEvent;
589
809
  }
590
810
  else {
591
811
  // Hello message - broadcast for peer discovery (kind 25050 with #l: hello)
@@ -603,6 +823,7 @@ export class WebRTCStore {
603
823
  };
604
824
  const event = await this.signer(eventTemplate);
605
825
  await this.pool.publish(this.config.relays, event);
826
+ return event;
606
827
  }
607
828
  }
608
829
  /**
@@ -622,9 +843,9 @@ export class WebRTCStore {
622
843
  if (followsSatisfied && otherSatisfied) {
623
844
  return;
624
845
  }
625
- this.sendSignaling({
846
+ void this.dispatchSignaling({
626
847
  type: 'hello',
627
- peerId: this.myPeerId.uuid,
848
+ peerId: this.myPeerId.toString(),
628
849
  });
629
850
  }
630
851
  cleanupConnections() {
@@ -636,6 +857,7 @@ export class WebRTCStore {
636
857
  if (state === 'failed' || state === 'closed' || state === 'disconnected' || isStale) {
637
858
  this.log('Cleaning up', state, 'connection', isStale ? '(stale)' : '');
638
859
  peer.close();
860
+ this.peerSelector.removePeer(peerIdStr);
639
861
  this.peers.delete(peerIdStr);
640
862
  this.emit({ type: 'update' });
641
863
  }
@@ -672,6 +894,7 @@ export class WebRTCStore {
672
894
  if (peerInfo.peer.pubkey === pubkey) {
673
895
  this.log('Disconnecting blocked peer:', pubkey.slice(0, 8));
674
896
  peerInfo.peer.close();
897
+ this.peerSelector.removePeer(peerIdStr);
675
898
  this.peers.delete(peerIdStr);
676
899
  this.emit({ type: 'peer-disconnected', peerId: peerIdStr });
677
900
  }
@@ -679,10 +902,10 @@ export class WebRTCStore {
679
902
  this.emit({ type: 'update' });
680
903
  }
681
904
  /**
682
- * Get my peer ID (uuid part only)
905
+ * Get my endpoint ID
683
906
  */
684
907
  getMyPeerId() {
685
- return this.myPeerId.uuid;
908
+ return this.myPeerId.toString();
686
909
  }
687
910
  /**
688
911
  * Check if store is satisfied (has enough connections in all pools)
@@ -737,6 +960,55 @@ export class WebRTCStore {
737
960
  getPoolConfig() {
738
961
  return { ...this.pools };
739
962
  }
963
+ async peerMetadataPointerHash() {
964
+ return sha256(new TextEncoder().encode(PEER_METADATA_POINTER_SLOT_KEY));
965
+ }
966
+ /**
967
+ * Persist selector metadata snapshot to local store.
968
+ * Returns the snapshot hash, or null when local storage is unavailable.
969
+ */
970
+ async persistPeerMetadata() {
971
+ if (!this.config.localStore)
972
+ return null;
973
+ const snapshot = this.peerSelector.exportPeerMetadataSnapshot();
974
+ const bytes = new TextEncoder().encode(JSON.stringify(snapshot));
975
+ const snapshotHash = await sha256(bytes);
976
+ await this.config.localStore.put(snapshotHash, bytes);
977
+ const pointerHash = await this.peerMetadataPointerHash();
978
+ await this.config.localStore.delete(pointerHash);
979
+ await this.config.localStore.put(pointerHash, new TextEncoder().encode(toHex(snapshotHash)));
980
+ return snapshotHash;
981
+ }
982
+ /**
983
+ * Load selector metadata snapshot from local store, if available.
984
+ */
985
+ async loadPeerMetadata() {
986
+ if (!this.config.localStore)
987
+ return false;
988
+ const pointerHash = await this.peerMetadataPointerHash();
989
+ const pointerBytes = await this.config.localStore.get(pointerHash);
990
+ if (!pointerBytes)
991
+ return false;
992
+ const pointerHex = new TextDecoder().decode(pointerBytes).trim();
993
+ if (pointerHex.length !== 64)
994
+ return false;
995
+ const snapshotHash = fromHex(pointerHex);
996
+ if (snapshotHash.length !== 32)
997
+ return false;
998
+ const snapshotBytes = await this.config.localStore.get(snapshotHash);
999
+ if (!snapshotBytes)
1000
+ return false;
1001
+ let snapshot;
1002
+ try {
1003
+ snapshot = JSON.parse(new TextDecoder().decode(snapshotBytes));
1004
+ }
1005
+ catch {
1006
+ return false;
1007
+ }
1008
+ this.peerSelector.importPeerMetadataSnapshot(snapshot);
1009
+ syncSelectorPeers(this.peerSelector, Array.from(this.peers.keys()));
1010
+ return true;
1011
+ }
740
1012
  /**
741
1013
  * Get fallback stores count
742
1014
  */
@@ -762,6 +1034,9 @@ export class WebRTCStore {
762
1034
  bytesSent: 0,
763
1035
  bytesReceived: 0,
764
1036
  bytesForwarded: 0,
1037
+ meshReceived: this.meshReceived,
1038
+ meshForwarded: this.meshForwarded,
1039
+ meshDroppedDuplicate: this.meshDroppedDuplicate,
765
1040
  };
766
1041
  const perPeer = new Map();
767
1042
  for (const [peerIdStr, { peer, pool }] of this.peers) {
@@ -836,45 +1111,11 @@ export class WebRTCStore {
836
1111
  * Internal method to fetch data from peers (separated for deduplication)
837
1112
  */
838
1113
  async fetchFromPeers(hash) {
839
- // Get currently connected peers (prioritize follows pool)
840
1114
  const triedPeers = new Set();
841
- const allPeers = Array.from(this.peers.values())
842
- .filter(({ peer }) => peer.isConnected);
843
- // Sort: follows first, then others
844
- allPeers.sort((a, b) => {
845
- if (a.pool === 'follows' && b.pool !== 'follows')
846
- return -1;
847
- if (a.pool !== 'follows' && b.pool === 'follows')
848
- return 1;
849
- return 0;
850
- });
851
- // Query peers sequentially with delay between attempts
852
- for (let i = 0; i < allPeers.length; i++) {
853
- const { peer } = allPeers[i];
854
- triedPeers.add(peer.peerId);
855
- // Start request to this peer
856
- const requestPromise = peer.request(hash);
857
- // Race between request completing and delay timeout
858
- // If request completes within delay, we're done
859
- // If delay passes first, start next peer while still waiting
860
- const result = await Promise.race([
861
- requestPromise.then(data => ({ type: 'data', data })),
862
- this.delay(this.config.peerQueryDelay).then(() => ({ type: 'timeout' })),
863
- ]);
864
- if (result.type === 'data' && result.data) {
865
- // Got data from this peer
866
- if (this.config.localStore) {
867
- await this.config.localStore.put(hash, result.data);
868
- }
869
- return result.data;
870
- }
871
- // If timeout, continue to next peer but also await the original request
872
- // in case it eventually returns data
873
- if (result.type === 'timeout') {
874
- // Fire-and-forget: if this peer eventually responds, we'll miss it
875
- // but that's fine - we're trying the next peer
876
- this.log('Peer', peer.peerId.slice(0, 12), 'timeout after', this.config.peerQueryDelay, 'ms, trying next');
877
- }
1115
+ const orderedPeers = this.orderedConnectedPeers();
1116
+ const webRtcData = await this.queryPeersWithDispatch(hash, orderedPeers, triedPeers);
1117
+ if (webRtcData) {
1118
+ return webRtcData;
878
1119
  }
879
1120
  // All WebRTC peers failed - try fallback stores in order
880
1121
  if (this.config.fallbackStores.length > 0) {
@@ -942,10 +1183,8 @@ export class WebRTCStore {
942
1183
  const reqs = this.pendingReqs.get(hash);
943
1184
  if (!reqs || reqs.length === 0)
944
1185
  return;
945
- // Get all connected peers
946
- const connectedPeers = Array.from(this.peers.values())
947
- .filter(({ peer }) => peer.isConnected)
948
- .map(({ peer }) => peer);
1186
+ const connectedPeers = this.orderedConnectedPeers();
1187
+ const expectedHashHex = toHex(hash);
949
1188
  for (const peer of connectedPeers) {
950
1189
  const peerIdStr = peer.peerId;
951
1190
  // Find requests that haven't tried this peer yet
@@ -957,8 +1196,16 @@ export class WebRTCStore {
957
1196
  r.triedPeers.add(peerIdStr);
958
1197
  }
959
1198
  this.log('Trying pending req from connected peer:', hash.slice(0, 16));
1199
+ const startedAt = Date.now();
1200
+ this.peerSelector.recordRequest(peerIdStr, 40);
960
1201
  const data = await peer.request(hash);
961
1202
  if (data) {
1203
+ const computedHash = await sha256(data);
1204
+ if (toHex(computedHash) !== expectedHashHex) {
1205
+ this.peerSelector.recordFailure(peerIdStr);
1206
+ continue;
1207
+ }
1208
+ this.peerSelector.recordSuccess(peerIdStr, Math.max(1, Date.now() - startedAt), data.length);
962
1209
  // Store locally
963
1210
  if (this.config.localStore) {
964
1211
  await this.config.localStore.put(hash, data);
@@ -975,6 +1222,7 @@ export class WebRTCStore {
975
1222
  this.log('Resolved pending req:', hash.slice(0, 16));
976
1223
  return;
977
1224
  }
1225
+ this.peerSelector.recordTimeout(peerIdStr);
978
1226
  }
979
1227
  }
980
1228
  /**
@@ -997,7 +1245,10 @@ export class WebRTCStore {
997
1245
  */
998
1246
  async tryPendingReqs(peer) {
999
1247
  const peerIdStr = peer.peerId;
1248
+ const expectedByHash = new Map();
1000
1249
  for (const [hash, reqs] of this.pendingReqs.entries()) {
1250
+ const hashKey = toHex(hash);
1251
+ expectedByHash.set(hashKey, hashKey);
1001
1252
  // Find requests that haven't tried this peer yet
1002
1253
  const untried = reqs.filter(r => !r.triedPeers.has(peerIdStr));
1003
1254
  if (untried.length === 0)
@@ -1006,8 +1257,17 @@ export class WebRTCStore {
1006
1257
  for (const r of untried) {
1007
1258
  r.triedPeers.add(peerIdStr);
1008
1259
  }
1260
+ const startedAt = Date.now();
1261
+ this.peerSelector.recordRequest(peerIdStr, 40);
1009
1262
  const data = await peer.request(hash);
1010
1263
  if (data) {
1264
+ const computedHash = await sha256(data);
1265
+ const expectedHashHex = expectedByHash.get(hashKey);
1266
+ if (toHex(computedHash) !== expectedHashHex) {
1267
+ this.peerSelector.recordFailure(peerIdStr);
1268
+ continue;
1269
+ }
1270
+ this.peerSelector.recordSuccess(peerIdStr, Math.max(1, Date.now() - startedAt), data.length);
1011
1271
  // Store locally
1012
1272
  if (this.config.localStore) {
1013
1273
  await this.config.localStore.put(hash, data);
@@ -1020,6 +1280,9 @@ export class WebRTCStore {
1020
1280
  this.pendingReqs.delete(hash);
1021
1281
  this.log('Resolved pending req:', hash.slice(0, 16));
1022
1282
  }
1283
+ else {
1284
+ this.peerSelector.recordTimeout(peerIdStr);
1285
+ }
1023
1286
  }
1024
1287
  }
1025
1288
  async has(hash) {