swarmwire-lib 0.2.0 → 0.4.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/README.md +6 -3
- package/docs/contract.md +10 -2
- package/lib/select.js +44 -18
- package/lib/swarmwire.js +52 -20
- package/lib/tree.js +19 -8
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -55,8 +55,9 @@ The command-line tool built on this library is moving to its own package,
|
|
|
55
55
|
|
|
56
56
|
> Status: **pre-alpha.** Transfers work end to end, over a real swarm, with resume,
|
|
57
57
|
> verification, request timeouts, partial seeding, rarest-first selection and an adaptive
|
|
58
|
-
> request window. Behaviour with very many peers is not measured
|
|
59
|
-
>
|
|
58
|
+
> request window. Behaviour with very many peers is only modelled, not measured. It is enough
|
|
59
|
+
> for a command-line tool; it is not yet the content-addressing layer SwarmFS needs (admission,
|
|
60
|
+
> peer hints, no public discovery). [docs/roadmap.md](docs/roadmap.md) says where it stands.
|
|
60
61
|
|
|
61
62
|
## What it is
|
|
62
63
|
|
|
@@ -110,7 +111,9 @@ Anything that needs a name, a directory or a user belongs upstairs. See
|
|
|
110
111
|
|------------------------------------|-----------------------------------------------------------------------------------------------|
|
|
111
112
|
| [docs/format.md](docs/format.md) | Addressing, verification, links, topic derivation — the parts that must never change casually |
|
|
112
113
|
| [docs/contract.md](docs/contract.md) | What applications can rely on, supply and must not expect |
|
|
113
|
-
| [docs/roadmap.md](docs/roadmap.md) | Vision and
|
|
114
|
+
| [docs/roadmap.md](docs/roadmap.md) | Vision, milestones, and where things stand |
|
|
115
|
+
| [docs/scaling.md](docs/scaling.md) | Piece selection in large swarms, simulated |
|
|
116
|
+
| [docs/research/p2p-landscape.md](docs/research/p2p-landscape.md) | What other P2P systems do, and what is worth taking |
|
|
114
117
|
| [AGENTS.md](AGENTS.md) | How to work in this repo |
|
|
115
118
|
|
|
116
119
|
## License
|
package/docs/contract.md
CHANGED
|
@@ -23,8 +23,9 @@ code (`serveCollection`, `downloadCollection`). See `AGENTS.md` for the invarian
|
|
|
23
23
|
| Peer identity: the remote public key of the connection | exists | - | `test/swarm/transfer.test.js` |
|
|
24
24
|
| Peer activity events, peer count, seeder and leecher counts | exists | - | `test/swarm/peers.test.js` |
|
|
25
25
|
| Folder file-list reader (look-only, in memory), and downloading one listed file | exists | - | `test/swarm/list.test.js` |
|
|
26
|
-
|
|
|
27
|
-
|
|
|
26
|
+
| Download order spread by random choice (no two newcomers ask for the same thing first) | exists | - | `test/swarm/spread.test.js` |
|
|
27
|
+
| Peer hints (preferred peers, priorities) | planned | 004 | - |
|
|
28
|
+
| Admission decisions | planned | 004 | - |
|
|
28
29
|
|
|
29
30
|
## 3. Rules the application can rely on
|
|
30
31
|
|
|
@@ -73,6 +74,13 @@ The application can decide, per peer and per content, whether content is served.
|
|
|
73
74
|
**Direct connections only**: a snapshot that may lag, not a count of the swarm. Global counts and
|
|
74
75
|
chunk rarity across the network are a tracker's job, not swarmwire's.
|
|
75
76
|
|
|
77
|
+
### 3.3a Download order
|
|
78
|
+
|
|
79
|
+
Groups are fetched rarest first, equal rarity broken at random, starting from a random place in
|
|
80
|
+
the file. **A download does not proceed in file order**: a file is complete when every group is
|
|
81
|
+
held, and nothing is promised about reading it while it downloads. How the choice was made, and
|
|
82
|
+
what was measured, is in `docs/scaling.md` in the repository (not part of the package).
|
|
83
|
+
|
|
76
84
|
### 3.4 Identity
|
|
77
85
|
|
|
78
86
|
swarmwire knows the remote public key of each connection and nothing else about a peer. Tying a
|
package/lib/select.js
CHANGED
|
@@ -5,29 +5,55 @@
|
|
|
5
5
|
* The reason is not speed on this download but survival of the swarm — a group held by one
|
|
6
6
|
* peer disappears when that peer leaves, and a swarm where every copy of some group lives on
|
|
7
7
|
* one machine is one disconnection away from nobody being able to finish.
|
|
8
|
+
*
|
|
9
|
+
* Two things keep a crowd from asking for the same thing. Equally rare groups are ordered at
|
|
10
|
+
* random rather than by position, and the scan for candidates starts at a random group rather
|
|
11
|
+
* than the start of the file — otherwise a thousand fresh downloaders of a large file would
|
|
12
|
+
* all draw from its first few hundred groups.
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
|
-
|
|
15
|
+
import { bao } from './blake3.js'
|
|
16
|
+
|
|
17
|
+
/** How many candidate groups to weigh at once. */
|
|
11
18
|
export const SCAN_LIMIT = 256
|
|
12
19
|
|
|
20
|
+
/** How many source peers hold each group. Kept up to date by the caller, read in O(1). */
|
|
21
|
+
export class Availability {
|
|
22
|
+
constructor (groupCount) {
|
|
23
|
+
this.counts = new Uint16Array(groupCount)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
at (index) { return this.counts[index] }
|
|
27
|
+
set (index) { this.counts[index]++ }
|
|
28
|
+
unset (index) { if (this.counts[index] > 0) this.counts[index]-- }
|
|
29
|
+
|
|
30
|
+
add (bitfield) { this._each(bitfield, (i) => this.set(i)) }
|
|
31
|
+
remove (bitfield) { this._each(bitfield, (i) => this.unset(i)) }
|
|
32
|
+
|
|
33
|
+
_each (bitfield, fn) {
|
|
34
|
+
const n = Math.min(this.counts.length, bitfield.length * 8)
|
|
35
|
+
for (let i = 0; i < n; i++) if (bao.getBit(bitfield, i)) fn(i)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
13
39
|
/**
|
|
14
|
-
*
|
|
15
|
-
* a
|
|
16
|
-
*
|
|
17
|
-
* Only the first `limit` missing groups are considered: on a large file, sorting every
|
|
18
|
-
* outstanding group on every request would cost more than it saves, and rarity among the next
|
|
19
|
-
* few hundred is what actually decides the next request.
|
|
20
|
-
*
|
|
21
|
-
* @param {number[]} missing - group indices still wanted, in ascending order
|
|
22
|
-
* @param {(index: number) => number} holders - how many peers hold this group
|
|
23
|
-
* @param {{limit?: number}} [opts]
|
|
24
|
-
* @returns {number[]}
|
|
40
|
+
* The groups worth asking this peer for: wanted, offered, and not already being fetched.
|
|
41
|
+
* Scanning starts at `from` and wraps, so the window is a random stretch of the file.
|
|
25
42
|
*/
|
|
26
|
-
export function
|
|
27
|
-
const
|
|
43
|
+
export function candidates ({ count, has, peerHas, claimed, from = 0, limit = SCAN_LIMIT }) {
|
|
44
|
+
const out = []
|
|
45
|
+
|
|
46
|
+
for (let k = 0; k < count && out.length < limit; k++) {
|
|
47
|
+
const i = (from + k) % count
|
|
48
|
+
if (!has(i) && peerHas(i) && !claimed(i)) out.push(i)
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
28
52
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
.map((
|
|
53
|
+
/** Fewest holders first; groups equally rare come in the order `random` gives them. */
|
|
54
|
+
export function rank (pool, availability, random) {
|
|
55
|
+
return pool
|
|
56
|
+
.map((index) => ({ index, rarity: availability.at(index), tie: random() }))
|
|
57
|
+
.sort((a, b) => a.rarity - b.rarity || a.tie - b.tie)
|
|
58
|
+
.map((entry) => entry.index)
|
|
33
59
|
}
|
package/lib/swarmwire.js
CHANGED
|
@@ -18,9 +18,9 @@ import { ContentSource, MemorySource } from './source.js'
|
|
|
18
18
|
import { encodeManifest, decodeManifest, totalSize } from './collection.js'
|
|
19
19
|
import { Destination } from './destination.js'
|
|
20
20
|
import { discoveryTopic, formatLink, parseLink } from './address.js'
|
|
21
|
-
import { DEFAULT_GROUP_LOG, groupCount } from './tree.js'
|
|
21
|
+
import { DEFAULT_GROUP_LOG, groupCount, groupLength } from './tree.js'
|
|
22
22
|
import { openChannel, VERSION } from './wire.js'
|
|
23
|
-
import {
|
|
23
|
+
import { Availability, candidates, rank } from './select.js'
|
|
24
24
|
import { Holdings } from './holdings.js'
|
|
25
25
|
import { Listing } from './listing.js'
|
|
26
26
|
import { systemTimers } from './timers.js'
|
|
@@ -79,6 +79,7 @@ export class Swarmwire extends EventEmitter {
|
|
|
79
79
|
this.timers = timers
|
|
80
80
|
this.requestTimeout = requestTimeout
|
|
81
81
|
this.window = window
|
|
82
|
+
this.random = Math.random // decides between equally rare groups; tests replace it with a seeded one
|
|
82
83
|
this.serving = new Map() // root → ContentSource
|
|
83
84
|
this.downloads = new Map() // root → Download
|
|
84
85
|
this.peers = new Map() // peer id → Peer
|
|
@@ -221,10 +222,13 @@ export class Swarmwire extends EventEmitter {
|
|
|
221
222
|
*
|
|
222
223
|
* @param {string|object} link
|
|
223
224
|
* @param {string} dir
|
|
224
|
-
*
|
|
225
|
+
* `onfile({ path, size })` runs as each file completes; `onprogress({ bytes, total })` as bytes of
|
|
226
|
+
* the whole folder arrive, first with `bytes: 0` once the list is known.
|
|
227
|
+
*
|
|
228
|
+
* @param {{onfile?: Function, onprogress?: Function}} [opts]
|
|
225
229
|
* @returns {Promise<{files: number, bytes: number}>}
|
|
226
230
|
*/
|
|
227
|
-
async downloadCollection (link, dir, { onfile = null } = {}) {
|
|
231
|
+
async downloadCollection (link, dir, { onfile = null, onprogress = null } = {}) {
|
|
228
232
|
const parsed = typeof link === 'string' ? parseLink(link) : link
|
|
229
233
|
|
|
230
234
|
await fsx.mkdir(dir)
|
|
@@ -237,14 +241,21 @@ export class Swarmwire extends EventEmitter {
|
|
|
237
241
|
if (!list) throw new Error('the folder list read was stopped')
|
|
238
242
|
|
|
239
243
|
let bytes = 0
|
|
244
|
+
const total = list.size
|
|
245
|
+
onprogress?.({ bytes, total })
|
|
240
246
|
|
|
241
247
|
for (const file of list.files) {
|
|
242
248
|
const target = join(dir, file.path)
|
|
243
249
|
await fsx.mkdir(parentOf(target))
|
|
244
250
|
|
|
245
|
-
|
|
251
|
+
const download = this.downloadFile(parsed, file, target)
|
|
252
|
+
download.on('progress', ({ have }) => {
|
|
253
|
+
onprogress?.({ bytes: bytes + Math.min(have * groupLength(this.groupLog), file.size), total })
|
|
254
|
+
})
|
|
255
|
+
await download
|
|
246
256
|
|
|
247
257
|
bytes += file.size
|
|
258
|
+
onprogress?.({ bytes, total })
|
|
248
259
|
if (onfile) onfile({ path: file.path, size: file.size })
|
|
249
260
|
}
|
|
250
261
|
|
|
@@ -553,6 +564,7 @@ export class Download extends EventEmitter {
|
|
|
553
564
|
|
|
554
565
|
this._destination = null
|
|
555
566
|
this._bitfields = new Map() // peer id -> what that peer says it holds
|
|
567
|
+
this._availability = null // how many of those hold each group; built once the size is known
|
|
556
568
|
this._sources = new Set() // peer ids worth asking right now
|
|
557
569
|
this._liars = new Set() // peer ids that sent something that did not verify: never again
|
|
558
570
|
this._asked = new Set() // peer ids already told what we want
|
|
@@ -675,6 +687,8 @@ export class Download extends EventEmitter {
|
|
|
675
687
|
if (this.stopped) return destination.close()
|
|
676
688
|
|
|
677
689
|
this._destination = destination
|
|
690
|
+
this._availability = new Availability(destination.groupCount)
|
|
691
|
+
for (const bitfield of this._bitfields.values()) this._availability.add(bitfield)
|
|
678
692
|
if (this._destination.complete) return this._finish()
|
|
679
693
|
|
|
680
694
|
for (const peer of this.node.peers.values()) this._askPeer(peer)
|
|
@@ -693,7 +707,7 @@ export class Download extends EventEmitter {
|
|
|
693
707
|
|
|
694
708
|
if (this._liars.has(peer.id)) return // nothing it says is worth storing
|
|
695
709
|
|
|
696
|
-
this.
|
|
710
|
+
this._setBitfield(peer.id, b4a.from(bitfield ?? []))
|
|
697
711
|
this._sources.add(peer.id)
|
|
698
712
|
|
|
699
713
|
if (this.size === null) {
|
|
@@ -737,7 +751,7 @@ export class Download extends EventEmitter {
|
|
|
737
751
|
this.emit('rejected', { index, from: peer.id })
|
|
738
752
|
this._liars.add(peer.id) // a peer that sends rubbish is never asked again
|
|
739
753
|
this._sources.delete(peer.id)
|
|
740
|
-
this.
|
|
754
|
+
this._dropBitfield(peer.id)
|
|
741
755
|
}
|
|
742
756
|
|
|
743
757
|
if (this.complete) return this._finish()
|
|
@@ -770,12 +784,20 @@ export class Download extends EventEmitter {
|
|
|
770
784
|
|
|
771
785
|
const requests = this._requests(peer)
|
|
772
786
|
const window = this._windowFor(peer)
|
|
773
|
-
const
|
|
787
|
+
const destination = this._destination
|
|
788
|
+
const random = this.node.random
|
|
789
|
+
|
|
790
|
+
const pool = candidates({
|
|
791
|
+
count: destination.groupCount,
|
|
792
|
+
has: (index) => destination.has(index),
|
|
793
|
+
peerHas: (index) => this._peerHas(peer.id, index),
|
|
794
|
+
claimed: (index) => this._claimed(index),
|
|
795
|
+
from: Math.floor(random() * destination.groupCount)
|
|
796
|
+
})
|
|
797
|
+
const wanted = rank(pool, this._availability, random)
|
|
774
798
|
|
|
775
799
|
for (const index of wanted) {
|
|
776
800
|
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
801
|
|
|
780
802
|
this._claim(peer, index)
|
|
781
803
|
peer.send.request({ root: b4a.from(this.root, 'hex'), index })
|
|
@@ -819,13 +841,17 @@ export class Download extends EventEmitter {
|
|
|
819
841
|
this._windows.set(peer.id, next)
|
|
820
842
|
}
|
|
821
843
|
|
|
822
|
-
/**
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
844
|
+
/** Remember what a peer holds, keeping the per-group counts in step. */
|
|
845
|
+
_setBitfield (peerId, bitfield) {
|
|
846
|
+
this._dropBitfield(peerId)
|
|
847
|
+
this._bitfields.set(peerId, bitfield)
|
|
848
|
+
this._availability?.add(bitfield)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
_dropBitfield (peerId) {
|
|
852
|
+
const old = this._bitfields.get(peerId)
|
|
853
|
+
if (old) this._availability?.remove(old)
|
|
854
|
+
this._bitfields.delete(peerId)
|
|
829
855
|
}
|
|
830
856
|
|
|
831
857
|
/** Does this peer hold that group? An empty bitfield means we were told nothing useful. */
|
|
@@ -848,7 +874,10 @@ export class Download extends EventEmitter {
|
|
|
848
874
|
this._narrow(peer)
|
|
849
875
|
|
|
850
876
|
const bitfield = this._bitfields.get(peer.id)
|
|
851
|
-
if (bitfield && index < bitfield.length * 8
|
|
877
|
+
if (bitfield && index < bitfield.length * 8 && bao.getBit(bitfield, index)) {
|
|
878
|
+
bao.clearBit(bitfield, index)
|
|
879
|
+
this._availability?.unset(index)
|
|
880
|
+
}
|
|
852
881
|
|
|
853
882
|
this._pumpAll()
|
|
854
883
|
}
|
|
@@ -858,7 +887,10 @@ export class Download extends EventEmitter {
|
|
|
858
887
|
const bitfield = this._bitfields.get(peer.id)
|
|
859
888
|
if (!bitfield || index >= bitfield.length * 8) return
|
|
860
889
|
|
|
861
|
-
bao.
|
|
890
|
+
if (!bao.getBit(bitfield, index)) {
|
|
891
|
+
bao.setBit(bitfield, index)
|
|
892
|
+
this._availability?.set(index)
|
|
893
|
+
}
|
|
862
894
|
this._pump(peer)
|
|
863
895
|
}
|
|
864
896
|
|
|
@@ -906,7 +938,7 @@ export class Download extends EventEmitter {
|
|
|
906
938
|
|
|
907
939
|
_peerGone (peer) {
|
|
908
940
|
this._sources.delete(peer.id)
|
|
909
|
-
this.
|
|
941
|
+
this._dropBitfield(peer.id)
|
|
910
942
|
this._asked.delete(peer.id)
|
|
911
943
|
this._releaseAll(peer.id) // whatever it owed us is up for grabs again
|
|
912
944
|
|
package/lib/tree.js
CHANGED
|
@@ -129,6 +129,7 @@ export class GroupTree {
|
|
|
129
129
|
this.groupHashes = groupHashes
|
|
130
130
|
this.size = size
|
|
131
131
|
this.groupLog = groupLog
|
|
132
|
+
this._known = new Map() // 'start:end' → hash of a larger subtree; a hash once known never changes
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
/** Hash content held in memory. For files, stream it through GroupTreeBuilder instead. */
|
|
@@ -162,7 +163,7 @@ export class GroupTree {
|
|
|
162
163
|
if (!this.complete) return null
|
|
163
164
|
if (this.groupCount === 1) return b4a.toString(this.groupHashes[0], 'hex')
|
|
164
165
|
|
|
165
|
-
return b4a.toString(subtree(this.groupHashes, 0, this.groupCount, true), 'hex')
|
|
166
|
+
return b4a.toString(subtree(this.groupHashes, 0, this.groupCount, true, this._known), 'hex')
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
/**
|
|
@@ -186,8 +187,8 @@ export class GroupTree {
|
|
|
186
187
|
while (end - start > 1) {
|
|
187
188
|
const mid = start + splitPoint(end - start)
|
|
188
189
|
const sibling = index < mid
|
|
189
|
-
? subtree(this.groupHashes, mid, end, false)
|
|
190
|
-
: subtree(this.groupHashes, start, mid, false)
|
|
190
|
+
? subtree(this.groupHashes, mid, end, false, this._known)
|
|
191
|
+
: subtree(this.groupHashes, start, mid, false, this._known)
|
|
191
192
|
|
|
192
193
|
if (sibling === null) return null
|
|
193
194
|
siblings.push(sibling)
|
|
@@ -259,18 +260,28 @@ export function splitPoint (count) {
|
|
|
259
260
|
return p
|
|
260
261
|
}
|
|
261
262
|
|
|
262
|
-
|
|
263
|
-
|
|
263
|
+
// shortcut: only subtrees of at least this many groups are remembered, so the memory is about n/16
|
|
264
|
+
// hashes and a proof recomputes at most ~15 per level; lower it if proofs ever show up in a profile.
|
|
265
|
+
const REMEMBER = 16
|
|
266
|
+
|
|
267
|
+
/** Null when any leaf under this subtree is unknown. `known` remembers the bigger ones. */
|
|
268
|
+
function subtree (hashes, start, end, isRoot, known) {
|
|
264
269
|
if (end - start === 1) return hashes[start] ?? null
|
|
265
270
|
|
|
271
|
+
const key = !isRoot && end - start >= REMEMBER ? start + ':' + end : null
|
|
272
|
+
const remembered = key && known.get(key)
|
|
273
|
+
if (remembered) return remembered
|
|
274
|
+
|
|
266
275
|
const mid = start + splitPoint(end - start)
|
|
267
|
-
const left = subtree(hashes, start, mid, false)
|
|
276
|
+
const left = subtree(hashes, start, mid, false, known)
|
|
268
277
|
if (left === null) return null
|
|
269
278
|
|
|
270
|
-
const right = subtree(hashes, mid, end, false)
|
|
279
|
+
const right = subtree(hashes, mid, end, false, known)
|
|
271
280
|
if (right === null) return null
|
|
272
281
|
|
|
273
|
-
|
|
282
|
+
const hash = bao.parentCV(left, right, isRoot)
|
|
283
|
+
if (key) known.set(key, hash)
|
|
284
|
+
return hash
|
|
274
285
|
}
|
|
275
286
|
|
|
276
287
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "swarmwire-lib",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Content-addressed, verified file transfer between filesystems over Hyperswarm. No copies, no cores, no trackers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"test:bare:net": "bare node_modules/brittle/bin/bare.js test/net/*.test.js",
|
|
68
68
|
"bench": "node bench/swarm.js",
|
|
69
69
|
"bench:net": "node bench/throughput.js",
|
|
70
|
+
"bench:sim": "node bench/swarm-sim.js",
|
|
70
71
|
"check:pack": "node scripts/check-pack.js"
|
|
71
72
|
},
|
|
72
73
|
"dependencies": {
|