@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.
- package/README.md +51 -0
- package/dist/events.d.ts +66 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +426 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/resolver/nostr.d.ts +2 -0
- package/dist/resolver/nostr.d.ts.map +1 -1
- package/dist/resolver/nostr.js +122 -31
- package/dist/resolver/nostr.js.map +1 -1
- package/dist/snapshot.d.ts +30 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +157 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/treeEventSnapshots.d.ts +30 -0
- package/dist/treeEventSnapshots.d.ts.map +1 -0
- package/dist/treeEventSnapshots.js +186 -0
- package/dist/treeEventSnapshots.js.map +1 -0
- package/dist/webrtc/index.d.ts +3 -2
- package/dist/webrtc/index.d.ts.map +1 -1
- package/dist/webrtc/index.js +3 -2
- package/dist/webrtc/index.js.map +1 -1
- package/dist/webrtc/peer.d.ts +12 -1
- package/dist/webrtc/peer.d.ts.map +1 -1
- package/dist/webrtc/peer.js +39 -5
- package/dist/webrtc/peer.js.map +1 -1
- package/dist/webrtc/peerSelector.d.ts +31 -0
- package/dist/webrtc/peerSelector.d.ts.map +1 -0
- package/dist/webrtc/peerSelector.js +348 -0
- package/dist/webrtc/peerSelector.js.map +1 -0
- package/dist/webrtc/protocol.d.ts +15 -4
- package/dist/webrtc/protocol.d.ts.map +1 -1
- package/dist/webrtc/protocol.js +31 -17
- package/dist/webrtc/protocol.js.map +1 -1
- package/dist/webrtc/store.d.ts +31 -4
- package/dist/webrtc/store.d.ts.map +1 -1
- package/dist/webrtc/store.js +362 -99
- package/dist/webrtc/store.js.map +1 -1
- package/dist/webrtc/types.d.ts +66 -4
- package/dist/webrtc/types.d.ts.map +1 -1
- package/dist/webrtc/types.js +71 -9
- package/dist/webrtc/types.js.map +1 -1
- package/package.json +8 -2
package/dist/webrtc/store.js
CHANGED
|
@@ -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,
|
|
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
|
|
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 ??
|
|
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.
|
|
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.
|
|
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
|
-
|
|
349
|
-
|
|
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 (
|
|
413
|
+
if (targetPeerId !== this.myPeerId.toString()) {
|
|
374
414
|
return;
|
|
375
415
|
}
|
|
376
|
-
const peerId = new PeerId(senderPubkey
|
|
416
|
+
const peerId = new PeerId(senderPubkey);
|
|
377
417
|
const peerIdStr = peerId.toString();
|
|
378
|
-
|
|
379
|
-
|
|
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(
|
|
430
|
+
await peerInfo.peer.handleSignaling(normalizedMsg);
|
|
386
431
|
}
|
|
387
432
|
}
|
|
388
433
|
}
|
|
389
|
-
async handleHello(
|
|
390
|
-
const peerId = new PeerId(senderPubkey
|
|
391
|
-
// Skip self
|
|
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.
|
|
513
|
+
myPeerId: this.myPeerId.toString(),
|
|
468
514
|
direction: 'inbound',
|
|
469
515
|
localStore: this.config.localStore,
|
|
470
|
-
sendSignaling: (m) => this.
|
|
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.
|
|
543
|
+
myPeerId: this.myPeerId.toString(),
|
|
495
544
|
direction: 'outbound',
|
|
496
545
|
localStore: this.config.localStore,
|
|
497
|
-
sendSignaling: (m) => this.
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
|
585
|
+
return (rank.get(a.peer.peerId) ?? Number.MAX_SAFE_INTEGER) -
|
|
586
|
+
(rank.get(b.peer.peerId) ?? Number.MAX_SAFE_INTEGER);
|
|
533
587
|
});
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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.
|
|
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.
|
|
846
|
+
void this.dispatchSignaling({
|
|
626
847
|
type: 'hello',
|
|
627
|
-
peerId: this.myPeerId.
|
|
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
|
|
905
|
+
* Get my endpoint ID
|
|
683
906
|
*/
|
|
684
907
|
getMyPeerId() {
|
|
685
|
-
return this.myPeerId.
|
|
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
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
946
|
-
const
|
|
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) {
|