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