swarmwire-lib 0.0.0-stage → 0.2.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.
@@ -0,0 +1,975 @@
1
+ /**
2
+ * A swarmwire node: serve content you already have, download content you want.
3
+ *
4
+ * It takes a swarm — anything with `join(topic, opts)` (returning a session with `destroy()`) and a `connection` event —
5
+ * so the same code runs against Hyperswarm and against the deterministic test network.
6
+ *
7
+ * Scheduling is deliberately plain for now: ask every peer that has the content, keep a small
8
+ * window of requests in flight with each, take whichever groups come back. Rarest-first,
9
+ * measured queue depths and peer banning are M2, and the harness is where they will be proved.
10
+ */
11
+
12
+ import { EventEmitter } from 'events'
13
+ import b4a from 'b4a'
14
+
15
+ import { bao } from './blake3.js'
16
+
17
+ import { ContentSource, MemorySource } from './source.js'
18
+ import { encodeManifest, decodeManifest, totalSize } from './collection.js'
19
+ import { Destination } from './destination.js'
20
+ import { discoveryTopic, formatLink, parseLink } from './address.js'
21
+ import { DEFAULT_GROUP_LOG, groupCount } from './tree.js'
22
+ import { openChannel, VERSION } from './wire.js'
23
+ import { rarestFirst } from './select.js'
24
+ import { Holdings } from './holdings.js'
25
+ import { Listing } from './listing.js'
26
+ import { systemTimers } from './timers.js'
27
+ import * as fsx from './fs.js'
28
+
29
+ /**
30
+ * Requests in flight per peer, to begin with.
31
+ *
32
+ * No fixed number is right. Keeping a link busy needs roughly (2 x latency x bandwidth) /
33
+ * group size requests outstanding: about 20 for a nearby link, 1600 for a fast distant one —
34
+ * `npm run bench` measures both. So the window starts here and adapts upwards.
35
+ *
36
+ * 32 is where the ramp stops being the bottleneck: starting at 8 costs 1.46x against an
37
+ * oracle-tuned fixed window on a distant link, 32 costs 1.25x, and 64 costs 1.19x for twice
38
+ * the memory. 32 outstanding requests is half a megabyte in flight per peer.
39
+ */
40
+ const WINDOW = 32
41
+
42
+ /**
43
+ * The most requests to have outstanding with one peer: 512 groups is 8 MiB of memory at the
44
+ * default group size, which is as much as one peer should be able to reserve.
45
+ */
46
+ const WINDOW_MAX = 512
47
+
48
+ /** Never go below this: a peer that keeps failing still deserves a chance to answer. */
49
+ const WINDOW_MIN = 4
50
+
51
+ /** How long to wait for a group before assuming the peer is not going to send it. */
52
+ const REQUEST_TIMEOUT = 15000
53
+
54
+ /** Timeouts a peer gets before it stops being asked. */
55
+ const MAX_TIMEOUTS = 3
56
+
57
+ /** Passing options makes `join` return a session that leaves only itself. */
58
+ const ANNOUNCE = { server: true, client: true }
59
+
60
+ /**
61
+ * Emits `peer-connected` and `peer-disconnected`, each with `{ id }`, for peers that completed
62
+ * the swarmwire handshake. `peerCount` and `counts(link)` read the same state.
63
+ */
64
+ export class Swarmwire extends EventEmitter {
65
+ /**
66
+ * @param {{swarm: object, groupLog?: number, timers?: object, requestTimeout?: number,
67
+ * window?: number}} opts
68
+ */
69
+ constructor ({
70
+ swarm,
71
+ groupLog = DEFAULT_GROUP_LOG,
72
+ timers = systemTimers,
73
+ requestTimeout = REQUEST_TIMEOUT,
74
+ window = WINDOW
75
+ }) {
76
+ super()
77
+ this.swarm = swarm
78
+ this.groupLog = groupLog
79
+ this.timers = timers
80
+ this.requestTimeout = requestTimeout
81
+ this.window = window
82
+ this.serving = new Map() // root → ContentSource
83
+ this.downloads = new Map() // root → Download
84
+ this.peers = new Map() // peer id → Peer
85
+ this.holdings = new Holdings({ groupLog }) // what those peers say they hold
86
+ this.lists = new Map() // folder root → its file entries, once known
87
+ this._listings = new Map() // folder root → the list read in progress
88
+ this._sessions = new Map() // served root → its announce session, ended by unserve
89
+
90
+ this._onconnection = (stream, info) => this._onPeer(stream, info)
91
+ this.swarm.on('connection', this._onconnection)
92
+ }
93
+
94
+ /**
95
+ * Share a file that already exists on disk. Nothing is copied, nothing is written next to
96
+ * it: the file is hashed where it lies and served from there.
97
+ *
98
+ * @param {string} path
99
+ * @param {{secret?: string|null, name?: string|null}} [opts]
100
+ * @returns {Promise<{root: string, size: number, link: string}>}
101
+ */
102
+ async serve (path, { secret = null, name = basename(path) } = {}) {
103
+ const source = await ContentSource.open(path, { groupLog: this.groupLog })
104
+
105
+ const existing = this.serving.get(source.root)
106
+ if (existing && existing !== source) await source.close()
107
+ else this.serving.set(source.root, source)
108
+ this._watch(source.root, source.size)
109
+
110
+ // Wait for the announce to actually land. Until it has, a peer looking this content up
111
+ // finds nobody, which is indistinguishable from "that link does not work".
112
+ const discovery = this._announce(source.root, discoveryTopic(source.root, secret))
113
+ if (typeof discovery?.flushed === 'function') await discovery.flushed()
114
+
115
+ return {
116
+ root: source.root,
117
+ size: source.size,
118
+ link: formatLink({ root: source.root, size: source.size, name, secret })
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Share a whole folder under one link.
124
+ *
125
+ * Every file is hashed where it lies and a manifest naming them is served from memory, so
126
+ * sharing a folder does not add anything to it. The link addresses the manifest; the files
127
+ * are fetched over the same connections afterwards, so a thousand files still cost one
128
+ * announce.
129
+ *
130
+ * @param {string} dir
131
+ * @param {{secret?: string|null, name?: string|null}} [opts]
132
+ * @returns {Promise<{root: string, size: number, files: number, link: string}>}
133
+ */
134
+ async serveCollection (dir, { secret = null, name = basename(dir) } = {}) {
135
+ const paths = await fsx.list(dir)
136
+ const entries = []
137
+
138
+ for (const relative of paths) {
139
+ const source = await ContentSource.open(join(dir, relative), { groupLog: this.groupLog })
140
+ if (!this.serving.has(source.root)) this.serving.set(source.root, source)
141
+ else await source.close()
142
+
143
+ entries.push({ path: relative, root: source.root, size: source.size })
144
+ this._watch(source.root, source.size)
145
+ }
146
+
147
+ const manifest = MemorySource.from(encodeManifest(entries), { groupLog: this.groupLog })
148
+ this.serving.set(manifest.root, manifest)
149
+ this.lists.set(manifest.root, entries)
150
+ this._watch(manifest.root, manifest.size)
151
+
152
+ const discovery = this._announce(manifest.root, discoveryTopic(manifest.root, secret))
153
+ if (typeof discovery?.flushed === 'function') await discovery.flushed()
154
+
155
+ return {
156
+ root: manifest.root,
157
+ size: totalSize(entries),
158
+ files: entries.length,
159
+ link: formatLink({ root: manifest.root, size: manifest.size, name, secret, type: 'collection' })
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Read a folder's file list without fetching any of it: each file's path, root and size, and
165
+ * the total. Nothing is written to disk and this node is never announced as a provider.
166
+ *
167
+ * @param {string|object} link - a folder link
168
+ * @returns {Listing} thenable, resolving to `{ files, size }`; `stop()` gives up waiting
169
+ */
170
+ readList (link) {
171
+ const parsed = typeof link === 'string' ? parseLink(link) : link
172
+ if (parsed.type !== 'collection') return Listing.failed(new Error('not a folder: that link is a single file'))
173
+
174
+ return this._list(parsed)
175
+ }
176
+
177
+ /**
178
+ * Fetch one file of a folder, using only the folder's link and an entry from its list. It is
179
+ * not announced on its own: the peers holding it are the ones connected through the folder.
180
+ *
181
+ * @param {string|object} link - the folder's link
182
+ * @param {{path: string, root: string, size: number}} file - an entry of `readList`'s result
183
+ * @param {string} outPath - where to write it; its directory must exist
184
+ * @returns {Download}
185
+ */
186
+ downloadFile (link, file, outPath) {
187
+ const { secret } = typeof link === 'string' ? parseLink(link) : link
188
+
189
+ return this.download(
190
+ { root: file.root, size: file.size, secret, name: file.path },
191
+ outPath,
192
+ { announce: false }
193
+ )
194
+ }
195
+
196
+ /** One read per folder at a time: asking again while one runs shares it (and its `stop`). */
197
+ _list (parsed) {
198
+ // shortcut: a folder's list is kept for the node's lifetime; evict if a node reads thousands of folders
199
+ const known = this.lists.get(parsed.root)
200
+ if (known) return Listing.known(known)
201
+
202
+ let listing = this._listings.get(parsed.root)
203
+ if (!listing) {
204
+ listing = new Listing(this, parsed)
205
+ this._listings.set(parsed.root, listing)
206
+ const done = () => this._listings.delete(parsed.root)
207
+ listing.then(done, done)
208
+ }
209
+
210
+ return listing
211
+ }
212
+
213
+ /** A folder's list became known: remember it, and ask peers what they hold of its files. */
214
+ _learnList (root, files) {
215
+ this.lists.set(root, files)
216
+ for (const file of files) this._watch(file.root, file.size)
217
+ }
218
+
219
+ /**
220
+ * Fetch a collection: the list first, then every file it names, into `dir`.
221
+ *
222
+ * @param {string|object} link
223
+ * @param {string} dir
224
+ * @param {{onfile?: Function}} [opts]
225
+ * @returns {Promise<{files: number, bytes: number}>}
226
+ */
227
+ async downloadCollection (link, dir, { onfile = null } = {}) {
228
+ const parsed = typeof link === 'string' ? parseLink(link) : link
229
+
230
+ await fsx.mkdir(dir)
231
+
232
+ // Reading the list only looks; downloading a folder also announces it, so whoever fetches
233
+ // it can be fetched from in turn.
234
+ this.swarm.join(discoveryTopic(parsed.root, parsed.secret), ANNOUNCE)
235
+
236
+ const list = await this._list(parsed)
237
+ if (!list) throw new Error('the folder list read was stopped')
238
+
239
+ let bytes = 0
240
+
241
+ for (const file of list.files) {
242
+ const target = join(dir, file.path)
243
+ await fsx.mkdir(parentOf(target))
244
+
245
+ await this.downloadFile(parsed, file, target)
246
+
247
+ bytes += file.size
248
+ if (onfile) onfile({ path: file.path, size: file.size })
249
+ }
250
+
251
+ return { files: list.files.length, bytes }
252
+ }
253
+
254
+ /** Stop serving content. The file itself is untouched. */
255
+ async unserve (root) {
256
+ const source = this.serving.get(root)
257
+ if (!source) return
258
+
259
+ this.serving.delete(root)
260
+ this.holdings.unwatch(root)
261
+ this.lists.delete(root)
262
+ await this._sessions.get(root)?.destroy?.()
263
+ this._sessions.delete(root)
264
+ await source.close()
265
+ }
266
+
267
+ _announce (root, topic) {
268
+ if (!this._sessions.has(root)) this._sessions.set(root, this.swarm.join(topic, ANNOUNCE))
269
+ return this._sessions.get(root)
270
+ }
271
+
272
+ /** Connected peers that completed the handshake, counting each distinct peer once. */
273
+ get peerCount () {
274
+ return this._ready().length
275
+ }
276
+
277
+ /**
278
+ * How many direct connections hold this content: all of it (seeders) or part of it
279
+ * (leechers). It describes who is connected to this node, not the swarm, and a peer that has
280
+ * said nothing about the content is counted in `peers` only. A folder's counts need its file
281
+ * list, which a node knows once it has served, read or downloaded it; until then they are 0.
282
+ *
283
+ * @param {string|object} link
284
+ * @returns {{peers: number, seeders: number, leechers: number}}
285
+ */
286
+ counts (link) {
287
+ const parsed = typeof link === 'string' ? parseLink(link) : link
288
+ const list = this.lists.get(parsed.root)
289
+
290
+ let roots = [parsed.root]
291
+ if (list) roots = [parsed.root, ...list.map((file) => file.root)]
292
+ else if (parsed.type === 'collection') roots = []
293
+
294
+ return this.holdings.count(this._ready(), roots)
295
+ }
296
+
297
+ _ready () {
298
+ const ids = []
299
+ for (const peer of this.peers.values()) if (peer.ready) ids.push(peer.id)
300
+ return ids
301
+ }
302
+
303
+ /** Ask every peer about a root we hold, unless a download already did. */
304
+ _watch (root, size) {
305
+ this.holdings.watch(root, size, this.peers.values(), (r) => this.downloads.has(r))
306
+ }
307
+
308
+ /**
309
+ * Fetch content by link. Returns immediately; await the result for completion, or
310
+ * `await download.ready()` to know the destination has been opened.
311
+ *
312
+ * @param {string|object} link
313
+ * @param {string} outPath
314
+ * @returns {Download}
315
+ */
316
+ download (link, outPath, { announce = true, destination = null } = {}) {
317
+ const parsed = typeof link === 'string' ? parseLink(link) : link
318
+
319
+ const existing = this.downloads.get(parsed.root)
320
+ if (existing) return existing
321
+
322
+ const download = new Download(this, parsed, outPath, { announced: announce, destination })
323
+ this.downloads.set(parsed.root, download)
324
+
325
+ // A file inside a collection is not announced on its own: whoever has it is already
326
+ // connected through the collection's topic, and a folder of a thousand files should not
327
+ // mean a thousand announces.
328
+ if (announce) download._session = this.swarm.join(discoveryTopic(parsed.root, parsed.secret), ANNOUNCE)
329
+
330
+ download._start()
331
+
332
+ return download
333
+ }
334
+
335
+ async destroy () {
336
+ this.swarm.removeListener?.('connection', this._onconnection)
337
+
338
+ for (const download of [...this.downloads.values()]) await download.stop()
339
+ for (const root of [...this.serving.keys()]) await this.unserve(root)
340
+
341
+ this.peers.clear()
342
+ }
343
+
344
+ // -- peers ----------------------------------------------------------------
345
+
346
+ _onPeer (stream, info) {
347
+ const id = peerId(info)
348
+ let peer = null
349
+
350
+ const channel = openChannel(stream, {
351
+ onhandshake: () => peer && this._onHandshake(peer),
352
+ onwant: (message) => this._onWant(id, message),
353
+ onhave: (message) => this._onHave(id, message),
354
+ onrequest: (message) => this._onRequest(id, message),
355
+ ondata: (message) => this._onData(id, message),
356
+ onunavailable: (message) => this._onUnavailable(id, message),
357
+ onholds: (message) => this._onHolds(id, message),
358
+ onreject: (message) => this._onReject(id, message),
359
+ onclose: () => peer && this._onPeerGone(peer)
360
+ })
361
+
362
+ if (channel === null) return
363
+
364
+ // A second connection from the same peer replaces the first without announcing it again.
365
+ peer = { id, send: channel.send, wants: new Set(), ready: this.peers.get(id)?.ready === true }
366
+ this.peers.set(id, peer)
367
+
368
+ peer.send.handshake({ version: VERSION, groupLog: this.groupLog })
369
+
370
+ // Tell a new peer what we are after. A peer that has it answers; one that does not stays
371
+ // quiet and is simply never asked.
372
+ for (const download of this.downloads.values()) download._askPeer(peer)
373
+ this.holdings.wantAll(peer, (root) => this.downloads.has(root))
374
+ }
375
+
376
+ /** The remote spoke swarmwire: from here on it is a peer, not just a connection. */
377
+ _onHandshake (peer) {
378
+ if (peer.ready) return
379
+
380
+ peer.ready = true
381
+ this.emit('peer-connected', { id: peer.id })
382
+ }
383
+
384
+ _onPeerGone (peer) {
385
+ // The connection that closed may be one a newer connection from the same peer replaced.
386
+ if (this.peers.get(peer.id) !== peer) return
387
+
388
+ this.peers.delete(peer.id)
389
+ this.holdings.dropPeer(peer.id)
390
+ for (const download of this.downloads.values()) download._peerGone(peer)
391
+
392
+ if (peer.ready) this.emit('peer-disconnected', { id: peer.id })
393
+ }
394
+
395
+ _onWant (id, { root }) {
396
+ const peer = this.peers.get(id)
397
+ if (!peer) return
398
+
399
+ const rootHex = hex(root)
400
+ peer.wants.add(rootHex) // so new groups can be announced to them later
401
+
402
+ const source = this.serving.get(rootHex)
403
+ if (source) {
404
+ peer.send.have({
405
+ root,
406
+ size: source.size,
407
+ groupLog: source.groupLog,
408
+ bitfield: fullBitfield(source.groupCount)
409
+ })
410
+ return
411
+ }
412
+
413
+ // A download in progress is a source too, for whatever it has verified so far. This is
414
+ // what keeps content alive once the original sender leaves.
415
+ const partial = this.downloads.get(rootHex)
416
+ if (partial?.servable) {
417
+ peer.send.have({
418
+ root,
419
+ size: partial.size,
420
+ groupLog: this.groupLog,
421
+ bitfield: partial.bitfield()
422
+ })
423
+ return
424
+ }
425
+
426
+ peer.send.unavailable({ root })
427
+ }
428
+
429
+ async _onRequest (id, { root, index }) {
430
+ const peer = this.peers.get(id)
431
+ if (!peer) return
432
+
433
+ const rootHex = hex(root)
434
+ const source = this.serving.get(rootHex)
435
+
436
+ if (source) {
437
+ if (index < 0 || index >= source.groupCount) return peer.send.reject({ root, index })
438
+
439
+ const data = await source.readGroup(index)
440
+ if (!this.peers.has(id)) return // they left while we were reading
441
+
442
+ peer.send.data({ root, index, proof: source.proof(index), data })
443
+ return
444
+ }
445
+
446
+ // Anything we cannot answer gets a refusal rather than silence, so the asker can give the
447
+ // group to someone else now instead of after a timeout.
448
+ const partial = this.downloads.get(rootHex)
449
+ if (!partial?.servable) return peer.send.reject({ root, index })
450
+
451
+ const served = await partial.serve(index)
452
+ if (!this.peers.has(id)) return
453
+ if (served === null) return peer.send.reject({ root, index })
454
+
455
+ peer.send.data({ root, index, proof: served.proof, data: served.data })
456
+ }
457
+
458
+ /**
459
+ * Keep serving content we just finished downloading, from where it landed.
460
+ *
461
+ * @param {string} root
462
+ * @param {string} path
463
+ * @param {import('./tree.js').GroupTree} tree
464
+ */
465
+ async _adopt (root, path, tree) {
466
+ if (this.serving.has(root)) return
467
+
468
+ try {
469
+ const source = await ContentSource.adopt(path, tree)
470
+ this.serving.set(root, source)
471
+ this._watch(root, source.size) // peers that connect from now on are asked what they hold
472
+ } catch {
473
+ // The file was moved or deleted between finishing and adopting it. Nothing to serve.
474
+ }
475
+ }
476
+
477
+ /** Tell everyone who asked for this content that we now hold one more piece of it. */
478
+ _announceHolds (rootHex, index) {
479
+ const root = b4a.from(rootHex, 'hex')
480
+
481
+ for (const peer of this.peers.values()) {
482
+ if (peer.wants.has(rootHex)) peer.send.holds({ root, index })
483
+ }
484
+ }
485
+
486
+ _onHolds (id, message) {
487
+ const rootHex = hex(message.root)
488
+ const download = this.downloads.get(rootHex)
489
+ const peer = this.peers.get(id)
490
+ if (!peer) return
491
+
492
+ const size = this.serving.get(rootHex)?.size ?? download?.size ?? this.holdings.watched.get(rootHex)
493
+ this.holdings.grow(id, rootHex, message.index, size ?? undefined)
494
+ download?._onHolds(peer, message)
495
+ }
496
+
497
+ _onReject (id, message) {
498
+ const download = this.downloads.get(hex(message.root))
499
+ const peer = this.peers.get(id)
500
+ if (download && peer) download._onReject(peer, message)
501
+ }
502
+
503
+ _onHave (id, message) {
504
+ const rootHex = hex(message.root)
505
+ const download = this.downloads.get(rootHex)
506
+ const peer = this.peers.get(id)
507
+ if (!peer) return
508
+
509
+ this.holdings.set(id, rootHex, message)
510
+ download?._onHave(peer, message)
511
+ }
512
+
513
+ _onData (id, message) {
514
+ const download = this.downloads.get(hex(message.root))
515
+ const peer = this.peers.get(id)
516
+ if (download && peer) download._onData(peer, message)
517
+ }
518
+
519
+ _onUnavailable (id, message) {
520
+ const rootHex = hex(message.root)
521
+ const download = this.downloads.get(rootHex)
522
+ const peer = this.peers.get(id)
523
+ if (!peer) return
524
+
525
+ this.holdings.remove(id, rootHex)
526
+ download?._sources.delete(peer.id)
527
+ }
528
+ }
529
+
530
+ /**
531
+ * One transfer in progress. Thenable, so `await download` waits for it to finish.
532
+ */
533
+ export class Download extends EventEmitter {
534
+ constructor (node, { root, size, secret }, outPath, { announced = true, destination = null } = {}) {
535
+ super()
536
+
537
+ this.announced = announced
538
+ this._session = null // the announce session, when there is one
539
+ this._destinationFactory = destination // where the content lands, when not a file at `outPath`
540
+
541
+ this.node = node
542
+ this.root = root
543
+ this.size = size // may be null until a peer tells us
544
+ this.secret = secret ?? null
545
+ this.path = outPath
546
+
547
+ this.received = 0 // groups accepted over the wire this session
548
+ this.rejected = 0 // groups that failed verification
549
+ this.timedOut = 0 // requests a peer never answered
550
+ this.rejectedByPeers = 0 // requests a peer declined
551
+ this.peersUsed = new Set()
552
+ this.stopped = false
553
+
554
+ this._destination = null
555
+ this._bitfields = new Map() // peer id -> what that peer says it holds
556
+ this._sources = new Set() // peer ids worth asking right now
557
+ this._liars = new Set() // peer ids that sent something that did not verify: never again
558
+ this._asked = new Set() // peer ids already told what we want
559
+ this._inflight = new Map() // peer id → Map(group index → cancel its timer)
560
+ this._strikes = new Map() // peer id → requests it has failed to answer
561
+ this._windows = new Map() // peer id → how many requests to keep outstanding with it
562
+ this._ready = null
563
+ this._resolve = null
564
+ this._reject = null
565
+ this._promise = new Promise((resolve, reject) => {
566
+ this._resolve = resolve
567
+ this._reject = reject
568
+ })
569
+ }
570
+
571
+ /** Resolves once the destination file is open and progress is known. */
572
+ ready () {
573
+ return this._ready ?? Promise.resolve()
574
+ }
575
+
576
+ get total () {
577
+ return this._destination ? this._destination.groupCount : (this.size === null ? 0 : groupCount(this.size, this.node.groupLog))
578
+ }
579
+
580
+ get have () {
581
+ return this._destination ? this._destination.have : 0
582
+ }
583
+
584
+ get complete () {
585
+ return this._destination ? this._destination.complete : false
586
+ }
587
+
588
+ /**
589
+ * Is there anything here worth offering other peers? A download with verified groups is a
590
+ * source like any other — this is what keeps content alive after the sender leaves.
591
+ */
592
+ get servable () {
593
+ return !this.stopped && this._destination !== null && this._destination.have > 0
594
+ }
595
+
596
+ /** What this download currently holds, for advertising to peers. */
597
+ bitfield () {
598
+ return this._destination ? this._destination.bitfield() : new Uint8Array(0)
599
+ }
600
+
601
+ /**
602
+ * Serve one group out of what has been verified so far.
603
+ *
604
+ * @param {number} index
605
+ * @returns {Promise<{data: Uint8Array, proof: Uint8Array[]}|null>} null when it cannot be
606
+ * proved yet: holding a group is not enough, the sibling hashes have to be there too
607
+ */
608
+ async serve (index) {
609
+ if (!this.servable) return null
610
+
611
+ const proof = this._destination.proof(index)
612
+ if (proof === null) return null
613
+
614
+ const data = await this._destination.readGroup(index)
615
+ return data === null ? null : { data, proof }
616
+ }
617
+
618
+ /** Requests sent and not yet answered. */
619
+ get inflight () {
620
+ let n = 0
621
+ for (const requests of this._inflight.values()) n += requests.size
622
+ return n
623
+ }
624
+
625
+ /** Stop without finishing. What has been verified stays on disk, ready to resume. */
626
+ async stop () {
627
+ if (this.stopped) return
628
+ this.stopped = true
629
+
630
+ // Let a destination that is mid-open finish, so there is something to close.
631
+ await this.ready().catch(() => {})
632
+
633
+ this._cancelEverything()
634
+ this.node.downloads.delete(this.root)
635
+ await this._session?.destroy?.() // only ours: the same topic may be served or read elsewhere
636
+
637
+ if (this._destination) await this._destination.close()
638
+
639
+ // Never resolve with `this`: a Download is itself a thenable, and a promise resolved with
640
+ // its own thenable waits for itself forever.
641
+ this._resolve()
642
+ }
643
+
644
+ then (onfulfilled, onrejected) {
645
+ return this._promise.then(onfulfilled, onrejected)
646
+ }
647
+
648
+ catch (onrejected) {
649
+ return this._promise.catch(onrejected)
650
+ }
651
+
652
+ finally (onfinally) {
653
+ return this._promise.finally(onfinally)
654
+ }
655
+
656
+ // -- internals ------------------------------------------------------------
657
+
658
+ _start () {
659
+ if (this.size === null) return // wait for a peer to tell us how big it is
660
+
661
+ this._ready = this._open()
662
+ this._ready.catch((err) => this._fail(err))
663
+ }
664
+
665
+ async _open () {
666
+ if (this._destination) return
667
+
668
+ const options = { root: this.root, size: this.size, groupLog: this.node.groupLog }
669
+ const destination = this._destinationFactory
670
+ ? await this._destinationFactory(options)
671
+ : await Destination.open(this.path, options)
672
+
673
+ // The download can be stopped while the file is still opening. Nobody would ever close
674
+ // this handle otherwise.
675
+ if (this.stopped) return destination.close()
676
+
677
+ this._destination = destination
678
+ if (this._destination.complete) return this._finish()
679
+
680
+ for (const peer of this.node.peers.values()) this._askPeer(peer)
681
+ }
682
+
683
+ _askPeer (peer) {
684
+ if (this.stopped || this._asked.has(peer.id)) return
685
+
686
+ this._asked.add(peer.id)
687
+ peer.send.want({ root: b4a.from(this.root, 'hex'), size: this.size ?? 0 })
688
+ }
689
+
690
+ async _onHave (peer, { size, groupLog, bitfield }) {
691
+ if (this.stopped) return
692
+ if (groupLog !== this.node.groupLog) return // we do not speak that granularity yet
693
+
694
+ if (this._liars.has(peer.id)) return // nothing it says is worth storing
695
+
696
+ this._bitfields.set(peer.id, b4a.from(bitfield ?? []))
697
+ this._sources.add(peer.id)
698
+
699
+ if (this.size === null) {
700
+ // No size hint in the link, so take the peer's word for it. A lie here cannot corrupt
701
+ // anything: every group is still checked against the root, and a wrong size fails.
702
+ this.size = size
703
+ this._ready = this._open()
704
+ this._ready.catch((err) => this._fail(err))
705
+ await this._ready
706
+ }
707
+
708
+ await this.ready()
709
+ this._pump(peer)
710
+ }
711
+
712
+ async _onData (peer, { index, proof, data }) {
713
+ if (this.stopped || !this._destination) return
714
+
715
+ // Were we asking this peer for as much as we allow when the answer came? If so the
716
+ // window, not the link, is what limits this peer, and it should grow.
717
+ const saturated = this._requests(peer).size >= this._windowFor(peer)
718
+
719
+ // Late answers still count. A group whose timer already fired went back in the pool, but
720
+ // the bytes verify against the root either way: throwing away something we still need, in
721
+ // order to go and ask somebody else for it, would be perverse.
722
+ const accepted = await this._destination.put(index, data, proof).catch(() => false)
723
+ this._release(peer, index) // only now: the group stays claimed while it is being written
724
+
725
+ if (accepted) {
726
+ this.received++
727
+ this.peersUsed.add(peer.id)
728
+ // Answering clears the record of not answering, and earns back a place in the pool: a
729
+ // peer dropped for being slow has just proved it is worth asking again.
730
+ this._strikes.delete(peer.id)
731
+ this._widen(peer, saturated)
732
+ if (!this._liars.has(peer.id) && this._bitfields.has(peer.id)) this._sources.add(peer.id)
733
+ this.node._announceHolds(this.root, index) // we can serve this one now
734
+ this.emit('progress', { have: this.have, total: this.total, from: peer.id })
735
+ } else {
736
+ this.rejected++
737
+ this.emit('rejected', { index, from: peer.id })
738
+ this._liars.add(peer.id) // a peer that sends rubbish is never asked again
739
+ this._sources.delete(peer.id)
740
+ this._bitfields.delete(peer.id)
741
+ }
742
+
743
+ if (this.complete) return this._finish()
744
+ this._pumpAll()
745
+ }
746
+
747
+ /**
748
+ * A peer took a request and never answered. Give the group back to the pool, and stop
749
+ * asking a peer that keeps doing it — silence costs more than refusal, because the group
750
+ * sits reserved while nothing happens.
751
+ */
752
+ _onTimeout (peer, index) {
753
+ if (this.stopped) return
754
+
755
+ this._requests(peer).delete(index)
756
+ this.timedOut++
757
+
758
+ const strikes = (this._strikes.get(peer.id) ?? 0) + 1
759
+ this._strikes.set(peer.id, strikes)
760
+ if (strikes >= MAX_TIMEOUTS) this._sources.delete(peer.id)
761
+
762
+ this._narrow(peer) // asking for less is the first thing to try when a peer struggles
763
+
764
+ this.emit('timeout', { index, from: peer.id, strikes })
765
+ this._pumpAll()
766
+ }
767
+
768
+ _pump (peer) {
769
+ if (this.stopped || !this._destination || !this._sources.has(peer.id)) return
770
+
771
+ const requests = this._requests(peer)
772
+ const window = this._windowFor(peer)
773
+ const wanted = rarestFirst(this._destination.missing(), (index) => this._holders(index))
774
+
775
+ for (const index of wanted) {
776
+ if (requests.size >= window) break
777
+ if (this._claimed(index)) continue
778
+ if (!this._peerHas(peer.id, index)) continue // it has not got that one yet
779
+
780
+ this._claim(peer, index)
781
+ peer.send.request({ root: b4a.from(this.root, 'hex'), index })
782
+ }
783
+ }
784
+
785
+ _pumpAll () {
786
+ for (const peer of this.node.peers.values()) {
787
+ if (this._sources.has(peer.id)) this._pump(peer)
788
+ }
789
+
790
+ // Nobody usable and nothing outstanding: say so rather than sitting silent forever.
791
+ // Whether to wait for new peers or give up belongs to the caller.
792
+ if (this._sources.size === 0 && this.inflight === 0 && !this.complete) {
793
+ this.emit('stalled', { have: this.have, total: this.total })
794
+ }
795
+ }
796
+
797
+ /**
798
+ * The request window for one peer.
799
+ *
800
+ * It grows by one each time an answer arrives while we were already asking for as much as
801
+ * the window allows, and halves whenever a request is refused or times out. Additive
802
+ * increase and multiplicative decrease, the same shape as congestion control, for the same
803
+ * reason: the right depth depends on a link's latency and speed, which only the traffic
804
+ * knows.
805
+ */
806
+ _windowFor (peer) {
807
+ return this._windows.get(peer.id) ?? this.node.window
808
+ }
809
+
810
+ _widen (peer, saturated) {
811
+ if (!saturated) return // the link, not the window, is what we are waiting on
812
+
813
+ const next = Math.min(this._windowFor(peer) + 1, WINDOW_MAX)
814
+ this._windows.set(peer.id, next)
815
+ }
816
+
817
+ _narrow (peer) {
818
+ const next = Math.max(Math.floor(this._windowFor(peer) / 2), WINDOW_MIN)
819
+ this._windows.set(peer.id, next)
820
+ }
821
+
822
+ /** How many connected peers hold this group. Drives rarest-first selection. */
823
+ _holders (index) {
824
+ let n = 0
825
+ for (const peerId of this._sources) {
826
+ if (this._peerHas(peerId, index)) n++
827
+ }
828
+ return n
829
+ }
830
+
831
+ /** Does this peer hold that group? An empty bitfield means we were told nothing useful. */
832
+ _peerHas (peerId, index) {
833
+ const bitfield = this._bitfields.get(peerId)
834
+ if (!bitfield || bitfield.length === 0) return false
835
+
836
+ return bao.getBit(bitfield, index)
837
+ }
838
+
839
+ /**
840
+ * A peer saying it will not serve a group after all. Take the group back, and stop
841
+ * believing it has that one until it says otherwise.
842
+ */
843
+ _onReject (peer, { index }) {
844
+ if (this.stopped) return
845
+
846
+ this._release(peer, index)
847
+ this.rejectedByPeers++
848
+ this._narrow(peer)
849
+
850
+ const bitfield = this._bitfields.get(peer.id)
851
+ if (bitfield && index < bitfield.length * 8) bao.clearBit(bitfield, index)
852
+
853
+ this._pumpAll()
854
+ }
855
+
856
+ /** A peer announcing one more group it now holds. */
857
+ _onHolds (peer, { index }) {
858
+ const bitfield = this._bitfields.get(peer.id)
859
+ if (!bitfield || index >= bitfield.length * 8) return
860
+
861
+ bao.setBit(bitfield, index)
862
+ this._pump(peer)
863
+ }
864
+
865
+ _requests (peer) {
866
+ let requests = this._inflight.get(peer.id)
867
+ if (!requests) {
868
+ requests = new Map()
869
+ this._inflight.set(peer.id, requests)
870
+ }
871
+ return requests
872
+ }
873
+
874
+ _claim (peer, index) {
875
+ const cancel = this.node.timers.setTimer(
876
+ this.node.requestTimeout,
877
+ () => this._onTimeout(peer, index)
878
+ )
879
+
880
+ this._requests(peer).set(index, cancel)
881
+ }
882
+
883
+ _release (peer, index) {
884
+ const requests = this._requests(peer)
885
+ const cancel = requests.get(index)
886
+
887
+ if (cancel) cancel()
888
+ requests.delete(index)
889
+ }
890
+
891
+ _releaseAll (peerId) {
892
+ const requests = this._inflight.get(peerId)
893
+ if (!requests) return
894
+
895
+ for (const cancel of requests.values()) cancel()
896
+ this._inflight.delete(peerId)
897
+ }
898
+
899
+ /** Is some peer already fetching this group? */
900
+ _claimed (index) {
901
+ for (const requests of this._inflight.values()) {
902
+ if (requests.has(index)) return true
903
+ }
904
+ return false
905
+ }
906
+
907
+ _peerGone (peer) {
908
+ this._sources.delete(peer.id)
909
+ this._bitfields.delete(peer.id)
910
+ this._asked.delete(peer.id)
911
+ this._releaseAll(peer.id) // whatever it owed us is up for grabs again
912
+
913
+ this._pumpAll()
914
+ }
915
+
916
+ async _finish () {
917
+ if (this.stopped) return
918
+ this.stopped = true
919
+
920
+ this._cancelEverything()
921
+ this.node.downloads.delete(this.root)
922
+
923
+ // Every group was hashed on the way in, so the finished file can be served without
924
+ // reading it again. A download that stops seeding the moment it finishes would make the
925
+ // swarm no more durable than its original sender.
926
+ const tree = this._destination.tree
927
+
928
+ await this._destination.finish()
929
+ if (!this._destinationFactory) await this.node._adopt(this.root, this.path, tree)
930
+
931
+ this.emit('progress', { have: this.have, total: this.total, from: null })
932
+ this._resolve()
933
+ }
934
+
935
+ _cancelEverything () {
936
+ for (const peerId of [...this._inflight.keys()]) this._releaseAll(peerId)
937
+ }
938
+
939
+ _fail (err) {
940
+ if (this.stopped) return
941
+ this.stopped = true
942
+ this._cancelEverything()
943
+ this.node.downloads.delete(this.root)
944
+ this._reject(err)
945
+ }
946
+ }
947
+
948
+ /** Every bit set: what a peer holding the whole thing advertises. */
949
+ function fullBitfield (count) {
950
+ const bitfield = bao.createBitfield(count)
951
+ for (let i = 0; i < count; i++) bao.setBit(bitfield, i)
952
+ return bitfield
953
+ }
954
+
955
+ function peerId (info) {
956
+ return info?.publicKey ? hex(info.publicKey) : String(info?.name ?? 'unknown')
957
+ }
958
+
959
+ function hex (bytes) {
960
+ return typeof bytes === 'string' ? bytes : b4a.toString(b4a.from(bytes), 'hex')
961
+ }
962
+
963
+ function join (dir, relative) {
964
+ return dir.replace(/[\/]+$/, '') + '/' + relative
965
+ }
966
+
967
+ function parentOf (path) {
968
+ const at = path.lastIndexOf('/')
969
+ return at === -1 ? '.' : path.slice(0, at)
970
+ }
971
+
972
+ function basename (path) {
973
+ const parts = String(path).split(/[\\/]/)
974
+ return parts[parts.length - 1] || null
975
+ }