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 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 yet. See
59
- > [docs/roadmap.md](docs/roadmap.md) for what is built and what is next.
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 milestones |
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
- | Peer hints (preferred peers, priorities) | planned | 003 | - |
27
- | Admission decisions | planned | 003 | - |
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
- /** How many missing groups to consider at once. */
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
- * Order candidate groups by how few peers hold them, keeping file order as the tie-break so
15
- * a transfer still roughly streams.
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 rarestFirst (missing, holders, { limit = SCAN_LIMIT } = {}) {
27
- const candidates = missing.length > limit ? missing.slice(0, limit) : missing.slice()
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
- return candidates
30
- .map((index) => ({ index, holders: holders(index) }))
31
- .sort((a, b) => a.holders - b.holders || a.index - b.index)
32
- .map((candidate) => candidate.index)
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 { rarestFirst } from './select.js'
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
- * @param {{onfile?: Function}} [opts]
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
- await this.downloadFile(parsed, file, target)
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._bitfields.set(peer.id, b4a.from(bitfield ?? []))
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._bitfields.delete(peer.id)
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 wanted = rarestFirst(this._destination.missing(), (index) => this._holders(index))
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
- /** 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
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) bao.clearBit(bitfield, index)
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.setBit(bitfield, index)
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._bitfields.delete(peer.id)
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
- /** Null when any leaf under this subtree is unknown. */
263
- function subtree (hashes, start, end, isRoot) {
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
- return bao.parentCV(left, right, isRoot)
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.2.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": {