@hashtree/nostr 0.1.14 → 0.2.3

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 (67) hide show
  1. package/README.md +11 -33
  2. package/dist/eventCollection.d.ts +1 -1
  3. package/dist/eventCollection.d.ts.map +1 -1
  4. package/dist/eventCollection.js +2 -7
  5. package/dist/eventCollection.js.map +1 -1
  6. package/dist/eventKeys.d.ts +1 -0
  7. package/dist/eventKeys.d.ts.map +1 -1
  8. package/dist/eventKeys.js +7 -1
  9. package/dist/eventKeys.js.map +1 -1
  10. package/dist/events.d.ts +2 -0
  11. package/dist/events.d.ts.map +1 -1
  12. package/dist/events.js +41 -61
  13. package/dist/events.js.map +1 -1
  14. package/dist/index.d.ts +4 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +10 -6
  17. package/dist/index.js.map +1 -1
  18. package/dist/resolver/nostr.d.ts.map +1 -1
  19. package/dist/resolver/nostr.js +17 -12
  20. package/dist/resolver/nostr.js.map +1 -1
  21. package/dist/snapshot.d.ts +5 -0
  22. package/dist/snapshot.d.ts.map +1 -1
  23. package/dist/snapshot.js +9 -2
  24. package/dist/snapshot.js.map +1 -1
  25. package/dist/treeEventSnapshots.d.ts.map +1 -1
  26. package/dist/treeEventSnapshots.js +6 -11
  27. package/dist/treeEventSnapshots.js.map +1 -1
  28. package/package.json +18 -14
  29. package/src/eventCollection.ts +189 -0
  30. package/src/eventKeys.ts +155 -0
  31. package/src/eventValidation.ts +71 -0
  32. package/src/events.ts +900 -0
  33. package/src/index.ts +122 -0
  34. package/src/replaceablePublish.ts +204 -0
  35. package/src/resolver/index.ts +14 -0
  36. package/src/resolver/nostr.ts +1210 -0
  37. package/src/snapshot.ts +213 -0
  38. package/src/treeEventSnapshotPermalinks.ts +150 -0
  39. package/src/treeEventSnapshots.ts +280 -0
  40. package/dist/webrtc/index.d.ts +0 -6
  41. package/dist/webrtc/index.d.ts.map +0 -1
  42. package/dist/webrtc/index.js +0 -7
  43. package/dist/webrtc/index.js.map +0 -1
  44. package/dist/webrtc/lruCache.d.ts +0 -20
  45. package/dist/webrtc/lruCache.d.ts.map +0 -1
  46. package/dist/webrtc/lruCache.js +0 -59
  47. package/dist/webrtc/lruCache.js.map +0 -1
  48. package/dist/webrtc/peer.d.ts +0 -133
  49. package/dist/webrtc/peer.d.ts.map +0 -1
  50. package/dist/webrtc/peer.js +0 -617
  51. package/dist/webrtc/peer.js.map +0 -1
  52. package/dist/webrtc/peerSelector.d.ts +0 -2
  53. package/dist/webrtc/peerSelector.d.ts.map +0 -1
  54. package/dist/webrtc/peerSelector.js +0 -2
  55. package/dist/webrtc/peerSelector.js.map +0 -1
  56. package/dist/webrtc/protocol.d.ts +0 -3
  57. package/dist/webrtc/protocol.d.ts.map +0 -1
  58. package/dist/webrtc/protocol.js +0 -2
  59. package/dist/webrtc/protocol.js.map +0 -1
  60. package/dist/webrtc/store.d.ts +0 -217
  61. package/dist/webrtc/store.d.ts.map +0 -1
  62. package/dist/webrtc/store.js +0 -1320
  63. package/dist/webrtc/store.js.map +0 -1
  64. package/dist/webrtc/types.d.ts +0 -101
  65. package/dist/webrtc/types.d.ts.map +0 -1
  66. package/dist/webrtc/types.js +0 -5
  67. package/dist/webrtc/types.js.map +0 -1
@@ -1,1320 +0,0 @@
1
- /**
2
- * WebRTC-based distributed store for hashtree
3
- *
4
- * Implements the Store interface, fetching data from P2P network.
5
- * Uses Nostr relays for WebRTC signaling with perfect negotiation (both peers can initiate).
6
- *
7
- * Signaling protocol (all use ephemeral kind 25050):
8
- * - Hello messages: #l: "hello" tag, broadcast for peer discovery (unencrypted)
9
- * - Directed signaling (offer, answer, candidate, candidates): #p tag with
10
- * recipient pubkey, NIP-17 style gift wrap for privacy
11
- *
12
- * Pool-based peer management:
13
- * - 'follows' pool: Users in your social graph (followed or followers)
14
- * - 'other' pool: Everyone else (randos)
15
- * Each pool has its own connection limits.
16
- */
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';
22
- import { Peer } from './peer.js';
23
- export const DEFAULT_RELAYS = [
24
- 'wss://relay.damus.io',
25
- 'wss://relay.primal.net',
26
- 'wss://relay.nostr.band',
27
- 'wss://temp.iris.to',
28
- 'wss://relay.snort.social',
29
- ];
30
- // All WebRTC signaling uses ephemeral kind 25050
31
- // Hello messages use #l tag for broadcast discovery
32
- // Directed messages use #p tag with gift wrap
33
- const SIGNALING_KIND = 25050;
34
- const HELLO_TAG = 'hello';
35
- const HASH_GET_TAG = 'hashGet';
36
- const SEEN_FRAME_CAP = 4096;
37
- const SEEN_FRAME_TTL_MS = 120_000;
38
- const SEEN_EVENT_CAP = 8192;
39
- const SEEN_EVENT_TTL_MS = 600_000;
40
- const PEER_METADATA_POINTER_SLOT_KEY = 'hashtree-webrtc/peer-metadata/latest/v1';
41
- const DEFAULT_REQUEST_DISPATCH = {
42
- initialFanout: 2,
43
- hedgeFanout: 1,
44
- maxFanout: 8,
45
- hedgeIntervalMs: 120,
46
- };
47
- function decodeHashGetTag(value) {
48
- if (value === undefined)
49
- return true;
50
- return !['0', 'false', 'FALSE', 'no', 'NO'].includes(value);
51
- }
52
- export class WebRTCStore {
53
- config;
54
- routing;
55
- peerSelector;
56
- pools;
57
- peerClassifier;
58
- getFollowedPubkeys;
59
- isPeerBlocked;
60
- signer;
61
- encrypt;
62
- decrypt;
63
- giftWrap;
64
- giftUnwrap;
65
- myPeerId;
66
- pool;
67
- subscriptions = [];
68
- helloSubscription = null;
69
- peers = new Map();
70
- // Track pubkeys we're currently connecting to in 'other' pool (prevents race conditions)
71
- pendingOtherPubkeys = new Set();
72
- helloInterval = null;
73
- cleanupInterval = null;
74
- eventHandlers = new Set();
75
- running = false;
76
- pendingReqs = new Map();
77
- // Deduplicate concurrent get() calls for the same hash
78
- pendingGets = new Map();
79
- // Store-level stats (not per-peer)
80
- blossomFetches = 0;
81
- // Track current hello subscription authors for change detection
82
- currentHelloAuthors = null;
83
- // Relayless mesh dedupe
84
- seenFrameIds = new Map();
85
- seenEventIds = new Map();
86
- // Relayless mesh stats
87
- meshReceived = 0;
88
- meshForwarded = 0;
89
- meshDroppedDuplicate = 0;
90
- constructor(config) {
91
- this.signer = config.signer;
92
- this.encrypt = config.encrypt;
93
- this.decrypt = config.decrypt;
94
- this.giftWrap = config.giftWrap;
95
- this.giftUnwrap = config.giftUnwrap;
96
- this.myPeerId = new PeerId(config.pubkey);
97
- // Default classifier: everyone is 'other' unless classifier provided
98
- this.peerClassifier = config.peerClassifier ?? (() => 'other');
99
- // Function to get followed pubkeys for subscription filtering
100
- this.getFollowedPubkeys = config.getFollowedPubkeys ?? null;
101
- // Function to check if a peer is blocked
102
- this.isPeerBlocked = config.isPeerBlocked ?? null;
103
- // Use pool config if provided, otherwise fall back to legacy config or defaults
104
- if (config.pools) {
105
- this.pools = config.pools;
106
- }
107
- else {
108
- // Legacy mode: single pool with old config values
109
- const maxConn = config.maxConnections ?? 6;
110
- const satConn = config.satisfiedConnections ?? 3;
111
- this.pools = {
112
- follows: { maxConnections: 0, satisfiedConnections: 0 }, // No follows pool in legacy
113
- other: { maxConnections: maxConn, satisfiedConnections: satConn },
114
- };
115
- }
116
- this.config = {
117
- helloInterval: config.helloInterval ?? 3000,
118
- messageTimeout: config.messageTimeout ?? 60000, // 60 seconds for relay propagation
119
- requestTimeout: config.requestTimeout ?? 500,
120
- peerQueryDelay: config.peerQueryDelay ?? 500,
121
- relays: config.relays ?? DEFAULT_RELAYS,
122
- localStore: config.localStore ?? null,
123
- fallbackStores: config.fallbackStores ?? [],
124
- debug: config.debug ?? false,
125
- };
126
- const dispatch = config.requestDispatch ?? (config.peerQueryDelay !== undefined
127
- ? {
128
- initialFanout: 1,
129
- hedgeFanout: 1,
130
- maxFanout: Number.MAX_SAFE_INTEGER,
131
- hedgeIntervalMs: Math.max(0, config.peerQueryDelay),
132
- }
133
- : DEFAULT_REQUEST_DISPATCH);
134
- this.routing = {
135
- // Retrieval should prefer the peer with the best reply odds.
136
- selectionStrategy: config.requestSelectionStrategy ?? 'weighted',
137
- fairnessEnabled: config.requestFairnessEnabled ?? true,
138
- dispatch,
139
- };
140
- this.peerSelector = PeerSelector.withStrategy(this.routing.selectionStrategy);
141
- this.peerSelector.setFairness(this.routing.fairnessEnabled);
142
- this.pool = new SimplePool();
143
- }
144
- log(...args) {
145
- if (this.config.debug) {
146
- console.log('[WebRTCStore]', ...args);
147
- }
148
- }
149
- /**
150
- * Get pool counts
151
- */
152
- getPoolCounts() {
153
- const counts = {
154
- follows: { connected: 0, total: 0 },
155
- other: { connected: 0, total: 0 },
156
- };
157
- for (const { peer, pool } of this.peers.values()) {
158
- counts[pool].total++;
159
- if (peer.isConnected) {
160
- counts[pool].connected++;
161
- }
162
- }
163
- return counts;
164
- }
165
- /**
166
- * Check if we can accept a peer in a given pool
167
- */
168
- canAcceptPeer(pool) {
169
- const counts = this.getPoolCounts();
170
- return counts[pool].total < this.pools[pool].maxConnections;
171
- }
172
- /**
173
- * Check if a pool is satisfied
174
- */
175
- isPoolSatisfied(pool) {
176
- const counts = this.getPoolCounts();
177
- return counts[pool].connected >= this.pools[pool].satisfiedConnections;
178
- }
179
- /**
180
- * Check if we already have a connection from a pubkey in the 'other' pool.
181
- * In the 'other' pool, we only allow 1 instance per pubkey.
182
- * Also checks pendingOtherPubkeys to prevent race conditions.
183
- */
184
- hasOtherPoolPubkey(pubkey) {
185
- if (this.pendingOtherPubkeys.has(pubkey)) {
186
- return true;
187
- }
188
- for (const { peer, pool } of this.peers.values()) {
189
- if (pool === 'other' && peer.pubkey === pubkey) {
190
- return true;
191
- }
192
- }
193
- return false;
194
- }
195
- /**
196
- * Start the WebRTC store - connect to relays and begin peer discovery
197
- */
198
- start() {
199
- if (this.running)
200
- return;
201
- this.running = true;
202
- this.log('Starting with peerId:', this.myPeerId.short());
203
- this.log('Pool config:', this.pools);
204
- this.log('Relays:', this.config.relays);
205
- // Subscribe to signaling messages
206
- this.startSubscription();
207
- // Send hello messages when not satisfied
208
- this.helloInterval = setInterval(() => {
209
- this.maybeSendHello();
210
- }, this.config.helloInterval);
211
- // Send initial hello
212
- this.maybeSendHello();
213
- // Cleanup stale connections
214
- this.cleanupInterval = setInterval(() => {
215
- this.cleanupConnections();
216
- }, 5000);
217
- }
218
- /**
219
- * Stop the WebRTC store
220
- */
221
- stop() {
222
- if (!this.running)
223
- return;
224
- this.running = false;
225
- this.log('Stopping');
226
- if (this.helloInterval) {
227
- clearInterval(this.helloInterval);
228
- this.helloInterval = null;
229
- }
230
- if (this.cleanupInterval) {
231
- clearInterval(this.cleanupInterval);
232
- this.cleanupInterval = null;
233
- }
234
- for (const sub of this.subscriptions) {
235
- sub.close();
236
- }
237
- this.subscriptions = [];
238
- // Close all peer connections
239
- for (const [peerId, { peer }] of this.peers.entries()) {
240
- this.peerSelector.removePeer(peerId);
241
- peer.close();
242
- }
243
- this.peers.clear();
244
- this.pendingOtherPubkeys.clear();
245
- this.seenFrameIds.clear();
246
- this.seenEventIds.clear();
247
- }
248
- /**
249
- * Set relays and reconnect. Useful for runtime relay configuration changes.
250
- * Closes existing subscriptions and starts new ones with the updated relays.
251
- */
252
- setRelays(relays) {
253
- this.log('setRelays:', relays);
254
- this.config.relays = relays;
255
- // If running, restart subscriptions with new relays
256
- if (this.running) {
257
- // Close existing subscriptions
258
- for (const sub of this.subscriptions) {
259
- sub.close();
260
- }
261
- this.subscriptions = [];
262
- // Clear existing peers (they were discovered via old relays)
263
- for (const [peerId, { peer }] of this.peers.entries()) {
264
- this.peerSelector.removePeer(peerId);
265
- peer.close();
266
- }
267
- this.peers.clear();
268
- // Start new subscriptions with updated relays
269
- this.startSubscription();
270
- this.emit({ type: 'update' });
271
- }
272
- }
273
- /**
274
- * Add event listener
275
- */
276
- on(handler) {
277
- this.eventHandlers.add(handler);
278
- return () => this.eventHandlers.delete(handler);
279
- }
280
- emit(event) {
281
- for (const handler of this.eventHandlers) {
282
- handler(event);
283
- }
284
- }
285
- startSubscription() {
286
- const since = Math.floor((Date.now() - this.config.messageTimeout) / 1000);
287
- const subHandler = {
288
- onevent: (event) => {
289
- this.handleSignalingEvent(event);
290
- },
291
- oneose: () => { },
292
- };
293
- // 1. Subscribe to hello messages based on pool configuration
294
- this.setupHelloSubscription(since, subHandler);
295
- // 2. Subscribe to directed signaling (kind 25050 with #p tag) for offers/answers/candidates
296
- // Always subscribe to directed messages (needed to receive offers/answers)
297
- this.subscriptions.push(this.pool.subscribe(this.config.relays, {
298
- kinds: [SIGNALING_KIND],
299
- '#p': [this.myPeerId.pubkey],
300
- since,
301
- }, subHandler));
302
- }
303
- /**
304
- * Setup hello subscription based on pool configuration
305
- * - If both pools are 0: don't subscribe to hellos
306
- * - If other pool is disabled but follows is enabled: subscribe to followed pubkeys only
307
- * - If other pool is enabled: subscribe to all hellos
308
- */
309
- setupHelloSubscription(since, subHandler) {
310
- // Close existing hello subscription if any
311
- if (this.helloSubscription) {
312
- this.helloSubscription.close();
313
- this.helloSubscription = null;
314
- }
315
- const followsMax = this.pools.follows.maxConnections;
316
- const otherMax = this.pools.other.maxConnections;
317
- // If both pools are disabled, don't subscribe to hellos at all
318
- if (followsMax === 0 && otherMax === 0) {
319
- this.log('Both pools disabled, not subscribing to hellos');
320
- this.currentHelloAuthors = [];
321
- return;
322
- }
323
- // If other pool is disabled but follows pool is enabled, only subscribe to followed users
324
- if (otherMax === 0 && followsMax > 0) {
325
- const followedPubkeys = this.getFollowedPubkeys?.() ?? [];
326
- if (followedPubkeys.length === 0) {
327
- this.log('Follows pool enabled but no followed users, not subscribing to hellos');
328
- this.currentHelloAuthors = [];
329
- return;
330
- }
331
- this.log('Other pool disabled, subscribing to hellos from', followedPubkeys.length, 'followed users');
332
- this.currentHelloAuthors = [...followedPubkeys];
333
- this.helloSubscription = this.pool.subscribe(this.config.relays, {
334
- kinds: [SIGNALING_KIND],
335
- '#l': [HELLO_TAG],
336
- authors: followedPubkeys,
337
- since,
338
- }, subHandler);
339
- return;
340
- }
341
- // Otherwise subscribe to all hellos
342
- this.log('Subscribing to all hellos');
343
- this.currentHelloAuthors = null; // null means all authors
344
- this.helloSubscription = this.pool.subscribe(this.config.relays, {
345
- kinds: [SIGNALING_KIND],
346
- '#l': [HELLO_TAG],
347
- since,
348
- }, subHandler);
349
- }
350
- /**
351
- * Update hello subscription when follows list changes
352
- * Call this after social graph updates
353
- */
354
- updateHelloSubscription() {
355
- if (!this.running)
356
- return;
357
- const followsMax = this.pools.follows.maxConnections;
358
- const otherMax = this.pools.other.maxConnections;
359
- // Only need to update if we're in follows-only mode
360
- if (otherMax === 0 && followsMax > 0) {
361
- const followedPubkeys = this.getFollowedPubkeys?.() ?? [];
362
- const currentAuthors = this.currentHelloAuthors ?? [];
363
- // Check if follows list changed
364
- const changed = followedPubkeys.length !== currentAuthors.length ||
365
- !followedPubkeys.every(pk => currentAuthors.includes(pk));
366
- if (changed) {
367
- const since = Math.floor((Date.now() - this.config.messageTimeout) / 1000);
368
- const subHandler = {
369
- onevent: (event) => {
370
- this.handleSignalingEvent(event);
371
- },
372
- oneose: () => { },
373
- };
374
- this.setupHelloSubscription(since, subHandler);
375
- }
376
- }
377
- }
378
- async handleSignalingEvent(event) {
379
- // Filter out old events (created more than messageTimeout ago)
380
- const eventAge = Date.now() / 1000 - (event.created_at ?? 0);
381
- if (eventAge > this.config.messageTimeout / 1000) {
382
- return;
383
- }
384
- // Check expiration
385
- const expirationTag = event.tags.find(t => t[0] === 'expiration');
386
- if (expirationTag) {
387
- const expiration = parseInt(expirationTag[1], 10);
388
- if (expiration < Date.now() / 1000) {
389
- return;
390
- }
391
- }
392
- // Check if this is a hello message (#l: hello tag)
393
- const lTag = event.tags.find(t => t[0] === 'l')?.[1];
394
- if (lTag === HELLO_TAG) {
395
- if (event.tags.some((tag) => tag[0] === 'peerId' && typeof tag[1] === 'string')) {
396
- const hashGet = decodeHashGetTag(event.tags.find((tag) => tag[0] === HASH_GET_TAG)?.[1]);
397
- await this.handleHello(event.pubkey, hashGet);
398
- }
399
- return;
400
- }
401
- // Check if this is a directed message (#p tag pointing to us)
402
- const pTag = event.tags.find(t => t[0] === 'p')?.[1];
403
- if (pTag === this.myPeerId.pubkey) {
404
- // Gift-wrapped signaling message - try to unwrap
405
- try {
406
- const inner = await this.giftUnwrap(event);
407
- if (!inner) {
408
- return; // Can't decrypt - not for us
409
- }
410
- const msg = JSON.parse(inner.content);
411
- await this.handleSignalingMessage(msg, inner.pubkey);
412
- }
413
- catch {
414
- // Not for us or invalid - ignore silently
415
- }
416
- }
417
- }
418
- async handleSignalingMessage(msg, senderPubkey) {
419
- const targetPeerId = PeerId.fromString(msg.targetPeerId).toString();
420
- // Directed message - check if it's for us
421
- if (targetPeerId !== this.myPeerId.toString()) {
422
- return;
423
- }
424
- const peerId = new PeerId(senderPubkey);
425
- const peerIdStr = peerId.toString();
426
- const normalizedMsg = {
427
- ...msg,
428
- peerId: peerIdStr,
429
- targetPeerId,
430
- };
431
- if (normalizedMsg.type === 'offer') {
432
- await this.handleOffer(peerId, normalizedMsg);
433
- }
434
- else {
435
- // answer or candidate
436
- const peerInfo = this.peers.get(peerIdStr);
437
- if (peerInfo) {
438
- await peerInfo.peer.handleSignaling(normalizedMsg);
439
- }
440
- }
441
- }
442
- async handleHello(senderPubkey, hashGet) {
443
- const peerId = new PeerId(senderPubkey);
444
- // Skip self
445
- if (peerId.toString() === this.myPeerId.toString()) {
446
- return;
447
- }
448
- // Skip blocked peers
449
- if (this.isPeerBlocked?.(senderPubkey)) {
450
- this.log('Ignoring hello from blocked peer:', senderPubkey.slice(0, 8));
451
- return;
452
- }
453
- // Check if we already have this peer
454
- const existing = this.peers.get(peerId.toString());
455
- if (existing) {
456
- existing.hashGet = hashGet;
457
- return;
458
- }
459
- // Classify the peer
460
- let pool;
461
- try {
462
- pool = this.peerClassifier(senderPubkey);
463
- }
464
- catch (e) {
465
- this.log('Error classifying peer:', e);
466
- pool = 'other';
467
- }
468
- // Check if we can accept this peer in their pool
469
- if (!this.canAcceptPeer(pool)) {
470
- return;
471
- }
472
- // In 'other' pool, only allow 1 instance per pubkey
473
- if (pool === 'other' && this.hasOtherPoolPubkey(senderPubkey)) {
474
- return;
475
- }
476
- // Perfect negotiation: both peers initiate
477
- // Collision is handled by Peer class (polite peer rolls back)
478
- // Mark as pending before async operation to prevent race conditions
479
- if (pool === 'other') {
480
- this.pendingOtherPubkeys.add(senderPubkey);
481
- }
482
- try {
483
- await this.connectToPeer(peerId, pool, hashGet);
484
- }
485
- finally {
486
- this.pendingOtherPubkeys.delete(senderPubkey);
487
- }
488
- }
489
- async handleOffer(peerId, msg) {
490
- // Skip self (exact same peerId)
491
- if (peerId.toString() === this.myPeerId.toString()) {
492
- return;
493
- }
494
- // Skip blocked peers
495
- if (this.isPeerBlocked?.(peerId.pubkey)) {
496
- this.log('Ignoring offer from blocked peer:', peerId.pubkey.slice(0, 8));
497
- return;
498
- }
499
- const peerIdStr = peerId.toString();
500
- // Classify the peer
501
- const pool = this.peerClassifier(peerId.pubkey);
502
- // Check if we can accept (unless we already have this peer)
503
- if (!this.peers.has(peerIdStr) && !this.canAcceptPeer(pool)) {
504
- return;
505
- }
506
- // In 'other' pool, only allow 1 instance per pubkey (unless we already have this exact peer)
507
- if (!this.peers.has(peerIdStr) && pool === 'other' && this.hasOtherPoolPubkey(peerId.pubkey)) {
508
- return;
509
- }
510
- // Mark as pending before any async gaps to prevent race conditions
511
- if (pool === 'other' && !this.peers.has(peerIdStr)) {
512
- this.pendingOtherPubkeys.add(peerId.pubkey);
513
- }
514
- // Clean up existing connection if any
515
- const existing = this.peers.get(peerIdStr);
516
- const hashGet = existing?.hashGet ?? true;
517
- if (existing) {
518
- this.peerSelector.removePeer(peerIdStr);
519
- existing.peer.close();
520
- this.peers.delete(peerIdStr);
521
- }
522
- const peer = new Peer({
523
- peerId,
524
- myPeerId: this.myPeerId.toString(),
525
- direction: 'inbound',
526
- localStore: this.config.localStore,
527
- sendSignaling: (m) => this.dispatchSignaling(m, peerId.pubkey),
528
- onClose: () => this.handlePeerClose(peerIdStr),
529
- onConnected: () => {
530
- this.emit({ type: 'peer-connected', peerId: peerIdStr });
531
- this.emit({ type: 'update' });
532
- this.maybeSendHello();
533
- this.tryPendingReqs(peer);
534
- },
535
- onForwardRequest: (hash, exclude, htl) => this.forwardRequest(hash, exclude, htl),
536
- onMeshFrame: (fromPeerId, frame) => this.handleMeshFrame(fromPeerId, frame),
537
- requestTimeout: this.config.requestTimeout,
538
- debug: this.config.debug,
539
- });
540
- this.peers.set(peerIdStr, { peer, pool, hashGet });
541
- this.peerSelector.addPeer(peerIdStr);
542
- // Clear pending now that peer is in the map
543
- this.pendingOtherPubkeys.delete(peerId.pubkey);
544
- await peer.handleSignaling(msg);
545
- }
546
- async connectToPeer(peerId, pool, hashGet = true) {
547
- const peerIdStr = peerId.toString();
548
- if (this.peers.has(peerIdStr)) {
549
- return;
550
- }
551
- this.log('Initiating connection to', peerId.short(), 'pool:', pool);
552
- const peer = new Peer({
553
- peerId,
554
- myPeerId: this.myPeerId.toString(),
555
- direction: 'outbound',
556
- localStore: this.config.localStore,
557
- sendSignaling: (m) => this.dispatchSignaling(m, peerId.pubkey),
558
- onClose: () => this.handlePeerClose(peerIdStr),
559
- onConnected: () => {
560
- this.emit({ type: 'peer-connected', peerId: peerIdStr });
561
- this.emit({ type: 'update' });
562
- this.maybeSendHello();
563
- this.tryPendingReqs(peer);
564
- },
565
- onForwardRequest: (hash, exclude, htl) => this.forwardRequest(hash, exclude, htl),
566
- onMeshFrame: (fromPeerId, frame) => this.handleMeshFrame(fromPeerId, frame),
567
- requestTimeout: this.config.requestTimeout,
568
- debug: this.config.debug,
569
- });
570
- this.peers.set(peerIdStr, { peer, pool, hashGet });
571
- this.peerSelector.addPeer(peerIdStr);
572
- await peer.connect();
573
- }
574
- handlePeerClose(peerIdStr) {
575
- this.peerSelector.removePeer(peerIdStr);
576
- this.peers.delete(peerIdStr);
577
- this.emit({ type: 'peer-disconnected', peerId: peerIdStr });
578
- this.emit({ type: 'update' });
579
- }
580
- orderedConnectedPeers(excludePeerId) {
581
- const connectedAll = Array.from(this.peers.values())
582
- .filter(({ peer, hashGet }) => peer.isConnected && hashGet);
583
- if (connectedAll.length === 0)
584
- return [];
585
- const currentPeerIds = connectedAll.map(({ peer }) => peer.peerId);
586
- syncSelectorPeers(this.peerSelector, currentPeerIds);
587
- const connected = connectedAll
588
- .filter(({ peer }) => !excludePeerId || peer.peerId !== excludePeerId);
589
- const order = this.peerSelector.selectPeers();
590
- const rank = new Map(order.map((peerId, idx) => [peerId, idx]));
591
- connected.sort((a, b) => {
592
- if (a.pool === 'follows' && b.pool !== 'follows')
593
- return -1;
594
- if (a.pool !== 'follows' && b.pool === 'follows')
595
- return 1;
596
- return (rank.get(a.peer.peerId) ?? Number.MAX_SAFE_INTEGER) -
597
- (rank.get(b.peer.peerId) ?? Number.MAX_SAFE_INTEGER);
598
- });
599
- return connected.map(({ peer }) => peer);
600
- }
601
- createInFlightPeerRequest(peer, hash, htl) {
602
- const startedAt = Date.now();
603
- this.peerSelector.recordRequest(peer.peerId, 40);
604
- const promise = peer.request(hash, htl).then((data) => ({
605
- peerId: peer.peerId,
606
- data,
607
- elapsedMs: Math.max(1, Date.now() - startedAt),
608
- })).catch(() => ({
609
- peerId: peer.peerId,
610
- data: null,
611
- elapsedMs: Math.max(1, Date.now() - startedAt),
612
- }));
613
- return { peerId: peer.peerId, settled: false, promise };
614
- }
615
- async waitForNextPeerResult(inFlight, waitMs) {
616
- const active = inFlight.filter((task) => !task.settled);
617
- if (active.length === 0 || waitMs <= 0)
618
- return null;
619
- const timeout = this.delay(waitMs).then(() => null);
620
- const outcome = await Promise.race([
621
- timeout,
622
- ...active.map((task) => task.promise.then((result) => ({
623
- task,
624
- data: result.data,
625
- elapsedMs: result.elapsedMs,
626
- }))),
627
- ]);
628
- if (!outcome)
629
- return null;
630
- outcome.task.settled = true;
631
- return outcome;
632
- }
633
- async queryPeersWithDispatch(hash, orderedPeers, triedPeers, htl) {
634
- if (orderedPeers.length === 0)
635
- return null;
636
- const dispatch = normalizeDispatchConfig(this.routing.dispatch, orderedPeers.length);
637
- const wavePlan = buildHedgedWavePlan(orderedPeers.length, dispatch);
638
- if (wavePlan.length === 0)
639
- return null;
640
- const deadline = Date.now() + this.config.requestTimeout;
641
- const inFlight = [];
642
- let nextPeerIdx = 0;
643
- const expectedHashHex = toHex(hash);
644
- for (let waveIdx = 0; waveIdx < wavePlan.length; waveIdx++) {
645
- const waveSize = wavePlan[waveIdx];
646
- const from = nextPeerIdx;
647
- const to = Math.min(from + waveSize, orderedPeers.length);
648
- nextPeerIdx = to;
649
- for (const peer of orderedPeers.slice(from, to)) {
650
- triedPeers.add(peer.peerId);
651
- inFlight.push(this.createInFlightPeerRequest(peer, hash, htl));
652
- }
653
- const isLastWave = waveIdx === wavePlan.length - 1 || nextPeerIdx >= orderedPeers.length;
654
- const windowEnd = isLastWave
655
- ? deadline
656
- : Math.min(deadline, Date.now() + dispatch.hedgeIntervalMs);
657
- while (Date.now() < windowEnd) {
658
- const remaining = windowEnd - Date.now();
659
- const result = await this.waitForNextPeerResult(inFlight, remaining);
660
- if (!result)
661
- break;
662
- if (!result.data) {
663
- this.peerSelector.recordTimeout(result.task.peerId);
664
- continue;
665
- }
666
- const computedHash = await sha256(result.data);
667
- if (toHex(computedHash) !== expectedHashHex) {
668
- this.peerSelector.recordFailure(result.task.peerId);
669
- continue;
670
- }
671
- this.peerSelector.recordSuccess(result.task.peerId, result.elapsedMs, result.data.length);
672
- if (this.config.localStore) {
673
- await this.config.localStore.put(hash, result.data);
674
- }
675
- return result.data;
676
- }
677
- if (Date.now() >= deadline)
678
- break;
679
- }
680
- for (const task of inFlight) {
681
- if (!task.settled)
682
- this.peerSelector.recordTimeout(task.peerId);
683
- }
684
- return null;
685
- }
686
- /**
687
- * Forward a request to other peers (excluding the requester)
688
- * Called by Peer when it receives a request it can't fulfill locally
689
- * Uses selector ordering + staged hedged dispatch
690
- * @param htl - Hops To Live (already decremented by calling peer)
691
- */
692
- async forwardRequest(hash, excludePeerId, htl) {
693
- const triedPeers = new Set();
694
- const orderedPeers = this.orderedConnectedPeers(excludePeerId);
695
- return this.queryPeersWithDispatch(hash, orderedPeers, triedPeers, htl);
696
- }
697
- /**
698
- * Send data to all peers who have requested this hash
699
- * Called when we receive data that peers may be waiting for
700
- */
701
- sendToInterestedPeers(hash, data) {
702
- let sendCount = 0;
703
- for (const { peer } of this.peers.values()) {
704
- if (peer.isConnected && peer.sendData(hash, data)) {
705
- sendCount++;
706
- }
707
- }
708
- if (sendCount > 0) {
709
- this.log('Sent data to', sendCount, 'interested peers for hash:', toHex(hash).slice(0, 16));
710
- }
711
- return sendCount;
712
- }
713
- pruneSeenSet(seen, ttlMs, cap) {
714
- const now = Date.now();
715
- for (const [key, ts] of seen) {
716
- if (now - ts >= ttlMs) {
717
- seen.delete(key);
718
- }
719
- }
720
- while (seen.size > cap) {
721
- const oldest = seen.keys().next().value;
722
- if (!oldest)
723
- break;
724
- seen.delete(oldest);
725
- }
726
- }
727
- markSeenFrameId(frameId) {
728
- this.pruneSeenSet(this.seenFrameIds, SEEN_FRAME_TTL_MS, SEEN_FRAME_CAP);
729
- if (this.seenFrameIds.has(frameId))
730
- return false;
731
- this.seenFrameIds.set(frameId, Date.now());
732
- return true;
733
- }
734
- markSeenEventId(eventId) {
735
- this.pruneSeenSet(this.seenEventIds, SEEN_EVENT_TTL_MS, SEEN_EVENT_CAP);
736
- if (this.seenEventIds.has(eventId))
737
- return false;
738
- this.seenEventIds.set(eventId, Date.now());
739
- return true;
740
- }
741
- forwardMeshFrame(frame, excludePeerId) {
742
- let forwarded = 0;
743
- for (const { peer } of this.peers.values()) {
744
- if (!peer.isConnected)
745
- continue;
746
- if (excludePeerId && peer.peerId === excludePeerId)
747
- continue;
748
- const nextHtl = decrementHTLWithPolicy(frame.htl, MESH_EVENT_POLICY, peer.getHTLConfig());
749
- if (!shouldForwardHTL(nextHtl))
750
- continue;
751
- const outbound = {
752
- ...frame,
753
- htl: nextHtl,
754
- };
755
- if (peer.sendMeshFrameText(outbound)) {
756
- forwarded++;
757
- }
758
- }
759
- return forwarded;
760
- }
761
- async handleMeshFrame(fromPeerId, frame) {
762
- const validationError = validateMeshNostrFrame(frame);
763
- if (validationError) {
764
- return;
765
- }
766
- if (!this.markSeenFrameId(frame.frame_id)) {
767
- this.meshDroppedDuplicate++;
768
- return;
769
- }
770
- const event = frame.payload.event;
771
- if (!this.markSeenEventId(event.id)) {
772
- this.meshDroppedDuplicate++;
773
- return;
774
- }
775
- if (!verifyEvent(event)) {
776
- return;
777
- }
778
- this.meshReceived++;
779
- await this.handleSignalingEvent(event);
780
- const forwarded = this.forwardMeshFrame(frame, fromPeerId);
781
- if (forwarded > 0) {
782
- this.meshForwarded += forwarded;
783
- }
784
- }
785
- async dispatchSignaling(msg, recipientPubkey) {
786
- const event = await this.sendSignaling(msg, recipientPubkey);
787
- if (!event) {
788
- return;
789
- }
790
- const frame = createMeshNostrEventFrame(event, this.myPeerId.toString(), MESH_EVENT_POLICY.maxHtl);
791
- if (!this.markSeenFrameId(frame.frame_id)) {
792
- this.meshDroppedDuplicate++;
793
- return;
794
- }
795
- if (!this.markSeenEventId(frame.payload.event.id)) {
796
- this.meshDroppedDuplicate++;
797
- return;
798
- }
799
- const forwarded = this.forwardMeshFrame(frame);
800
- if (forwarded > 0) {
801
- this.meshForwarded += forwarded;
802
- }
803
- }
804
- async sendSignaling(msg, recipientPubkey) {
805
- // Fill in our peer ID
806
- if ('peerId' in msg && msg.peerId === '') {
807
- msg.peerId = this.myPeerId.toString();
808
- }
809
- if (recipientPubkey) {
810
- // Directed message (offer, answer, candidate, candidates)
811
- // Use NIP-17 style gift wrap with kind 25050
812
- const innerEvent = {
813
- kind: SIGNALING_KIND,
814
- content: JSON.stringify(msg),
815
- tags: [],
816
- };
817
- const wrappedEvent = await this.giftWrap(innerEvent, recipientPubkey);
818
- await this.pool.publish(this.config.relays, wrappedEvent);
819
- return wrappedEvent;
820
- }
821
- else {
822
- // Hello message - broadcast for peer discovery (kind 25050 with #l: hello)
823
- const expiration = Math.floor((Date.now() + 5 * 60 * 1000) / 1000); // 5 minutes
824
- const tags = [
825
- ['l', HELLO_TAG],
826
- ['peerId', msg.peerId],
827
- [HASH_GET_TAG, msg.type === 'hello' && msg.hashGet === false ? '0' : '1'],
828
- ['expiration', expiration.toString()],
829
- ];
830
- const eventTemplate = {
831
- kind: SIGNALING_KIND,
832
- created_at: Math.floor(Date.now() / 1000),
833
- tags,
834
- content: '',
835
- };
836
- const event = await this.signer(eventTemplate);
837
- await this.pool.publish(this.config.relays, event);
838
- return event;
839
- }
840
- }
841
- /**
842
- * Force send a hello message (useful for testing after pool config changes)
843
- */
844
- sendHello() {
845
- if (!this.running)
846
- return;
847
- this.maybeSendHello();
848
- }
849
- maybeSendHello() {
850
- if (!this.running)
851
- return;
852
- // Check if both pools are satisfied
853
- const followsSatisfied = this.isPoolSatisfied('follows');
854
- const otherSatisfied = this.isPoolSatisfied('other');
855
- if (followsSatisfied && otherSatisfied) {
856
- return;
857
- }
858
- void this.dispatchSignaling({
859
- type: 'hello',
860
- peerId: this.myPeerId.toString(),
861
- hashGet: true,
862
- });
863
- }
864
- cleanupConnections() {
865
- const now = Date.now();
866
- const connectionTimeout = 15000; // 15 seconds to establish connection
867
- for (const [peerIdStr, { peer }] of this.peers) {
868
- const state = peer.state;
869
- const isStale = state === 'new' && (now - peer.createdAt) > connectionTimeout;
870
- if (state === 'failed' || state === 'closed' || state === 'disconnected' || isStale) {
871
- this.log('Cleaning up', state, 'connection', isStale ? '(stale)' : '');
872
- peer.close();
873
- this.peerSelector.removePeer(peerIdStr);
874
- this.peers.delete(peerIdStr);
875
- this.emit({ type: 'update' });
876
- }
877
- }
878
- }
879
- /**
880
- * Get number of connected peers
881
- */
882
- getConnectedCount() {
883
- return Array.from(this.peers.values())
884
- .filter(({ peer }) => peer.isConnected).length;
885
- }
886
- /**
887
- * Get all peer statuses
888
- */
889
- getPeers() {
890
- return Array.from(this.peers.values()).map(({ peer, pool, hashGet }) => ({
891
- peerId: peer.peerId,
892
- pubkey: peer.pubkey,
893
- state: peer.state,
894
- direction: peer.direction,
895
- connectedAt: peer.connectedAt,
896
- isSelf: peer.pubkey === this.myPeerId.pubkey,
897
- pool,
898
- isConnected: peer.isConnected, // Includes data channel state
899
- hashGet,
900
- }));
901
- }
902
- /**
903
- * Disconnect all peers with a given pubkey
904
- * Used when blocking a peer to immediately disconnect them
905
- */
906
- disconnectPeerByPubkey(pubkey) {
907
- for (const [peerIdStr, peerInfo] of this.peers) {
908
- if (peerInfo.peer.pubkey === pubkey) {
909
- this.log('Disconnecting blocked peer:', pubkey.slice(0, 8));
910
- peerInfo.peer.close();
911
- this.peerSelector.removePeer(peerIdStr);
912
- this.peers.delete(peerIdStr);
913
- this.emit({ type: 'peer-disconnected', peerId: peerIdStr });
914
- }
915
- }
916
- this.emit({ type: 'update' });
917
- }
918
- /**
919
- * Get my endpoint ID
920
- */
921
- getMyPeerId() {
922
- return this.myPeerId.toString();
923
- }
924
- /**
925
- * Check if store is satisfied (has enough connections in all pools)
926
- */
927
- isSatisfied() {
928
- return this.isPoolSatisfied('follows') && this.isPoolSatisfied('other');
929
- }
930
- /**
931
- * Update peer classifier (e.g., when social graph updates)
932
- */
933
- setPeerClassifier(classifier) {
934
- this.peerClassifier = classifier;
935
- // Re-classify existing peers (they keep their connections, just update pool assignment)
936
- for (const [peerIdStr, peerInfo] of this.peers) {
937
- const newPool = classifier(peerInfo.peer.pubkey);
938
- if (newPool !== peerInfo.pool) {
939
- this.log('Reclassified peer', peerIdStr.slice(0, 16), 'from', peerInfo.pool, 'to', newPool);
940
- peerInfo.pool = newPool;
941
- }
942
- }
943
- this.emit({ type: 'update' });
944
- }
945
- /**
946
- * Update pool configuration (e.g., from settings)
947
- */
948
- setPoolConfig(pools) {
949
- const oldOtherMax = this.pools.other.maxConnections;
950
- const oldFollowsMax = this.pools.follows.maxConnections;
951
- this.pools = pools;
952
- this.log('Pool config updated:', pools);
953
- // Check if subscription mode needs to change
954
- const newOtherMax = pools.other.maxConnections;
955
- const newFollowsMax = pools.follows.maxConnections;
956
- const subscriptionModeChanged = (oldOtherMax === 0) !== (newOtherMax === 0) ||
957
- (oldFollowsMax === 0) !== (newFollowsMax === 0);
958
- if (subscriptionModeChanged && this.running) {
959
- const since = Math.floor((Date.now() - this.config.messageTimeout) / 1000);
960
- const subHandler = {
961
- onevent: (event) => {
962
- this.handleSignalingEvent(event);
963
- },
964
- oneose: () => { },
965
- };
966
- this.setupHelloSubscription(since, subHandler);
967
- }
968
- // Existing connections remain, but new limits apply for future connections
969
- this.emit({ type: 'update' });
970
- }
971
- /**
972
- * Get current pool configuration
973
- */
974
- getPoolConfig() {
975
- return { ...this.pools };
976
- }
977
- async peerMetadataPointerHash() {
978
- return sha256(new TextEncoder().encode(PEER_METADATA_POINTER_SLOT_KEY));
979
- }
980
- /**
981
- * Persist selector metadata snapshot to local store.
982
- * Returns the snapshot hash, or null when local storage is unavailable.
983
- */
984
- async persistPeerMetadata() {
985
- if (!this.config.localStore)
986
- return null;
987
- const snapshot = this.peerSelector.exportPeerMetadataSnapshot();
988
- const bytes = new TextEncoder().encode(JSON.stringify(snapshot));
989
- const snapshotHash = await sha256(bytes);
990
- await this.config.localStore.put(snapshotHash, bytes);
991
- const pointerHash = await this.peerMetadataPointerHash();
992
- await this.config.localStore.delete(pointerHash);
993
- await this.config.localStore.put(pointerHash, new TextEncoder().encode(toHex(snapshotHash)));
994
- return snapshotHash;
995
- }
996
- /**
997
- * Load selector metadata snapshot from local store, if available.
998
- */
999
- async loadPeerMetadata() {
1000
- if (!this.config.localStore)
1001
- return false;
1002
- const pointerHash = await this.peerMetadataPointerHash();
1003
- const pointerBytes = await this.config.localStore.get(pointerHash);
1004
- if (!pointerBytes)
1005
- return false;
1006
- const pointerHex = new TextDecoder().decode(pointerBytes).trim();
1007
- if (pointerHex.length !== 64)
1008
- return false;
1009
- const snapshotHash = fromHex(pointerHex);
1010
- if (snapshotHash.length !== 32)
1011
- return false;
1012
- const snapshotBytes = await this.config.localStore.get(snapshotHash);
1013
- if (!snapshotBytes)
1014
- return false;
1015
- let snapshot;
1016
- try {
1017
- snapshot = JSON.parse(new TextDecoder().decode(snapshotBytes));
1018
- }
1019
- catch {
1020
- return false;
1021
- }
1022
- this.peerSelector.importPeerMetadataSnapshot(snapshot);
1023
- syncSelectorPeers(this.peerSelector, Array.from(this.peers.keys()));
1024
- return true;
1025
- }
1026
- /**
1027
- * Get fallback stores count
1028
- */
1029
- getFallbackStoresCount() {
1030
- return this.config.fallbackStores.length;
1031
- }
1032
- /**
1033
- * Get WebRTC stats (aggregate and per-peer)
1034
- */
1035
- getStats() {
1036
- // Aggregate stats from all peers + store-level stats
1037
- const aggregate = {
1038
- requestsSent: 0,
1039
- requestsReceived: 0,
1040
- responsesSent: 0,
1041
- responsesReceived: 0,
1042
- receiveErrors: 0,
1043
- blossomFetches: this.blossomFetches,
1044
- fragmentsSent: 0,
1045
- fragmentsReceived: 0,
1046
- fragmentTimeouts: 0,
1047
- reassembliesCompleted: 0,
1048
- bytesSent: 0,
1049
- bytesReceived: 0,
1050
- bytesForwarded: 0,
1051
- meshReceived: this.meshReceived,
1052
- meshForwarded: this.meshForwarded,
1053
- meshDroppedDuplicate: this.meshDroppedDuplicate,
1054
- };
1055
- const perPeer = new Map();
1056
- for (const [peerIdStr, { peer, pool }] of this.peers) {
1057
- const peerStats = peer.getStats();
1058
- aggregate.requestsSent += peerStats.requestsSent;
1059
- aggregate.requestsReceived += peerStats.requestsReceived;
1060
- aggregate.responsesSent += peerStats.responsesSent;
1061
- aggregate.responsesReceived += peerStats.responsesReceived;
1062
- aggregate.receiveErrors += peerStats.receiveErrors;
1063
- aggregate.fragmentsSent += peerStats.fragmentsSent;
1064
- aggregate.fragmentsReceived += peerStats.fragmentsReceived;
1065
- aggregate.fragmentTimeouts += peerStats.fragmentTimeouts;
1066
- aggregate.reassembliesCompleted += peerStats.reassembliesCompleted;
1067
- aggregate.bytesSent += peerStats.bytesSent;
1068
- aggregate.bytesReceived += peerStats.bytesReceived;
1069
- aggregate.bytesForwarded += peerStats.bytesForwarded;
1070
- perPeer.set(peerIdStr, {
1071
- pubkey: peer.pubkey,
1072
- pool,
1073
- stats: peerStats,
1074
- });
1075
- }
1076
- return { aggregate, perPeer };
1077
- }
1078
- // Store interface implementation
1079
- async put(hash, data) {
1080
- // Write to local store if available
1081
- const success = this.config.localStore
1082
- ? await this.config.localStore.put(hash, data)
1083
- : false;
1084
- // Send to any peers who have requested this hash
1085
- this.sendToInterestedPeers(hash, data);
1086
- // Fire-and-forget writes to fallback stores (e.g. blossom servers)
1087
- // Don't await - let them complete in background
1088
- for (const store of this.config.fallbackStores) {
1089
- store.put(hash, data).catch(() => {
1090
- // Silently ignore failures - fallback stores are best-effort
1091
- });
1092
- }
1093
- return success;
1094
- }
1095
- async get(hash) {
1096
- // Guard against undefined hash
1097
- if (!hash)
1098
- return null;
1099
- // Try local store first
1100
- if (this.config.localStore) {
1101
- const local = await this.config.localStore.get(hash);
1102
- if (local)
1103
- return local;
1104
- }
1105
- // Deduplicate: if there's already a pending request for this hash, wait for it
1106
- const hashHex = toHex(hash);
1107
- const pendingGet = this.pendingGets.get(hashHex);
1108
- if (pendingGet) {
1109
- return pendingGet;
1110
- }
1111
- // Create the actual fetch promise
1112
- const fetchPromise = this.fetchFromPeers(hash);
1113
- // Store it for deduplication
1114
- this.pendingGets.set(hashHex, fetchPromise);
1115
- // Clean up when done
1116
- try {
1117
- const result = await fetchPromise;
1118
- return result;
1119
- }
1120
- finally {
1121
- this.pendingGets.delete(hashHex);
1122
- }
1123
- }
1124
- /**
1125
- * Internal method to fetch data from peers (separated for deduplication)
1126
- */
1127
- async fetchFromPeers(hash) {
1128
- const triedPeers = new Set();
1129
- const orderedPeers = this.orderedConnectedPeers();
1130
- const webRtcData = await this.queryPeersWithDispatch(hash, orderedPeers, triedPeers);
1131
- if (webRtcData) {
1132
- return webRtcData;
1133
- }
1134
- // All WebRTC peers failed - try fallback stores in order
1135
- if (this.config.fallbackStores.length > 0) {
1136
- for (const store of this.config.fallbackStores) {
1137
- try {
1138
- const data = await store.get(hash);
1139
- if (data) {
1140
- this.blossomFetches++;
1141
- if (this.config.localStore) {
1142
- await this.config.localStore.put(hash, data);
1143
- }
1144
- return data;
1145
- }
1146
- }
1147
- catch (e) {
1148
- this.log('Fallback store error:', e);
1149
- }
1150
- }
1151
- }
1152
- // If running and either:
1153
- // 1. Not satisfied (still seeking more peers), OR
1154
- // 2. We haven't tried any peers yet (no peers connected, but might connect soon)
1155
- // Then add to pending reqs and wait for new peers
1156
- if (this.running && (!this.isSatisfied() || triedPeers.size === 0)) {
1157
- return this.waitForHash(hash, triedPeers);
1158
- }
1159
- return null;
1160
- }
1161
- /**
1162
- * Helper to create a delay promise
1163
- */
1164
- delay(ms) {
1165
- return new Promise(resolve => setTimeout(resolve, ms));
1166
- }
1167
- /**
1168
- * Add hash to pending requests list and wait for it to be resolved by peers
1169
- * Also immediately tries any connected peers that weren't tried yet
1170
- */
1171
- waitForHash(hash, triedPeers) {
1172
- return new Promise((resolve) => {
1173
- // Use longer timeout for pending requests - we need to wait for peers to connect
1174
- const reqTimeout = Math.max(this.config.requestTimeout * 6, 30000);
1175
- const timeout = setTimeout(() => {
1176
- this.removePendingReq(hash, req);
1177
- resolve(null);
1178
- }, reqTimeout);
1179
- const req = { resolve, timeout, triedPeers };
1180
- const existing = this.pendingReqs.get(hash);
1181
- if (existing) {
1182
- existing.push(req);
1183
- }
1184
- else {
1185
- this.pendingReqs.set(hash, [req]);
1186
- }
1187
- this.log('Added to pending reqs:', hash.slice(0, 16), 'tried', triedPeers.size, 'peers');
1188
- // Immediately try any connected peers that weren't tried yet
1189
- // This handles the race condition where peers connect while we're setting up the request
1190
- this.tryConnectedPeersForHash(hash);
1191
- });
1192
- }
1193
- /**
1194
- * Try all currently connected peers for a specific hash in the pending requests
1195
- */
1196
- async tryConnectedPeersForHash(hash) {
1197
- const reqs = this.pendingReqs.get(hash);
1198
- if (!reqs || reqs.length === 0)
1199
- return;
1200
- const connectedPeers = this.orderedConnectedPeers();
1201
- const expectedHashHex = toHex(hash);
1202
- for (const peer of connectedPeers) {
1203
- const peerIdStr = peer.peerId;
1204
- // Find requests that haven't tried this peer yet
1205
- const untried = reqs.filter(r => !r.triedPeers.has(peerIdStr));
1206
- if (untried.length === 0)
1207
- continue;
1208
- // Mark as tried
1209
- for (const r of untried) {
1210
- r.triedPeers.add(peerIdStr);
1211
- }
1212
- this.log('Trying pending req from connected peer:', hash.slice(0, 16));
1213
- const startedAt = Date.now();
1214
- this.peerSelector.recordRequest(peerIdStr, 40);
1215
- const data = await peer.request(hash);
1216
- if (data) {
1217
- const computedHash = await sha256(data);
1218
- if (toHex(computedHash) !== expectedHashHex) {
1219
- this.peerSelector.recordFailure(peerIdStr);
1220
- continue;
1221
- }
1222
- this.peerSelector.recordSuccess(peerIdStr, Math.max(1, Date.now() - startedAt), data.length);
1223
- // Store locally
1224
- if (this.config.localStore) {
1225
- await this.config.localStore.put(hash, data);
1226
- }
1227
- // Resolve all waiting requests
1228
- const currentReqs = this.pendingReqs.get(hash);
1229
- if (currentReqs) {
1230
- for (const r of currentReqs) {
1231
- clearTimeout(r.timeout);
1232
- r.resolve(data);
1233
- }
1234
- this.pendingReqs.delete(hash);
1235
- }
1236
- this.log('Resolved pending req:', hash.slice(0, 16));
1237
- return;
1238
- }
1239
- this.peerSelector.recordTimeout(peerIdStr);
1240
- }
1241
- }
1242
- /**
1243
- * Remove a pending request from the list
1244
- */
1245
- removePendingReq(hash, req) {
1246
- const reqs = this.pendingReqs.get(hash);
1247
- if (!reqs)
1248
- return;
1249
- const idx = reqs.indexOf(req);
1250
- if (idx !== -1) {
1251
- reqs.splice(idx, 1);
1252
- if (reqs.length === 0) {
1253
- this.pendingReqs.delete(hash);
1254
- }
1255
- }
1256
- }
1257
- /**
1258
- * Try pending requests with a newly connected peer
1259
- */
1260
- async tryPendingReqs(peer) {
1261
- const peerIdStr = peer.peerId;
1262
- const expectedByHash = new Map();
1263
- for (const [hash, reqs] of this.pendingReqs.entries()) {
1264
- const hashKey = toHex(hash);
1265
- expectedByHash.set(hashKey, hashKey);
1266
- // Find requests that haven't tried this peer yet
1267
- const untried = reqs.filter(r => !r.triedPeers.has(peerIdStr));
1268
- if (untried.length === 0)
1269
- continue;
1270
- // Mark as tried
1271
- for (const r of untried) {
1272
- r.triedPeers.add(peerIdStr);
1273
- }
1274
- const startedAt = Date.now();
1275
- this.peerSelector.recordRequest(peerIdStr, 40);
1276
- const data = await peer.request(hash);
1277
- if (data) {
1278
- const computedHash = await sha256(data);
1279
- const expectedHashHex = expectedByHash.get(hashKey);
1280
- if (toHex(computedHash) !== expectedHashHex) {
1281
- this.peerSelector.recordFailure(peerIdStr);
1282
- continue;
1283
- }
1284
- this.peerSelector.recordSuccess(peerIdStr, Math.max(1, Date.now() - startedAt), data.length);
1285
- // Store locally
1286
- if (this.config.localStore) {
1287
- await this.config.localStore.put(hash, data);
1288
- }
1289
- // Resolve all waiting requests
1290
- for (const r of reqs) {
1291
- clearTimeout(r.timeout);
1292
- r.resolve(data);
1293
- }
1294
- this.pendingReqs.delete(hash);
1295
- this.log('Resolved pending req:', hash.slice(0, 16));
1296
- }
1297
- else {
1298
- this.peerSelector.recordTimeout(peerIdStr);
1299
- }
1300
- }
1301
- }
1302
- async has(hash) {
1303
- // Check local store
1304
- if (this.config.localStore) {
1305
- const hasLocal = await this.config.localStore.has(hash);
1306
- if (hasLocal)
1307
- return true;
1308
- }
1309
- // Could query peers, but for now just check locally
1310
- return false;
1311
- }
1312
- async delete(hash) {
1313
- // Only delete from local store
1314
- if (this.config.localStore) {
1315
- return this.config.localStore.delete(hash);
1316
- }
1317
- return false;
1318
- }
1319
- }
1320
- //# sourceMappingURL=store.js.map