@hashtree/core 0.1.3 → 0.1.4
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/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +2 -1
- package/dist/builder.js.map +1 -1
- package/dist/compare.d.ts +2 -0
- package/dist/compare.d.ts.map +1 -0
- package/dist/compare.js +8 -0
- package/dist/compare.js.map +1 -0
- package/dist/encrypted.d.ts.map +1 -1
- package/dist/encrypted.js +28 -18
- package/dist/encrypted.js.map +1 -1
- package/dist/hashtree.js +1 -1
- package/dist/hashtree.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/store/blossom.d.ts.map +1 -1
- package/dist/store/blossom.js +52 -22
- package/dist/store/blossom.js.map +1 -1
- package/dist/store/fallback.d.ts +3 -1
- package/dist/store/fallback.d.ts.map +1 -1
- package/dist/store/fallback.js +60 -24
- package/dist/store/fallback.js.map +1 -1
- package/dist/tree/create.d.ts.map +1 -1
- package/dist/tree/create.js +2 -1
- package/dist/tree/create.js.map +1 -1
- package/dist/types.d.ts +14 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -3
- package/dist/resolver/index.d.ts +0 -5
- package/dist/resolver/index.d.ts.map +0 -1
- package/dist/resolver/index.js +0 -5
- package/dist/resolver/index.js.map +0 -1
- package/dist/resolver/nostr.d.ts +0 -82
- package/dist/resolver/nostr.d.ts.map +0 -1
- package/dist/resolver/nostr.js +0 -868
- package/dist/resolver/nostr.js.map +0 -1
- package/dist/store/dexie.d.ts +0 -44
- package/dist/store/dexie.d.ts.map +0 -1
- package/dist/store/dexie.js +0 -196
- package/dist/store/dexie.js.map +0 -1
- package/dist/store/opfs.d.ts +0 -56
- package/dist/store/opfs.d.ts.map +0 -1
- package/dist/store/opfs.js +0 -200
- package/dist/store/opfs.js.map +0 -1
- package/dist/webrtc/index.d.ts +0 -4
- package/dist/webrtc/index.d.ts.map +0 -1
- package/dist/webrtc/index.js +0 -4
- package/dist/webrtc/index.js.map +0 -1
- package/dist/webrtc/lruCache.d.ts +0 -20
- package/dist/webrtc/lruCache.d.ts.map +0 -1
- package/dist/webrtc/lruCache.js +0 -59
- package/dist/webrtc/lruCache.js.map +0 -1
- package/dist/webrtc/peer.d.ts +0 -122
- package/dist/webrtc/peer.d.ts.map +0 -1
- package/dist/webrtc/peer.js +0 -583
- package/dist/webrtc/peer.js.map +0 -1
- package/dist/webrtc/protocol.d.ts +0 -76
- package/dist/webrtc/protocol.d.ts.map +0 -1
- package/dist/webrtc/protocol.js +0 -167
- package/dist/webrtc/protocol.js.map +0 -1
- package/dist/webrtc/store.d.ts +0 -190
- package/dist/webrtc/store.d.ts.map +0 -1
- package/dist/webrtc/store.js +0 -1043
- package/dist/webrtc/store.js.map +0 -1
- package/dist/webrtc/types.d.ts +0 -196
- package/dist/webrtc/types.d.ts.map +0 -1
- package/dist/webrtc/types.js +0 -46
- package/dist/webrtc/types.js.map +0 -1
package/dist/webrtc/store.js
DELETED
|
@@ -1,1043 +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 } from 'nostr-tools';
|
|
18
|
-
import { toHex } from '../types.js';
|
|
19
|
-
import { PeerId, generateUuid, } from './types.js';
|
|
20
|
-
import { Peer } from './peer.js';
|
|
21
|
-
export const DEFAULT_RELAYS = [
|
|
22
|
-
'wss://relay.damus.io',
|
|
23
|
-
'wss://relay.primal.net',
|
|
24
|
-
'wss://nos.lol',
|
|
25
|
-
'wss://relay.nostr.band',
|
|
26
|
-
'wss://temp.iris.to',
|
|
27
|
-
'wss://relay.snort.social',
|
|
28
|
-
];
|
|
29
|
-
// All WebRTC signaling uses ephemeral kind 25050
|
|
30
|
-
// Hello messages use #l tag for broadcast discovery
|
|
31
|
-
// Directed messages use #p tag with gift wrap
|
|
32
|
-
const SIGNALING_KIND = 25050;
|
|
33
|
-
const HELLO_TAG = 'hello';
|
|
34
|
-
export class WebRTCStore {
|
|
35
|
-
config;
|
|
36
|
-
pools;
|
|
37
|
-
peerClassifier;
|
|
38
|
-
getFollowedPubkeys;
|
|
39
|
-
isPeerBlocked;
|
|
40
|
-
signer;
|
|
41
|
-
encrypt;
|
|
42
|
-
decrypt;
|
|
43
|
-
giftWrap;
|
|
44
|
-
giftUnwrap;
|
|
45
|
-
myPeerId;
|
|
46
|
-
pool;
|
|
47
|
-
subscriptions = [];
|
|
48
|
-
helloSubscription = null;
|
|
49
|
-
peers = new Map();
|
|
50
|
-
// Track pubkeys we're currently connecting to in 'other' pool (prevents race conditions)
|
|
51
|
-
pendingOtherPubkeys = new Set();
|
|
52
|
-
helloInterval = null;
|
|
53
|
-
cleanupInterval = null;
|
|
54
|
-
eventHandlers = new Set();
|
|
55
|
-
running = false;
|
|
56
|
-
pendingReqs = new Map();
|
|
57
|
-
// Deduplicate concurrent get() calls for the same hash
|
|
58
|
-
pendingGets = new Map();
|
|
59
|
-
// Store-level stats (not per-peer)
|
|
60
|
-
blossomFetches = 0;
|
|
61
|
-
// Track current hello subscription authors for change detection
|
|
62
|
-
currentHelloAuthors = null;
|
|
63
|
-
constructor(config) {
|
|
64
|
-
this.signer = config.signer;
|
|
65
|
-
this.encrypt = config.encrypt;
|
|
66
|
-
this.decrypt = config.decrypt;
|
|
67
|
-
this.giftWrap = config.giftWrap;
|
|
68
|
-
this.giftUnwrap = config.giftUnwrap;
|
|
69
|
-
this.myPeerId = new PeerId(config.pubkey, generateUuid());
|
|
70
|
-
// Default classifier: everyone is 'other' unless classifier provided
|
|
71
|
-
this.peerClassifier = config.peerClassifier ?? (() => 'other');
|
|
72
|
-
// Function to get followed pubkeys for subscription filtering
|
|
73
|
-
this.getFollowedPubkeys = config.getFollowedPubkeys ?? null;
|
|
74
|
-
// Function to check if a peer is blocked
|
|
75
|
-
this.isPeerBlocked = config.isPeerBlocked ?? null;
|
|
76
|
-
// Use pool config if provided, otherwise fall back to legacy config or defaults
|
|
77
|
-
if (config.pools) {
|
|
78
|
-
this.pools = config.pools;
|
|
79
|
-
}
|
|
80
|
-
else {
|
|
81
|
-
// Legacy mode: single pool with old config values
|
|
82
|
-
const maxConn = config.maxConnections ?? 6;
|
|
83
|
-
const satConn = config.satisfiedConnections ?? 3;
|
|
84
|
-
this.pools = {
|
|
85
|
-
follows: { maxConnections: 0, satisfiedConnections: 0 }, // No follows pool in legacy
|
|
86
|
-
other: { maxConnections: maxConn, satisfiedConnections: satConn },
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
this.config = {
|
|
90
|
-
helloInterval: config.helloInterval ?? 10000,
|
|
91
|
-
messageTimeout: config.messageTimeout ?? 60000, // 60 seconds for relay propagation
|
|
92
|
-
requestTimeout: config.requestTimeout ?? 500,
|
|
93
|
-
peerQueryDelay: config.peerQueryDelay ?? 500,
|
|
94
|
-
relays: config.relays ?? DEFAULT_RELAYS,
|
|
95
|
-
localStore: config.localStore ?? null,
|
|
96
|
-
fallbackStores: config.fallbackStores ?? [],
|
|
97
|
-
debug: config.debug ?? false,
|
|
98
|
-
};
|
|
99
|
-
this.pool = new SimplePool();
|
|
100
|
-
}
|
|
101
|
-
log(...args) {
|
|
102
|
-
if (this.config.debug) {
|
|
103
|
-
console.log('[WebRTCStore]', ...args);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* Get pool counts
|
|
108
|
-
*/
|
|
109
|
-
getPoolCounts() {
|
|
110
|
-
const counts = {
|
|
111
|
-
follows: { connected: 0, total: 0 },
|
|
112
|
-
other: { connected: 0, total: 0 },
|
|
113
|
-
};
|
|
114
|
-
for (const { peer, pool } of this.peers.values()) {
|
|
115
|
-
counts[pool].total++;
|
|
116
|
-
if (peer.isConnected) {
|
|
117
|
-
counts[pool].connected++;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return counts;
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* Check if we can accept a peer in a given pool
|
|
124
|
-
*/
|
|
125
|
-
canAcceptPeer(pool) {
|
|
126
|
-
const counts = this.getPoolCounts();
|
|
127
|
-
return counts[pool].total < this.pools[pool].maxConnections;
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* Check if a pool is satisfied
|
|
131
|
-
*/
|
|
132
|
-
isPoolSatisfied(pool) {
|
|
133
|
-
const counts = this.getPoolCounts();
|
|
134
|
-
return counts[pool].connected >= this.pools[pool].satisfiedConnections;
|
|
135
|
-
}
|
|
136
|
-
/**
|
|
137
|
-
* Check if we already have a connection from a pubkey in the 'other' pool.
|
|
138
|
-
* In the 'other' pool, we only allow 1 instance per pubkey.
|
|
139
|
-
* Also checks pendingOtherPubkeys to prevent race conditions.
|
|
140
|
-
*/
|
|
141
|
-
hasOtherPoolPubkey(pubkey) {
|
|
142
|
-
if (this.pendingOtherPubkeys.has(pubkey)) {
|
|
143
|
-
return true;
|
|
144
|
-
}
|
|
145
|
-
for (const { peer, pool } of this.peers.values()) {
|
|
146
|
-
if (pool === 'other' && peer.pubkey === pubkey) {
|
|
147
|
-
return true;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
return false;
|
|
151
|
-
}
|
|
152
|
-
/**
|
|
153
|
-
* Start the WebRTC store - connect to relays and begin peer discovery
|
|
154
|
-
*/
|
|
155
|
-
start() {
|
|
156
|
-
if (this.running)
|
|
157
|
-
return;
|
|
158
|
-
this.running = true;
|
|
159
|
-
this.log('Starting with peerId:', this.myPeerId.short());
|
|
160
|
-
this.log('Pool config:', this.pools);
|
|
161
|
-
this.log('Relays:', this.config.relays);
|
|
162
|
-
// Subscribe to signaling messages
|
|
163
|
-
this.startSubscription();
|
|
164
|
-
// Send hello messages when not satisfied
|
|
165
|
-
this.helloInterval = setInterval(() => {
|
|
166
|
-
this.maybeSendHello();
|
|
167
|
-
}, this.config.helloInterval);
|
|
168
|
-
// Send initial hello
|
|
169
|
-
this.maybeSendHello();
|
|
170
|
-
// Cleanup stale connections
|
|
171
|
-
this.cleanupInterval = setInterval(() => {
|
|
172
|
-
this.cleanupConnections();
|
|
173
|
-
}, 5000);
|
|
174
|
-
}
|
|
175
|
-
/**
|
|
176
|
-
* Stop the WebRTC store
|
|
177
|
-
*/
|
|
178
|
-
stop() {
|
|
179
|
-
if (!this.running)
|
|
180
|
-
return;
|
|
181
|
-
this.running = false;
|
|
182
|
-
this.log('Stopping');
|
|
183
|
-
if (this.helloInterval) {
|
|
184
|
-
clearInterval(this.helloInterval);
|
|
185
|
-
this.helloInterval = null;
|
|
186
|
-
}
|
|
187
|
-
if (this.cleanupInterval) {
|
|
188
|
-
clearInterval(this.cleanupInterval);
|
|
189
|
-
this.cleanupInterval = null;
|
|
190
|
-
}
|
|
191
|
-
for (const sub of this.subscriptions) {
|
|
192
|
-
sub.close();
|
|
193
|
-
}
|
|
194
|
-
this.subscriptions = [];
|
|
195
|
-
// Close all peer connections
|
|
196
|
-
for (const { peer } of this.peers.values()) {
|
|
197
|
-
peer.close();
|
|
198
|
-
}
|
|
199
|
-
this.peers.clear();
|
|
200
|
-
this.pendingOtherPubkeys.clear();
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* Set relays and reconnect. Useful for runtime relay configuration changes.
|
|
204
|
-
* Closes existing subscriptions and starts new ones with the updated relays.
|
|
205
|
-
*/
|
|
206
|
-
setRelays(relays) {
|
|
207
|
-
this.log('setRelays:', relays);
|
|
208
|
-
this.config.relays = relays;
|
|
209
|
-
// If running, restart subscriptions with new relays
|
|
210
|
-
if (this.running) {
|
|
211
|
-
// Close existing subscriptions
|
|
212
|
-
for (const sub of this.subscriptions) {
|
|
213
|
-
sub.close();
|
|
214
|
-
}
|
|
215
|
-
this.subscriptions = [];
|
|
216
|
-
// Clear existing peers (they were discovered via old relays)
|
|
217
|
-
for (const { peer } of this.peers.values()) {
|
|
218
|
-
peer.close();
|
|
219
|
-
}
|
|
220
|
-
this.peers.clear();
|
|
221
|
-
// Start new subscriptions with updated relays
|
|
222
|
-
this.startSubscription();
|
|
223
|
-
this.emit({ type: 'update' });
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
/**
|
|
227
|
-
* Add event listener
|
|
228
|
-
*/
|
|
229
|
-
on(handler) {
|
|
230
|
-
this.eventHandlers.add(handler);
|
|
231
|
-
return () => this.eventHandlers.delete(handler);
|
|
232
|
-
}
|
|
233
|
-
emit(event) {
|
|
234
|
-
for (const handler of this.eventHandlers) {
|
|
235
|
-
handler(event);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
startSubscription() {
|
|
239
|
-
const since = Math.floor((Date.now() - this.config.messageTimeout) / 1000);
|
|
240
|
-
const subHandler = {
|
|
241
|
-
onevent: (event) => {
|
|
242
|
-
this.handleSignalingEvent(event);
|
|
243
|
-
},
|
|
244
|
-
oneose: () => { },
|
|
245
|
-
};
|
|
246
|
-
// 1. Subscribe to hello messages based on pool configuration
|
|
247
|
-
this.setupHelloSubscription(since, subHandler);
|
|
248
|
-
// 2. Subscribe to directed signaling (kind 25050 with #p tag) for offers/answers/candidates
|
|
249
|
-
// Always subscribe to directed messages (needed to receive offers/answers)
|
|
250
|
-
this.subscriptions.push(this.pool.subscribe(this.config.relays, {
|
|
251
|
-
kinds: [SIGNALING_KIND],
|
|
252
|
-
'#p': [this.myPeerId.pubkey],
|
|
253
|
-
since,
|
|
254
|
-
}, subHandler));
|
|
255
|
-
}
|
|
256
|
-
/**
|
|
257
|
-
* Setup hello subscription based on pool configuration
|
|
258
|
-
* - If both pools are 0: don't subscribe to hellos
|
|
259
|
-
* - If other pool is disabled but follows is enabled: subscribe to followed pubkeys only
|
|
260
|
-
* - If other pool is enabled: subscribe to all hellos
|
|
261
|
-
*/
|
|
262
|
-
setupHelloSubscription(since, subHandler) {
|
|
263
|
-
// Close existing hello subscription if any
|
|
264
|
-
if (this.helloSubscription) {
|
|
265
|
-
this.helloSubscription.close();
|
|
266
|
-
this.helloSubscription = null;
|
|
267
|
-
}
|
|
268
|
-
const followsMax = this.pools.follows.maxConnections;
|
|
269
|
-
const otherMax = this.pools.other.maxConnections;
|
|
270
|
-
// If both pools are disabled, don't subscribe to hellos at all
|
|
271
|
-
if (followsMax === 0 && otherMax === 0) {
|
|
272
|
-
this.log('Both pools disabled, not subscribing to hellos');
|
|
273
|
-
this.currentHelloAuthors = [];
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
// If other pool is disabled but follows pool is enabled, only subscribe to followed users
|
|
277
|
-
if (otherMax === 0 && followsMax > 0) {
|
|
278
|
-
const followedPubkeys = this.getFollowedPubkeys?.() ?? [];
|
|
279
|
-
if (followedPubkeys.length === 0) {
|
|
280
|
-
this.log('Follows pool enabled but no followed users, not subscribing to hellos');
|
|
281
|
-
this.currentHelloAuthors = [];
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
this.log('Other pool disabled, subscribing to hellos from', followedPubkeys.length, 'followed users');
|
|
285
|
-
this.currentHelloAuthors = [...followedPubkeys];
|
|
286
|
-
this.helloSubscription = this.pool.subscribe(this.config.relays, {
|
|
287
|
-
kinds: [SIGNALING_KIND],
|
|
288
|
-
'#l': [HELLO_TAG],
|
|
289
|
-
authors: followedPubkeys,
|
|
290
|
-
since,
|
|
291
|
-
}, subHandler);
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
// Otherwise subscribe to all hellos
|
|
295
|
-
this.log('Subscribing to all hellos');
|
|
296
|
-
this.currentHelloAuthors = null; // null means all authors
|
|
297
|
-
this.helloSubscription = this.pool.subscribe(this.config.relays, {
|
|
298
|
-
kinds: [SIGNALING_KIND],
|
|
299
|
-
'#l': [HELLO_TAG],
|
|
300
|
-
since,
|
|
301
|
-
}, subHandler);
|
|
302
|
-
}
|
|
303
|
-
/**
|
|
304
|
-
* Update hello subscription when follows list changes
|
|
305
|
-
* Call this after social graph updates
|
|
306
|
-
*/
|
|
307
|
-
updateHelloSubscription() {
|
|
308
|
-
if (!this.running)
|
|
309
|
-
return;
|
|
310
|
-
const followsMax = this.pools.follows.maxConnections;
|
|
311
|
-
const otherMax = this.pools.other.maxConnections;
|
|
312
|
-
// Only need to update if we're in follows-only mode
|
|
313
|
-
if (otherMax === 0 && followsMax > 0) {
|
|
314
|
-
const followedPubkeys = this.getFollowedPubkeys?.() ?? [];
|
|
315
|
-
const currentAuthors = this.currentHelloAuthors ?? [];
|
|
316
|
-
// Check if follows list changed
|
|
317
|
-
const changed = followedPubkeys.length !== currentAuthors.length ||
|
|
318
|
-
!followedPubkeys.every(pk => currentAuthors.includes(pk));
|
|
319
|
-
if (changed) {
|
|
320
|
-
const since = Math.floor((Date.now() - this.config.messageTimeout) / 1000);
|
|
321
|
-
const subHandler = {
|
|
322
|
-
onevent: (event) => {
|
|
323
|
-
this.handleSignalingEvent(event);
|
|
324
|
-
},
|
|
325
|
-
oneose: () => { },
|
|
326
|
-
};
|
|
327
|
-
this.setupHelloSubscription(since, subHandler);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
async handleSignalingEvent(event) {
|
|
332
|
-
// Filter out old events (created more than messageTimeout ago)
|
|
333
|
-
const eventAge = Date.now() / 1000 - (event.created_at ?? 0);
|
|
334
|
-
if (eventAge > this.config.messageTimeout / 1000) {
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
// Check expiration
|
|
338
|
-
const expirationTag = event.tags.find(t => t[0] === 'expiration');
|
|
339
|
-
if (expirationTag) {
|
|
340
|
-
const expiration = parseInt(expirationTag[1], 10);
|
|
341
|
-
if (expiration < Date.now() / 1000) {
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
// Check if this is a hello message (#l: hello tag)
|
|
346
|
-
const lTag = event.tags.find(t => t[0] === 'l')?.[1];
|
|
347
|
-
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);
|
|
351
|
-
}
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
// Check if this is a directed message (#p tag pointing to us)
|
|
355
|
-
const pTag = event.tags.find(t => t[0] === 'p')?.[1];
|
|
356
|
-
if (pTag === this.myPeerId.pubkey) {
|
|
357
|
-
// Gift-wrapped signaling message - try to unwrap
|
|
358
|
-
try {
|
|
359
|
-
const inner = await this.giftUnwrap(event);
|
|
360
|
-
if (!inner) {
|
|
361
|
-
return; // Can't decrypt - not for us
|
|
362
|
-
}
|
|
363
|
-
const msg = JSON.parse(inner.content);
|
|
364
|
-
await this.handleSignalingMessage(msg, inner.pubkey);
|
|
365
|
-
}
|
|
366
|
-
catch {
|
|
367
|
-
// Not for us or invalid - ignore silently
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
async handleSignalingMessage(msg, senderPubkey) {
|
|
372
|
-
// Directed message - check if it's for us
|
|
373
|
-
if (msg.targetPeerId !== this.myPeerId.toString()) {
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
const peerId = new PeerId(senderPubkey, msg.peerId);
|
|
377
|
-
const peerIdStr = peerId.toString();
|
|
378
|
-
if (msg.type === 'offer') {
|
|
379
|
-
await this.handleOffer(peerId, msg);
|
|
380
|
-
}
|
|
381
|
-
else {
|
|
382
|
-
// answer or candidate
|
|
383
|
-
const peerInfo = this.peers.get(peerIdStr);
|
|
384
|
-
if (peerInfo) {
|
|
385
|
-
await peerInfo.peer.handleSignaling(msg);
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
async handleHello(peerUuid, senderPubkey) {
|
|
390
|
-
const peerId = new PeerId(senderPubkey, peerUuid);
|
|
391
|
-
// Skip self (exact same peerId = same session)
|
|
392
|
-
if (peerId.toString() === this.myPeerId.toString()) {
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
// Skip blocked peers
|
|
396
|
-
if (this.isPeerBlocked?.(senderPubkey)) {
|
|
397
|
-
this.log('Ignoring hello from blocked peer:', senderPubkey.slice(0, 8));
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
// Check if we already have this peer
|
|
401
|
-
if (this.peers.has(peerId.toString())) {
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
// Classify the peer
|
|
405
|
-
let pool;
|
|
406
|
-
try {
|
|
407
|
-
pool = this.peerClassifier(senderPubkey);
|
|
408
|
-
}
|
|
409
|
-
catch (e) {
|
|
410
|
-
this.log('Error classifying peer:', e);
|
|
411
|
-
pool = 'other';
|
|
412
|
-
}
|
|
413
|
-
// Check if we can accept this peer in their pool
|
|
414
|
-
if (!this.canAcceptPeer(pool)) {
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
// In 'other' pool, only allow 1 instance per pubkey
|
|
418
|
-
if (pool === 'other' && this.hasOtherPoolPubkey(senderPubkey)) {
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
// Perfect negotiation: both peers initiate
|
|
422
|
-
// Collision is handled by Peer class (polite peer rolls back)
|
|
423
|
-
// Mark as pending before async operation to prevent race conditions
|
|
424
|
-
if (pool === 'other') {
|
|
425
|
-
this.pendingOtherPubkeys.add(senderPubkey);
|
|
426
|
-
}
|
|
427
|
-
try {
|
|
428
|
-
await this.connectToPeer(peerId, pool);
|
|
429
|
-
}
|
|
430
|
-
finally {
|
|
431
|
-
this.pendingOtherPubkeys.delete(senderPubkey);
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
async handleOffer(peerId, msg) {
|
|
435
|
-
// Skip self (exact same peerId)
|
|
436
|
-
if (peerId.toString() === this.myPeerId.toString()) {
|
|
437
|
-
return;
|
|
438
|
-
}
|
|
439
|
-
// Skip blocked peers
|
|
440
|
-
if (this.isPeerBlocked?.(peerId.pubkey)) {
|
|
441
|
-
this.log('Ignoring offer from blocked peer:', peerId.pubkey.slice(0, 8));
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
const peerIdStr = peerId.toString();
|
|
445
|
-
// Classify the peer
|
|
446
|
-
const pool = this.peerClassifier(peerId.pubkey);
|
|
447
|
-
// Check if we can accept (unless we already have this peer)
|
|
448
|
-
if (!this.peers.has(peerIdStr) && !this.canAcceptPeer(pool)) {
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
// In 'other' pool, only allow 1 instance per pubkey (unless we already have this exact peer)
|
|
452
|
-
if (!this.peers.has(peerIdStr) && pool === 'other' && this.hasOtherPoolPubkey(peerId.pubkey)) {
|
|
453
|
-
return;
|
|
454
|
-
}
|
|
455
|
-
// Mark as pending before any async gaps to prevent race conditions
|
|
456
|
-
if (pool === 'other' && !this.peers.has(peerIdStr)) {
|
|
457
|
-
this.pendingOtherPubkeys.add(peerId.pubkey);
|
|
458
|
-
}
|
|
459
|
-
// Clean up existing connection if any
|
|
460
|
-
const existing = this.peers.get(peerIdStr);
|
|
461
|
-
if (existing) {
|
|
462
|
-
existing.peer.close();
|
|
463
|
-
this.peers.delete(peerIdStr);
|
|
464
|
-
}
|
|
465
|
-
const peer = new Peer({
|
|
466
|
-
peerId,
|
|
467
|
-
myPeerId: this.myPeerId.uuid,
|
|
468
|
-
direction: 'inbound',
|
|
469
|
-
localStore: this.config.localStore,
|
|
470
|
-
sendSignaling: (m) => this.sendSignaling(m, peerId.pubkey),
|
|
471
|
-
onClose: () => this.handlePeerClose(peerIdStr),
|
|
472
|
-
onConnected: () => {
|
|
473
|
-
this.emit({ type: 'peer-connected', peerId: peerIdStr });
|
|
474
|
-
this.emit({ type: 'update' });
|
|
475
|
-
this.tryPendingReqs(peer);
|
|
476
|
-
},
|
|
477
|
-
onForwardRequest: (hash, exclude, htl) => this.forwardRequest(hash, exclude, htl),
|
|
478
|
-
requestTimeout: this.config.requestTimeout,
|
|
479
|
-
debug: this.config.debug,
|
|
480
|
-
});
|
|
481
|
-
this.peers.set(peerIdStr, { peer, pool });
|
|
482
|
-
// Clear pending now that peer is in the map
|
|
483
|
-
this.pendingOtherPubkeys.delete(peerId.pubkey);
|
|
484
|
-
await peer.handleSignaling(msg);
|
|
485
|
-
}
|
|
486
|
-
async connectToPeer(peerId, pool) {
|
|
487
|
-
const peerIdStr = peerId.toString();
|
|
488
|
-
if (this.peers.has(peerIdStr)) {
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
this.log('Initiating connection to', peerId.short(), 'pool:', pool);
|
|
492
|
-
const peer = new Peer({
|
|
493
|
-
peerId,
|
|
494
|
-
myPeerId: this.myPeerId.uuid,
|
|
495
|
-
direction: 'outbound',
|
|
496
|
-
localStore: this.config.localStore,
|
|
497
|
-
sendSignaling: (m) => this.sendSignaling(m, peerId.pubkey),
|
|
498
|
-
onClose: () => this.handlePeerClose(peerIdStr),
|
|
499
|
-
onConnected: () => {
|
|
500
|
-
this.emit({ type: 'peer-connected', peerId: peerIdStr });
|
|
501
|
-
this.emit({ type: 'update' });
|
|
502
|
-
this.tryPendingReqs(peer);
|
|
503
|
-
},
|
|
504
|
-
onForwardRequest: (hash, exclude, htl) => this.forwardRequest(hash, exclude, htl),
|
|
505
|
-
requestTimeout: this.config.requestTimeout,
|
|
506
|
-
debug: this.config.debug,
|
|
507
|
-
});
|
|
508
|
-
this.peers.set(peerIdStr, { peer, pool });
|
|
509
|
-
await peer.connect();
|
|
510
|
-
}
|
|
511
|
-
handlePeerClose(peerIdStr) {
|
|
512
|
-
this.peers.delete(peerIdStr);
|
|
513
|
-
this.emit({ type: 'peer-disconnected', peerId: peerIdStr });
|
|
514
|
-
this.emit({ type: 'update' });
|
|
515
|
-
}
|
|
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) => {
|
|
528
|
-
if (a.pool === 'follows' && b.pool !== 'follows')
|
|
529
|
-
return -1;
|
|
530
|
-
if (a.pool !== 'follows' && b.pool === 'follows')
|
|
531
|
-
return 1;
|
|
532
|
-
return 0;
|
|
533
|
-
});
|
|
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
|
|
546
|
-
if (this.config.localStore) {
|
|
547
|
-
await this.config.localStore.put(hash, result.data);
|
|
548
|
-
}
|
|
549
|
-
return result.data;
|
|
550
|
-
}
|
|
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
|
-
}
|
|
555
|
-
}
|
|
556
|
-
return null;
|
|
557
|
-
}
|
|
558
|
-
/**
|
|
559
|
-
* Send data to all peers who have requested this hash
|
|
560
|
-
* Called when we receive data that peers may be waiting for
|
|
561
|
-
*/
|
|
562
|
-
sendToInterestedPeers(hash, data) {
|
|
563
|
-
let sendCount = 0;
|
|
564
|
-
for (const { peer } of this.peers.values()) {
|
|
565
|
-
if (peer.isConnected && peer.sendData(hash, data)) {
|
|
566
|
-
sendCount++;
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
if (sendCount > 0) {
|
|
570
|
-
this.log('Sent data to', sendCount, 'interested peers for hash:', toHex(hash).slice(0, 16));
|
|
571
|
-
}
|
|
572
|
-
return sendCount;
|
|
573
|
-
}
|
|
574
|
-
async sendSignaling(msg, recipientPubkey) {
|
|
575
|
-
// Fill in our peer ID
|
|
576
|
-
if ('peerId' in msg && msg.peerId === '') {
|
|
577
|
-
msg.peerId = this.myPeerId.uuid;
|
|
578
|
-
}
|
|
579
|
-
if (recipientPubkey) {
|
|
580
|
-
// Directed message (offer, answer, candidate, candidates)
|
|
581
|
-
// Use NIP-17 style gift wrap with kind 25050
|
|
582
|
-
const innerEvent = {
|
|
583
|
-
kind: SIGNALING_KIND,
|
|
584
|
-
content: JSON.stringify(msg),
|
|
585
|
-
tags: [],
|
|
586
|
-
};
|
|
587
|
-
const wrappedEvent = await this.giftWrap(innerEvent, recipientPubkey);
|
|
588
|
-
await this.pool.publish(this.config.relays, wrappedEvent);
|
|
589
|
-
}
|
|
590
|
-
else {
|
|
591
|
-
// Hello message - broadcast for peer discovery (kind 25050 with #l: hello)
|
|
592
|
-
const expiration = Math.floor((Date.now() + 5 * 60 * 1000) / 1000); // 5 minutes
|
|
593
|
-
const tags = [
|
|
594
|
-
['l', HELLO_TAG],
|
|
595
|
-
['peerId', msg.peerId],
|
|
596
|
-
['expiration', expiration.toString()],
|
|
597
|
-
];
|
|
598
|
-
const eventTemplate = {
|
|
599
|
-
kind: SIGNALING_KIND,
|
|
600
|
-
created_at: Math.floor(Date.now() / 1000),
|
|
601
|
-
tags,
|
|
602
|
-
content: '',
|
|
603
|
-
};
|
|
604
|
-
const event = await this.signer(eventTemplate);
|
|
605
|
-
await this.pool.publish(this.config.relays, event);
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
/**
|
|
609
|
-
* Force send a hello message (useful for testing after pool config changes)
|
|
610
|
-
*/
|
|
611
|
-
sendHello() {
|
|
612
|
-
if (!this.running)
|
|
613
|
-
return;
|
|
614
|
-
this.maybeSendHello();
|
|
615
|
-
}
|
|
616
|
-
maybeSendHello() {
|
|
617
|
-
if (!this.running)
|
|
618
|
-
return;
|
|
619
|
-
// Check if both pools are satisfied
|
|
620
|
-
const followsSatisfied = this.isPoolSatisfied('follows');
|
|
621
|
-
const otherSatisfied = this.isPoolSatisfied('other');
|
|
622
|
-
if (followsSatisfied && otherSatisfied) {
|
|
623
|
-
return;
|
|
624
|
-
}
|
|
625
|
-
this.sendSignaling({
|
|
626
|
-
type: 'hello',
|
|
627
|
-
peerId: this.myPeerId.uuid,
|
|
628
|
-
});
|
|
629
|
-
}
|
|
630
|
-
cleanupConnections() {
|
|
631
|
-
const now = Date.now();
|
|
632
|
-
const connectionTimeout = 15000; // 15 seconds to establish connection
|
|
633
|
-
for (const [peerIdStr, { peer }] of this.peers) {
|
|
634
|
-
const state = peer.state;
|
|
635
|
-
const isStale = state === 'new' && (now - peer.createdAt) > connectionTimeout;
|
|
636
|
-
if (state === 'failed' || state === 'closed' || state === 'disconnected' || isStale) {
|
|
637
|
-
this.log('Cleaning up', state, 'connection', isStale ? '(stale)' : '');
|
|
638
|
-
peer.close();
|
|
639
|
-
this.peers.delete(peerIdStr);
|
|
640
|
-
this.emit({ type: 'update' });
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
/**
|
|
645
|
-
* Get number of connected peers
|
|
646
|
-
*/
|
|
647
|
-
getConnectedCount() {
|
|
648
|
-
return Array.from(this.peers.values())
|
|
649
|
-
.filter(({ peer }) => peer.isConnected).length;
|
|
650
|
-
}
|
|
651
|
-
/**
|
|
652
|
-
* Get all peer statuses
|
|
653
|
-
*/
|
|
654
|
-
getPeers() {
|
|
655
|
-
return Array.from(this.peers.values()).map(({ peer, pool }) => ({
|
|
656
|
-
peerId: peer.peerId,
|
|
657
|
-
pubkey: peer.pubkey,
|
|
658
|
-
state: peer.state,
|
|
659
|
-
direction: peer.direction,
|
|
660
|
-
connectedAt: peer.connectedAt,
|
|
661
|
-
isSelf: peer.pubkey === this.myPeerId.pubkey,
|
|
662
|
-
pool,
|
|
663
|
-
isConnected: peer.isConnected, // Includes data channel state
|
|
664
|
-
}));
|
|
665
|
-
}
|
|
666
|
-
/**
|
|
667
|
-
* Disconnect all peers with a given pubkey
|
|
668
|
-
* Used when blocking a peer to immediately disconnect them
|
|
669
|
-
*/
|
|
670
|
-
disconnectPeerByPubkey(pubkey) {
|
|
671
|
-
for (const [peerIdStr, peerInfo] of this.peers) {
|
|
672
|
-
if (peerInfo.peer.pubkey === pubkey) {
|
|
673
|
-
this.log('Disconnecting blocked peer:', pubkey.slice(0, 8));
|
|
674
|
-
peerInfo.peer.close();
|
|
675
|
-
this.peers.delete(peerIdStr);
|
|
676
|
-
this.emit({ type: 'peer-disconnected', peerId: peerIdStr });
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
this.emit({ type: 'update' });
|
|
680
|
-
}
|
|
681
|
-
/**
|
|
682
|
-
* Get my peer ID (uuid part only)
|
|
683
|
-
*/
|
|
684
|
-
getMyPeerId() {
|
|
685
|
-
return this.myPeerId.uuid;
|
|
686
|
-
}
|
|
687
|
-
/**
|
|
688
|
-
* Check if store is satisfied (has enough connections in all pools)
|
|
689
|
-
*/
|
|
690
|
-
isSatisfied() {
|
|
691
|
-
return this.isPoolSatisfied('follows') && this.isPoolSatisfied('other');
|
|
692
|
-
}
|
|
693
|
-
/**
|
|
694
|
-
* Update peer classifier (e.g., when social graph updates)
|
|
695
|
-
*/
|
|
696
|
-
setPeerClassifier(classifier) {
|
|
697
|
-
this.peerClassifier = classifier;
|
|
698
|
-
// Re-classify existing peers (they keep their connections, just update pool assignment)
|
|
699
|
-
for (const [peerIdStr, peerInfo] of this.peers) {
|
|
700
|
-
const newPool = classifier(peerInfo.peer.pubkey);
|
|
701
|
-
if (newPool !== peerInfo.pool) {
|
|
702
|
-
this.log('Reclassified peer', peerIdStr.slice(0, 16), 'from', peerInfo.pool, 'to', newPool);
|
|
703
|
-
peerInfo.pool = newPool;
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
this.emit({ type: 'update' });
|
|
707
|
-
}
|
|
708
|
-
/**
|
|
709
|
-
* Update pool configuration (e.g., from settings)
|
|
710
|
-
*/
|
|
711
|
-
setPoolConfig(pools) {
|
|
712
|
-
const oldOtherMax = this.pools.other.maxConnections;
|
|
713
|
-
const oldFollowsMax = this.pools.follows.maxConnections;
|
|
714
|
-
this.pools = pools;
|
|
715
|
-
this.log('Pool config updated:', pools);
|
|
716
|
-
// Check if subscription mode needs to change
|
|
717
|
-
const newOtherMax = pools.other.maxConnections;
|
|
718
|
-
const newFollowsMax = pools.follows.maxConnections;
|
|
719
|
-
const subscriptionModeChanged = (oldOtherMax === 0) !== (newOtherMax === 0) ||
|
|
720
|
-
(oldFollowsMax === 0) !== (newFollowsMax === 0);
|
|
721
|
-
if (subscriptionModeChanged && this.running) {
|
|
722
|
-
const since = Math.floor((Date.now() - this.config.messageTimeout) / 1000);
|
|
723
|
-
const subHandler = {
|
|
724
|
-
onevent: (event) => {
|
|
725
|
-
this.handleSignalingEvent(event);
|
|
726
|
-
},
|
|
727
|
-
oneose: () => { },
|
|
728
|
-
};
|
|
729
|
-
this.setupHelloSubscription(since, subHandler);
|
|
730
|
-
}
|
|
731
|
-
// Existing connections remain, but new limits apply for future connections
|
|
732
|
-
this.emit({ type: 'update' });
|
|
733
|
-
}
|
|
734
|
-
/**
|
|
735
|
-
* Get current pool configuration
|
|
736
|
-
*/
|
|
737
|
-
getPoolConfig() {
|
|
738
|
-
return { ...this.pools };
|
|
739
|
-
}
|
|
740
|
-
/**
|
|
741
|
-
* Get fallback stores count
|
|
742
|
-
*/
|
|
743
|
-
getFallbackStoresCount() {
|
|
744
|
-
return this.config.fallbackStores.length;
|
|
745
|
-
}
|
|
746
|
-
/**
|
|
747
|
-
* Get WebRTC stats (aggregate and per-peer)
|
|
748
|
-
*/
|
|
749
|
-
getStats() {
|
|
750
|
-
// Aggregate stats from all peers + store-level stats
|
|
751
|
-
const aggregate = {
|
|
752
|
-
requestsSent: 0,
|
|
753
|
-
requestsReceived: 0,
|
|
754
|
-
responsesSent: 0,
|
|
755
|
-
responsesReceived: 0,
|
|
756
|
-
receiveErrors: 0,
|
|
757
|
-
blossomFetches: this.blossomFetches,
|
|
758
|
-
fragmentsSent: 0,
|
|
759
|
-
fragmentsReceived: 0,
|
|
760
|
-
fragmentTimeouts: 0,
|
|
761
|
-
reassembliesCompleted: 0,
|
|
762
|
-
bytesSent: 0,
|
|
763
|
-
bytesReceived: 0,
|
|
764
|
-
bytesForwarded: 0,
|
|
765
|
-
};
|
|
766
|
-
const perPeer = new Map();
|
|
767
|
-
for (const [peerIdStr, { peer, pool }] of this.peers) {
|
|
768
|
-
const peerStats = peer.getStats();
|
|
769
|
-
aggregate.requestsSent += peerStats.requestsSent;
|
|
770
|
-
aggregate.requestsReceived += peerStats.requestsReceived;
|
|
771
|
-
aggregate.responsesSent += peerStats.responsesSent;
|
|
772
|
-
aggregate.responsesReceived += peerStats.responsesReceived;
|
|
773
|
-
aggregate.receiveErrors += peerStats.receiveErrors;
|
|
774
|
-
aggregate.fragmentsSent += peerStats.fragmentsSent;
|
|
775
|
-
aggregate.fragmentsReceived += peerStats.fragmentsReceived;
|
|
776
|
-
aggregate.fragmentTimeouts += peerStats.fragmentTimeouts;
|
|
777
|
-
aggregate.reassembliesCompleted += peerStats.reassembliesCompleted;
|
|
778
|
-
aggregate.bytesSent += peerStats.bytesSent;
|
|
779
|
-
aggregate.bytesReceived += peerStats.bytesReceived;
|
|
780
|
-
aggregate.bytesForwarded += peerStats.bytesForwarded;
|
|
781
|
-
perPeer.set(peerIdStr, {
|
|
782
|
-
pubkey: peer.pubkey,
|
|
783
|
-
pool,
|
|
784
|
-
stats: peerStats,
|
|
785
|
-
});
|
|
786
|
-
}
|
|
787
|
-
return { aggregate, perPeer };
|
|
788
|
-
}
|
|
789
|
-
// Store interface implementation
|
|
790
|
-
async put(hash, data) {
|
|
791
|
-
// Write to local store if available
|
|
792
|
-
const success = this.config.localStore
|
|
793
|
-
? await this.config.localStore.put(hash, data)
|
|
794
|
-
: false;
|
|
795
|
-
// Send to any peers who have requested this hash
|
|
796
|
-
this.sendToInterestedPeers(hash, data);
|
|
797
|
-
// Fire-and-forget writes to fallback stores (e.g. blossom servers)
|
|
798
|
-
// Don't await - let them complete in background
|
|
799
|
-
for (const store of this.config.fallbackStores) {
|
|
800
|
-
store.put(hash, data).catch(() => {
|
|
801
|
-
// Silently ignore failures - fallback stores are best-effort
|
|
802
|
-
});
|
|
803
|
-
}
|
|
804
|
-
return success;
|
|
805
|
-
}
|
|
806
|
-
async get(hash) {
|
|
807
|
-
// Guard against undefined hash
|
|
808
|
-
if (!hash)
|
|
809
|
-
return null;
|
|
810
|
-
// Try local store first
|
|
811
|
-
if (this.config.localStore) {
|
|
812
|
-
const local = await this.config.localStore.get(hash);
|
|
813
|
-
if (local)
|
|
814
|
-
return local;
|
|
815
|
-
}
|
|
816
|
-
// Deduplicate: if there's already a pending request for this hash, wait for it
|
|
817
|
-
const hashHex = toHex(hash);
|
|
818
|
-
const pendingGet = this.pendingGets.get(hashHex);
|
|
819
|
-
if (pendingGet) {
|
|
820
|
-
return pendingGet;
|
|
821
|
-
}
|
|
822
|
-
// Create the actual fetch promise
|
|
823
|
-
const fetchPromise = this.fetchFromPeers(hash);
|
|
824
|
-
// Store it for deduplication
|
|
825
|
-
this.pendingGets.set(hashHex, fetchPromise);
|
|
826
|
-
// Clean up when done
|
|
827
|
-
try {
|
|
828
|
-
const result = await fetchPromise;
|
|
829
|
-
return result;
|
|
830
|
-
}
|
|
831
|
-
finally {
|
|
832
|
-
this.pendingGets.delete(hashHex);
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
/**
|
|
836
|
-
* Internal method to fetch data from peers (separated for deduplication)
|
|
837
|
-
*/
|
|
838
|
-
async fetchFromPeers(hash) {
|
|
839
|
-
// Get currently connected peers (prioritize follows pool)
|
|
840
|
-
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
|
-
}
|
|
878
|
-
}
|
|
879
|
-
// All WebRTC peers failed - try fallback stores in order
|
|
880
|
-
if (this.config.fallbackStores.length > 0) {
|
|
881
|
-
for (const store of this.config.fallbackStores) {
|
|
882
|
-
try {
|
|
883
|
-
const data = await store.get(hash);
|
|
884
|
-
if (data) {
|
|
885
|
-
this.blossomFetches++;
|
|
886
|
-
if (this.config.localStore) {
|
|
887
|
-
await this.config.localStore.put(hash, data);
|
|
888
|
-
}
|
|
889
|
-
return data;
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
catch (e) {
|
|
893
|
-
this.log('Fallback store error:', e);
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
// If running and either:
|
|
898
|
-
// 1. Not satisfied (still seeking more peers), OR
|
|
899
|
-
// 2. We haven't tried any peers yet (no peers connected, but might connect soon)
|
|
900
|
-
// Then add to pending reqs and wait for new peers
|
|
901
|
-
if (this.running && (!this.isSatisfied() || triedPeers.size === 0)) {
|
|
902
|
-
return this.waitForHash(hash, triedPeers);
|
|
903
|
-
}
|
|
904
|
-
return null;
|
|
905
|
-
}
|
|
906
|
-
/**
|
|
907
|
-
* Helper to create a delay promise
|
|
908
|
-
*/
|
|
909
|
-
delay(ms) {
|
|
910
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
911
|
-
}
|
|
912
|
-
/**
|
|
913
|
-
* Add hash to pending requests list and wait for it to be resolved by peers
|
|
914
|
-
* Also immediately tries any connected peers that weren't tried yet
|
|
915
|
-
*/
|
|
916
|
-
waitForHash(hash, triedPeers) {
|
|
917
|
-
return new Promise((resolve) => {
|
|
918
|
-
// Use longer timeout for pending requests - we need to wait for peers to connect
|
|
919
|
-
const reqTimeout = Math.max(this.config.requestTimeout * 6, 30000);
|
|
920
|
-
const timeout = setTimeout(() => {
|
|
921
|
-
this.removePendingReq(hash, req);
|
|
922
|
-
resolve(null);
|
|
923
|
-
}, reqTimeout);
|
|
924
|
-
const req = { resolve, timeout, triedPeers };
|
|
925
|
-
const existing = this.pendingReqs.get(hash);
|
|
926
|
-
if (existing) {
|
|
927
|
-
existing.push(req);
|
|
928
|
-
}
|
|
929
|
-
else {
|
|
930
|
-
this.pendingReqs.set(hash, [req]);
|
|
931
|
-
}
|
|
932
|
-
this.log('Added to pending reqs:', hash.slice(0, 16), 'tried', triedPeers.size, 'peers');
|
|
933
|
-
// Immediately try any connected peers that weren't tried yet
|
|
934
|
-
// This handles the race condition where peers connect while we're setting up the request
|
|
935
|
-
this.tryConnectedPeersForHash(hash);
|
|
936
|
-
});
|
|
937
|
-
}
|
|
938
|
-
/**
|
|
939
|
-
* Try all currently connected peers for a specific hash in the pending requests
|
|
940
|
-
*/
|
|
941
|
-
async tryConnectedPeersForHash(hash) {
|
|
942
|
-
const reqs = this.pendingReqs.get(hash);
|
|
943
|
-
if (!reqs || reqs.length === 0)
|
|
944
|
-
return;
|
|
945
|
-
// Get all connected peers
|
|
946
|
-
const connectedPeers = Array.from(this.peers.values())
|
|
947
|
-
.filter(({ peer }) => peer.isConnected)
|
|
948
|
-
.map(({ peer }) => peer);
|
|
949
|
-
for (const peer of connectedPeers) {
|
|
950
|
-
const peerIdStr = peer.peerId;
|
|
951
|
-
// Find requests that haven't tried this peer yet
|
|
952
|
-
const untried = reqs.filter(r => !r.triedPeers.has(peerIdStr));
|
|
953
|
-
if (untried.length === 0)
|
|
954
|
-
continue;
|
|
955
|
-
// Mark as tried
|
|
956
|
-
for (const r of untried) {
|
|
957
|
-
r.triedPeers.add(peerIdStr);
|
|
958
|
-
}
|
|
959
|
-
this.log('Trying pending req from connected peer:', hash.slice(0, 16));
|
|
960
|
-
const data = await peer.request(hash);
|
|
961
|
-
if (data) {
|
|
962
|
-
// Store locally
|
|
963
|
-
if (this.config.localStore) {
|
|
964
|
-
await this.config.localStore.put(hash, data);
|
|
965
|
-
}
|
|
966
|
-
// Resolve all waiting requests
|
|
967
|
-
const currentReqs = this.pendingReqs.get(hash);
|
|
968
|
-
if (currentReqs) {
|
|
969
|
-
for (const r of currentReqs) {
|
|
970
|
-
clearTimeout(r.timeout);
|
|
971
|
-
r.resolve(data);
|
|
972
|
-
}
|
|
973
|
-
this.pendingReqs.delete(hash);
|
|
974
|
-
}
|
|
975
|
-
this.log('Resolved pending req:', hash.slice(0, 16));
|
|
976
|
-
return;
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
/**
|
|
981
|
-
* Remove a pending request from the list
|
|
982
|
-
*/
|
|
983
|
-
removePendingReq(hash, req) {
|
|
984
|
-
const reqs = this.pendingReqs.get(hash);
|
|
985
|
-
if (!reqs)
|
|
986
|
-
return;
|
|
987
|
-
const idx = reqs.indexOf(req);
|
|
988
|
-
if (idx !== -1) {
|
|
989
|
-
reqs.splice(idx, 1);
|
|
990
|
-
if (reqs.length === 0) {
|
|
991
|
-
this.pendingReqs.delete(hash);
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
/**
|
|
996
|
-
* Try pending requests with a newly connected peer
|
|
997
|
-
*/
|
|
998
|
-
async tryPendingReqs(peer) {
|
|
999
|
-
const peerIdStr = peer.peerId;
|
|
1000
|
-
for (const [hash, reqs] of this.pendingReqs.entries()) {
|
|
1001
|
-
// Find requests that haven't tried this peer yet
|
|
1002
|
-
const untried = reqs.filter(r => !r.triedPeers.has(peerIdStr));
|
|
1003
|
-
if (untried.length === 0)
|
|
1004
|
-
continue;
|
|
1005
|
-
// Mark as tried
|
|
1006
|
-
for (const r of untried) {
|
|
1007
|
-
r.triedPeers.add(peerIdStr);
|
|
1008
|
-
}
|
|
1009
|
-
const data = await peer.request(hash);
|
|
1010
|
-
if (data) {
|
|
1011
|
-
// Store locally
|
|
1012
|
-
if (this.config.localStore) {
|
|
1013
|
-
await this.config.localStore.put(hash, data);
|
|
1014
|
-
}
|
|
1015
|
-
// Resolve all waiting requests
|
|
1016
|
-
for (const r of reqs) {
|
|
1017
|
-
clearTimeout(r.timeout);
|
|
1018
|
-
r.resolve(data);
|
|
1019
|
-
}
|
|
1020
|
-
this.pendingReqs.delete(hash);
|
|
1021
|
-
this.log('Resolved pending req:', hash.slice(0, 16));
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
async has(hash) {
|
|
1026
|
-
// Check local store
|
|
1027
|
-
if (this.config.localStore) {
|
|
1028
|
-
const hasLocal = await this.config.localStore.has(hash);
|
|
1029
|
-
if (hasLocal)
|
|
1030
|
-
return true;
|
|
1031
|
-
}
|
|
1032
|
-
// Could query peers, but for now just check locally
|
|
1033
|
-
return false;
|
|
1034
|
-
}
|
|
1035
|
-
async delete(hash) {
|
|
1036
|
-
// Only delete from local store
|
|
1037
|
-
if (this.config.localStore) {
|
|
1038
|
-
return this.config.localStore.delete(hash);
|
|
1039
|
-
}
|
|
1040
|
-
return false;
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
//# sourceMappingURL=store.js.map
|