nostr-over-bt 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +504 -0
- package/README.md +64 -0
- package/docs/API.md +84 -0
- package/docs/P2P_DISCOVERY.md +54 -0
- package/package.json +56 -0
- package/src/core/EventPackager.js +45 -0
- package/src/core/FeedIndex.js +74 -0
- package/src/core/FeedManager.js +158 -0
- package/src/core/IdentityManager.js +153 -0
- package/src/core/TransportManager.js +344 -0
- package/src/core/WoTManager.js +80 -0
- package/src/index.js +15 -0
- package/src/interfaces/ITransport.js +46 -0
- package/src/transport/BitTorrentTransport.js +134 -0
- package/src/transport/HybridTransport.js +57 -0
- package/src/transport/NostrTransport.js +29 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { EventPackager } from './EventPackager.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TransportManager coordinates the end-to-end flow of packaging an event,
|
|
5
|
+
* seeding it via BitTorrent, and publishing it to Nostr relays with
|
|
6
|
+
* injected BitTorrent metadata.
|
|
7
|
+
*/
|
|
8
|
+
export class TransportManager {
|
|
9
|
+
/**
|
|
10
|
+
* @param {HybridTransport} transport - The hybrid transport instance.
|
|
11
|
+
* @param {object|WoTManager} [options={}] - Options object or legacy WoTManager instance.
|
|
12
|
+
* @param {WoTManager} [options.wotManager] - Web of Trust manager.
|
|
13
|
+
* @param {FeedManager} [options.feedManager] - P2P Feed manager.
|
|
14
|
+
*/
|
|
15
|
+
constructor(transport, options = {}) {
|
|
16
|
+
this.transport = transport;
|
|
17
|
+
|
|
18
|
+
// Backward compatibility handling
|
|
19
|
+
if (options && typeof options.isFollowing === 'function') {
|
|
20
|
+
this.wotManager = options;
|
|
21
|
+
this.feedManager = null;
|
|
22
|
+
} else {
|
|
23
|
+
this.wotManager = options.wotManager || null;
|
|
24
|
+
this.feedManager = options.feedManager || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
this.packager = new EventPackager();
|
|
28
|
+
this.keyCache = new Map(); // nostrPubkey -> transportPubkey
|
|
29
|
+
this.magnetCache = new Map(); // eventId -> magnetUri
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves a Nostr Pubkey to its associated Transport Public Key.
|
|
34
|
+
* Tries cache, then Relay lookup (Kind 30078).
|
|
35
|
+
*
|
|
36
|
+
* @param {string} nostrPubkey - The user's hex pubkey.
|
|
37
|
+
* @returns {Promise<string|null>} - The Transport Public Key (hex).
|
|
38
|
+
*/
|
|
39
|
+
async resolveTransportKey(nostrPubkey) {
|
|
40
|
+
if (this.keyCache.has(nostrPubkey)) return this.keyCache.get(nostrPubkey);
|
|
41
|
+
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const filter = {
|
|
44
|
+
authors: [nostrPubkey],
|
|
45
|
+
kinds: [30078],
|
|
46
|
+
'#d': ['nostr-over-bt-identity'],
|
|
47
|
+
limit: 1
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const timeout = setTimeout(() => resolve(null), 5000);
|
|
51
|
+
|
|
52
|
+
this.transport.nostr.subscribe(filter, (event) => {
|
|
53
|
+
if (event.content && event.content.length === 64) {
|
|
54
|
+
clearTimeout(timeout);
|
|
55
|
+
this.keyCache.set(nostrPubkey, event.content);
|
|
56
|
+
resolve(event.content);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Bootstraps the Web of Trust list directly from a user's P2P Feed.
|
|
64
|
+
* This allows a client to "Sync Follows" without a relay.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} transportPubkey - The target user's P2P address.
|
|
67
|
+
* @returns {Promise<void>}
|
|
68
|
+
*/
|
|
69
|
+
async bootstrapWoTP2P(transportPubkey) {
|
|
70
|
+
if (!this.wotManager) throw new Error("WoTManager not initialized.");
|
|
71
|
+
|
|
72
|
+
console.log(`TransportManager: Bootstrapping WoT from P2P address ${transportPubkey}...`);
|
|
73
|
+
|
|
74
|
+
const events = await this.subscribeP2P(transportPubkey);
|
|
75
|
+
|
|
76
|
+
// Find latest Kind 3 (Contact List) in the P2P feed
|
|
77
|
+
const contactList = events.find(e => e.kind === 3);
|
|
78
|
+
if (contactList) {
|
|
79
|
+
const fullEventJson = await this.transport.bt.fetch(contactList.magnet);
|
|
80
|
+
const fullEvent = JSON.parse(fullEventJson.toString());
|
|
81
|
+
|
|
82
|
+
if (this.wotManager._parseContactList) {
|
|
83
|
+
this.wotManager._parseContactList(fullEvent);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Recursively syncs the WoT graph via P2P.
|
|
90
|
+
* 1. Fetches follow lists for Degree 1.
|
|
91
|
+
* 2. For each discovered user, fetches their follow list (Degree 2).
|
|
92
|
+
* 3. Continues until maxDegree is reached.
|
|
93
|
+
*
|
|
94
|
+
* @returns {Promise<void>}
|
|
95
|
+
*/
|
|
96
|
+
async syncWoTRecursiveP2P() {
|
|
97
|
+
if (!this.wotManager) throw new Error("WoTManager not initialized.");
|
|
98
|
+
|
|
99
|
+
console.log(`TransportManager: Starting recursive WoT sync (Max Degree: ${this.wotManager.maxDegree})...`);
|
|
100
|
+
|
|
101
|
+
for (let d = 1; d < this.wotManager.maxDegree; d++) {
|
|
102
|
+
const currentNodes = this.wotManager.getPubkeysAtDegree(d);
|
|
103
|
+
console.log(`TransportManager: Level ${d} has ${currentNodes.length} nodes. Fetching their follows (Level ${d+1})...`);
|
|
104
|
+
|
|
105
|
+
const promises = currentNodes.map(async (pk) => {
|
|
106
|
+
try {
|
|
107
|
+
const tpk = await this.resolveTransportKey(pk);
|
|
108
|
+
if (tpk) {
|
|
109
|
+
const events = await this.subscribeP2P(tpk);
|
|
110
|
+
const contactList = events.find(e => e.kind === 3);
|
|
111
|
+
if (contactList) {
|
|
112
|
+
const fullEventJson = await this.transport.bt.fetch(contactList.magnet);
|
|
113
|
+
const fullEvent = JSON.parse(fullEventJson.toString());
|
|
114
|
+
this.wotManager._parseContactList(fullEvent, d + 1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// console.warn(`TransportManager: Failed to fetch follows for ${pk}`, e.message);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await Promise.all(promises);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Subscribes to all events from users in the WoT list via P2P.
|
|
128
|
+
* @returns {Promise<Array>} - Flattened list of latest events from all followed users.
|
|
129
|
+
*/
|
|
130
|
+
async subscribeFollowsP2P() {
|
|
131
|
+
if (!this.wotManager) throw new Error("WoTManager not initialized.");
|
|
132
|
+
if (!this.feedManager) throw new Error("FeedManager not initialized.");
|
|
133
|
+
|
|
134
|
+
const followPubkeys = Array.from(this.wotManager.follows.keys());
|
|
135
|
+
console.log(`TransportManager: Resolving P2P feeds for ${followPubkeys.length} follows (all degrees)...`);
|
|
136
|
+
|
|
137
|
+
const allEvents = [];
|
|
138
|
+
const resolvePromises = followPubkeys.map(async (npk) => {
|
|
139
|
+
const tpk = await this.resolveTransportKey(npk);
|
|
140
|
+
if (tpk) {
|
|
141
|
+
const events = await this.subscribeP2P(tpk);
|
|
142
|
+
allEvents.push(...events);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await Promise.all(resolvePromises);
|
|
147
|
+
return allEvents.sort((a, b) => b.ts - a.ts);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Publishes an event purely via P2P (DHT + Swarm), bypassing relays.
|
|
152
|
+
* Requires FeedManager to be initialized.
|
|
153
|
+
*
|
|
154
|
+
* @param {object} event - The signed Nostr event.
|
|
155
|
+
* @returns {Promise<string>} - The magnet URI of the updated Index.
|
|
156
|
+
*/
|
|
157
|
+
async publishP2P(event) {
|
|
158
|
+
if (!this.feedManager) throw new Error("FeedManager not initialized.");
|
|
159
|
+
|
|
160
|
+
// 1. Seed the event content itself
|
|
161
|
+
const eventBuffer = this.packager.package(event);
|
|
162
|
+
const eventFilename = this.packager.getFilename(event);
|
|
163
|
+
const eventMagnet = await this.transport.bt.publish({ buffer: eventBuffer, filename: eventFilename });
|
|
164
|
+
|
|
165
|
+
// 2. Update the P2P Feed Index
|
|
166
|
+
const indexMagnet = await this.feedManager.updateFeed(event, eventMagnet);
|
|
167
|
+
|
|
168
|
+
return indexMagnet;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Subscribes to a user's P2P feed (resolves DHT pointer).
|
|
173
|
+
* @param {string} transportPubkey - The Transport Public Key (hex).
|
|
174
|
+
* @returns {Promise<Array>} - List of recent events (metadata/pointers).
|
|
175
|
+
*/
|
|
176
|
+
async subscribeP2P(transportPubkey) {
|
|
177
|
+
if (!this.feedManager) throw new Error("FeedManager not initialized.");
|
|
178
|
+
|
|
179
|
+
// 1. Resolve DHT Pointer
|
|
180
|
+
let pointer;
|
|
181
|
+
try {
|
|
182
|
+
pointer = await this.feedManager.resolveFeedPointer(transportPubkey);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
throw new Error(`DHT resolution failed: ${e.message}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!pointer) return [];
|
|
188
|
+
|
|
189
|
+
// 2. Fetch Index Torrent
|
|
190
|
+
const magnet = `magnet:?xt=urn:btih:${pointer.infoHash}`;
|
|
191
|
+
try {
|
|
192
|
+
const indexBuf = await this.transport.bt.fetch(magnet);
|
|
193
|
+
const data = JSON.parse(indexBuf.toString());
|
|
194
|
+
return data.items || [];
|
|
195
|
+
} catch (e) {
|
|
196
|
+
throw new Error(`Failed to fetch or parse P2P index: ${e.message}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Handles an incoming event from a relay.
|
|
202
|
+
* Checks if the event should be auto-seeded based on WoT rules.
|
|
203
|
+
*
|
|
204
|
+
* @param {object} event - The incoming Nostr event.
|
|
205
|
+
* @returns {Promise<string|null>} - Magnet URI if seeded, null otherwise.
|
|
206
|
+
*/
|
|
207
|
+
async handleIncomingEvent(event) {
|
|
208
|
+
if (this.shouldSeed(event)) {
|
|
209
|
+
console.log(`TransportManager: Auto-seeding event ${event.id} from followed user ${event.pubkey}`);
|
|
210
|
+
return await this.reseedEvent(event);
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Determines if an event should be seeded.
|
|
217
|
+
* @param {object} event
|
|
218
|
+
* @returns {boolean}
|
|
219
|
+
*/
|
|
220
|
+
shouldSeed(event) {
|
|
221
|
+
if (!this.wotManager) return false;
|
|
222
|
+
return this.wotManager.isFollowing(event.pubkey);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Publishes a PRE-SIGNED event.
|
|
227
|
+
* Enforces "Deferred Seeding": Relay publish must succeed before Seeding begins.
|
|
228
|
+
* Expects a standard Nostr event object (compatible with nostr-tools).
|
|
229
|
+
*
|
|
230
|
+
* @param {object} signedEvent - The signed Nostr event.
|
|
231
|
+
* @param {Array<object>} [mediaFiles=[]] - Optional list of files to seed { buffer, filename }.
|
|
232
|
+
* @returns {Promise<object>}
|
|
233
|
+
*/
|
|
234
|
+
async publish(signedEvent, mediaFiles = []) {
|
|
235
|
+
let relayStatus;
|
|
236
|
+
try {
|
|
237
|
+
relayStatus = await this.transport.nostr.publish(signedEvent);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
console.error("TransportManager: Relay publish failed. Aborting seed.", error);
|
|
240
|
+
throw new Error("Relay publish failed. Seeding aborted.");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const eventBuffer = this.packager.package(signedEvent);
|
|
244
|
+
const eventFilename = this.packager.getFilename(signedEvent);
|
|
245
|
+
|
|
246
|
+
const seedPromises = [
|
|
247
|
+
this.transport.bt.publish({ buffer: eventBuffer, filename: eventFilename })
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
for (const file of mediaFiles) {
|
|
251
|
+
if (file.buffer && file.filename) {
|
|
252
|
+
seedPromises.push(this.transport.bt.publish(file));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const magnetUris = await Promise.all(seedPromises);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
magnetUri: magnetUris[0],
|
|
260
|
+
mediaMagnets: magnetUris.slice(1),
|
|
261
|
+
relayStatus
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Reseeds an event that was fetched from a relay.
|
|
267
|
+
* Optimization: Uses memory cache and background processing to prevent blocking.
|
|
268
|
+
*
|
|
269
|
+
* @param {object} event - The Nostr event to seed.
|
|
270
|
+
* @param {boolean} [background=true] - If true, resolves instantly while seeding in background.
|
|
271
|
+
* @returns {Promise<string>} - The magnet URI (or cached URI).
|
|
272
|
+
*/
|
|
273
|
+
async reseedEvent(event, background = true) {
|
|
274
|
+
if (!event || !event.id) throw new Error("Invalid event for reseeding.");
|
|
275
|
+
|
|
276
|
+
// 1. Instant Cache/Tag Check
|
|
277
|
+
if (this.magnetCache.has(event.id)) return this.magnetCache.get(event.id);
|
|
278
|
+
const existingBt = event.tags?.find(t => t[0] === 'bt');
|
|
279
|
+
if (existingBt && existingBt[1]) {
|
|
280
|
+
this.magnetCache.set(event.id, existingBt[1]);
|
|
281
|
+
return existingBt[1];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 2. Perform Hashing and DHT Announcement
|
|
285
|
+
const performSeed = async () => {
|
|
286
|
+
try {
|
|
287
|
+
const buffer = this.packager.package(event);
|
|
288
|
+
const filename = this.packager.getFilename(event);
|
|
289
|
+
const magnetUri = await this.transport.bt.publish({ buffer, filename });
|
|
290
|
+
this.magnetCache.set(event.id, magnetUri);
|
|
291
|
+
|
|
292
|
+
// If we have a FeedManager (Relay mode), update the global index
|
|
293
|
+
if (this.feedManager) {
|
|
294
|
+
await this.feedManager.updateFeed(event, magnetUri);
|
|
295
|
+
}
|
|
296
|
+
return magnetUri;
|
|
297
|
+
} catch (err) {
|
|
298
|
+
console.error(`TransportManager: Background seeding failed for ${event.id}:`, err.message);
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
if (background) {
|
|
304
|
+
// Kick off process but don't await result
|
|
305
|
+
performSeed().catch(() => {});
|
|
306
|
+
// Return a "likely" magnet if we can calculate it, or just resolve null for now
|
|
307
|
+
// For now, we return a placeholder or the ID to signify "queued"
|
|
308
|
+
return `queued:${event.id}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return await performSeed();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Fetches media associated with an event.
|
|
316
|
+
* Strategy: Try BitTorrent (Magnet) first. If fails/timeout, fallback to HTTP URL.
|
|
317
|
+
*
|
|
318
|
+
* @param {object} event - The Nostr event containing media tags.
|
|
319
|
+
* @returns {Promise<Buffer|Stream>}
|
|
320
|
+
*/
|
|
321
|
+
async fetchMedia(event) {
|
|
322
|
+
const btTag = event.tags.find(t => t[0] === 'bt');
|
|
323
|
+
const urlTag = event.tags.find(t => t[0] === 'url' || t[0] === 'image' || t[0] === 'video');
|
|
324
|
+
|
|
325
|
+
const magnet = btTag ? btTag[1] : null;
|
|
326
|
+
const url = urlTag ? urlTag[1] : null;
|
|
327
|
+
|
|
328
|
+
if (magnet) {
|
|
329
|
+
try {
|
|
330
|
+
console.log(`TransportManager: Attempting to fetch media via BitTorrent: ${magnet}`);
|
|
331
|
+
return await this.transport.bt.fetch(magnet);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
console.warn("TransportManager: BT fetch failed, trying HTTP fallback...", err);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (url) {
|
|
338
|
+
console.log(`TransportManager: Fetching media via HTTP: ${url}`);
|
|
339
|
+
return "mock-http-data";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
throw new Error("No media found (neither BT nor HTTP).");
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WoTManager handles the "Web of Trust" logic.
|
|
3
|
+
* It maintains a list of users that the local user follows,
|
|
4
|
+
* allowing the TransportManager to selectively seed content.
|
|
5
|
+
*/
|
|
6
|
+
export class WoTManager {
|
|
7
|
+
constructor(nostrTransport) {
|
|
8
|
+
this.nostr = nostrTransport;
|
|
9
|
+
this.follows = new Map(); // pubkey -> { degree, lastSynced }
|
|
10
|
+
this.myPubkey = null;
|
|
11
|
+
this.maxDegree = 2; // Default to follows of follows
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Refreshes the follow list for the given user.
|
|
16
|
+
* @param {string} userPubkey - The public key of the local user.
|
|
17
|
+
* @returns {Promise<void>}
|
|
18
|
+
*/
|
|
19
|
+
async refreshFollows(userPubkey) {
|
|
20
|
+
this.myPubkey = userPubkey;
|
|
21
|
+
this.follows.set(userPubkey, { degree: 0, lastSynced: Date.now() });
|
|
22
|
+
console.log(`WoTManager: Refreshing primary follows for ${userPubkey}...`);
|
|
23
|
+
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
const filter = { authors: [userPubkey], kinds: [3], limit: 1 };
|
|
26
|
+
const timeout = setTimeout(() => resolve(), 5000);
|
|
27
|
+
|
|
28
|
+
this.nostr.subscribe(filter, (event) => {
|
|
29
|
+
if (event.kind === 3) {
|
|
30
|
+
clearTimeout(timeout);
|
|
31
|
+
this._parseContactList(event, 1);
|
|
32
|
+
resolve();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Adds follows from a specific user at a specific degree.
|
|
40
|
+
* @param {object} event - Kind 3 event.
|
|
41
|
+
* @param {number} degree - Depth in the graph.
|
|
42
|
+
*/
|
|
43
|
+
_parseContactList(event, degree) {
|
|
44
|
+
if (degree > this.maxDegree) return;
|
|
45
|
+
|
|
46
|
+
if (event.tags) {
|
|
47
|
+
for (const tag of event.tags) {
|
|
48
|
+
if (tag[0] === 'p') {
|
|
49
|
+
const pk = tag[1];
|
|
50
|
+
// Only add if not already present or if we found a shorter path
|
|
51
|
+
const existing = this.follows.get(pk);
|
|
52
|
+
if (!existing || existing.degree > degree) {
|
|
53
|
+
this.follows.set(pk, { degree, lastSynced: 0 });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
console.log(`WoTManager: Graph expanded. Total nodes: ${this.follows.size}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Checks if a pubkey is within the trusted graph.
|
|
63
|
+
* @param {string} pubkey
|
|
64
|
+
* @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
isFollowing(pubkey) {
|
|
67
|
+
return this.follows.has(pubkey);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Returns all pubkeys at a specific degree.
|
|
72
|
+
* @param {number} degree
|
|
73
|
+
* @returns {Array<string>}
|
|
74
|
+
*/
|
|
75
|
+
getPubkeysAtDegree(degree) {
|
|
76
|
+
return Array.from(this.follows.entries())
|
|
77
|
+
.filter(([_, data]) => data.degree === degree)
|
|
78
|
+
.map(([pk, _]) => pk);
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Core
|
|
2
|
+
export { TransportManager } from './core/TransportManager.js';
|
|
3
|
+
export { IdentityManager } from './core/IdentityManager.js';
|
|
4
|
+
export { WoTManager } from './core/WoTManager.js';
|
|
5
|
+
export { FeedManager } from './core/FeedManager.js';
|
|
6
|
+
export { EventPackager } from './core/EventPackager.js';
|
|
7
|
+
export { FeedIndex } from './core/FeedIndex.js';
|
|
8
|
+
|
|
9
|
+
// Transports
|
|
10
|
+
export { BitTorrentTransport } from './transport/BitTorrentTransport.js';
|
|
11
|
+
export { NostrTransport } from './transport/NostrTransport.js';
|
|
12
|
+
export { HybridTransport } from './transport/HybridTransport.js';
|
|
13
|
+
|
|
14
|
+
// Interfaces
|
|
15
|
+
export { ITransport } from './interfaces/ITransport.js';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract Base Class for Transport Layers.
|
|
3
|
+
* Enforces implementation of core transport methods.
|
|
4
|
+
*/
|
|
5
|
+
export class ITransport {
|
|
6
|
+
constructor() {
|
|
7
|
+
if (this.constructor === ITransport) {
|
|
8
|
+
throw new Error("Abstract classes can't be instantiated.");
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Connect to the transport network.
|
|
14
|
+
* @returns {Promise<void>}
|
|
15
|
+
*/
|
|
16
|
+
async connect() {
|
|
17
|
+
throw new Error("Method 'connect()' must be implemented.");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Disconnect from the transport network.
|
|
22
|
+
* @returns {Promise<void>}
|
|
23
|
+
*/
|
|
24
|
+
async disconnect() {
|
|
25
|
+
throw new Error("Method 'disconnect()' must be implemented.");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Publish an event.
|
|
30
|
+
* @param {object} _event - The Nostr event object.
|
|
31
|
+
* @returns {Promise<string>} - Returns the ID or status of the published event.
|
|
32
|
+
*/
|
|
33
|
+
async publish(_event) {
|
|
34
|
+
throw new Error("Method 'publish()' must be implemented.");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Subscribe to events.
|
|
39
|
+
* @param {object} _filter - The subscription filter.
|
|
40
|
+
* @param {function} _onEvent - Callback when an event is received.
|
|
41
|
+
* @returns {Promise<void>}
|
|
42
|
+
*/
|
|
43
|
+
async subscribe(_filter, _onEvent) {
|
|
44
|
+
throw new Error("Method 'subscribe()' must be implemented.");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { ITransport } from '../interfaces/ITransport.js';
|
|
2
|
+
import WebTorrent from 'webtorrent';
|
|
3
|
+
|
|
4
|
+
export class BitTorrentTransport extends ITransport {
|
|
5
|
+
constructor(options = {}) {
|
|
6
|
+
super();
|
|
7
|
+
this.announce = options.announce || [];
|
|
8
|
+
this.client = new WebTorrent({
|
|
9
|
+
...options,
|
|
10
|
+
dht: options.dht === undefined ? true : options.dht,
|
|
11
|
+
tracker: options.tracker !== false,
|
|
12
|
+
webSeeds: options.webSeeds !== false
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Resilience: Prevent memory leak warnings in complex simulations
|
|
16
|
+
this.client.setMaxListeners(100);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async connect() {
|
|
20
|
+
if (this.client.destroyed) throw new Error("Client was destroyed.");
|
|
21
|
+
console.log(`BitTorrentTransport: Online (DHT: ${this.client.dht ? 'Enabled' : 'Disabled'}).`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async disconnect() {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
console.log("BitTorrentTransport: Shutting down client...");
|
|
27
|
+
this.client.destroy((err) => {
|
|
28
|
+
if (err) console.error("Error during BT shutdown:", err.message);
|
|
29
|
+
resolve();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Seeds data via BitTorrent.
|
|
36
|
+
* @param {object} data - { buffer, filename }
|
|
37
|
+
* @returns {Promise<string>} - The magnet URI.
|
|
38
|
+
*/
|
|
39
|
+
async publish(data) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const { buffer, filename } = data;
|
|
42
|
+
|
|
43
|
+
const opts = { name: filename };
|
|
44
|
+
if (this.announce.length > 0) {
|
|
45
|
+
opts.announce = this.announce;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.client.seed(buffer, opts, (torrent) => {
|
|
49
|
+
console.log(`BitTorrentTransport: Seeding ${filename}. Magnet: ${torrent.magnetURI}`);
|
|
50
|
+
resolve(torrent.magnetURI);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Error handling for the client
|
|
54
|
+
this.client.on('error', (err) => {
|
|
55
|
+
reject(err);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fetches a file via BitTorrent.
|
|
62
|
+
* @param {string} magnetUri
|
|
63
|
+
* @returns {Promise<Buffer>}
|
|
64
|
+
*/
|
|
65
|
+
async fetch(magnetUri) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
// Set a timeout for the fetch
|
|
68
|
+
const timeout = setTimeout(() => {
|
|
69
|
+
// this.client.remove(magnetUri); // Cleanup?
|
|
70
|
+
reject(new Error("BitTorrent fetch timed out"));
|
|
71
|
+
}, 5000); // 5s timeout for demo
|
|
72
|
+
|
|
73
|
+
this.client.add(magnetUri, (torrent) => {
|
|
74
|
+
// Assume single file for simplicity
|
|
75
|
+
const file = torrent.files[0];
|
|
76
|
+
if (!file) {
|
|
77
|
+
clearTimeout(timeout);
|
|
78
|
+
reject(new Error("No files in torrent"));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
file.getBuffer((err, buffer) => {
|
|
83
|
+
clearTimeout(timeout);
|
|
84
|
+
if (err) reject(err);
|
|
85
|
+
else resolve(buffer);
|
|
86
|
+
|
|
87
|
+
// Optional: Destroy torrent after fetch if we don't want to seed
|
|
88
|
+
// torrent.destroy();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Waits for the DHT to bootstrap and find at least one node.
|
|
96
|
+
* @param {number} [timeout=10000]
|
|
97
|
+
* @returns {Promise<void>}
|
|
98
|
+
*/
|
|
99
|
+
async waitForDHT(timeout = 10000) {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const dht = this.getDHT();
|
|
102
|
+
if (!dht) return resolve(); // No DHT, no wait
|
|
103
|
+
|
|
104
|
+
if (dht.nodes.length > 0) return resolve();
|
|
105
|
+
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
dht.removeListener('node', onNode);
|
|
108
|
+
reject(new Error("DHT bootstrap timed out."));
|
|
109
|
+
}, timeout);
|
|
110
|
+
|
|
111
|
+
const onNode = () => {
|
|
112
|
+
if (dht.nodes.length > 0) {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
dht.removeListener('node', onNode);
|
|
115
|
+
resolve();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
dht.on('node', onNode);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async subscribe(filter, _onEvent) {
|
|
124
|
+
console.log("BitTorrentTransport: Listening for DHT matches", filter);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Returns the underlying DHT instance.
|
|
129
|
+
* @returns {object}
|
|
130
|
+
*/
|
|
131
|
+
getDHT() {
|
|
132
|
+
return this.client.dht;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ITransport } from '../interfaces/ITransport.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HybridTransport coordinates communication between the Nostr network (Relays)
|
|
5
|
+
* and the BitTorrent network (DHT/Swarm).
|
|
6
|
+
* It implements the ITransport interface by delegating to specific transport instances.
|
|
7
|
+
*/
|
|
8
|
+
export class HybridTransport extends ITransport {
|
|
9
|
+
/**
|
|
10
|
+
* @param {ITransport} nostrTransport - Transport for Nostr Relays.
|
|
11
|
+
* @param {ITransport} btTransport - Transport for BitTorrent.
|
|
12
|
+
*/
|
|
13
|
+
constructor(nostrTransport, btTransport) {
|
|
14
|
+
super();
|
|
15
|
+
this.nostr = nostrTransport;
|
|
16
|
+
this.bt = btTransport;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Connects both transports.
|
|
21
|
+
* @returns {Promise<void>}
|
|
22
|
+
*/
|
|
23
|
+
async connect() {
|
|
24
|
+
await Promise.all([this.nostr.connect(), this.bt.connect()]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Disconnects both transports.
|
|
29
|
+
* @returns {Promise<void>}
|
|
30
|
+
*/
|
|
31
|
+
async disconnect() {
|
|
32
|
+
await Promise.all([this.nostr.disconnect(), this.bt.disconnect()]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Publishes to both networks.
|
|
37
|
+
* @param {object} event
|
|
38
|
+
* @returns {Promise<object>} - { relayId, magnet }
|
|
39
|
+
*/
|
|
40
|
+
async publish(event) {
|
|
41
|
+
// Hybrid logic placeholder
|
|
42
|
+
const relayId = await this.nostr.publish(event);
|
|
43
|
+
const magnet = await this.bt.publish(event);
|
|
44
|
+
return { relayId, magnet };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Subscribes to both networks (delegated).
|
|
49
|
+
* @param {object} filter
|
|
50
|
+
* @param {function} onEvent
|
|
51
|
+
* @returns {Promise<void>}
|
|
52
|
+
*/
|
|
53
|
+
async subscribe(filter, onEvent) {
|
|
54
|
+
await this.nostr.subscribe(filter, onEvent);
|
|
55
|
+
await this.bt.subscribe(filter, onEvent);
|
|
56
|
+
}
|
|
57
|
+
}
|