swarmwire-lib 0.1.0 → 0.3.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
@@ -9,35 +9,19 @@ Files are served in place, from wherever they already live on disk. Nothing is c
9
9
  store, a core, or a cache first.
10
10
 
11
11
  ```
12
- npm i swarmwire-lib
12
+ npm i swarmwire-lib hyperswarm
13
13
  ```
14
14
 
15
- One side serves a file and prints a link. The other side hands the link to `download`. (This is
16
- `examples/send-get.js`; in the repository it imports `../index.js` instead of `swarmwire-lib`. It is
17
- an ES module, so your project needs `"type": "module"` or a `.mjs` file.)
15
+ One side serves a file and prints a link. The other side hands the link to `download`:
18
16
 
19
17
  ```js
20
- /**
21
- * Send a file to someone, or get one.
22
- *
23
- * node examples/send-get.js send ./holidays.mp4
24
- * node examples/send-get.js get 'swarmwire://…' ./holidays.mp4
25
- *
26
- * `--bootstrap host:port` points both sides at a private DHT; leave it out for the real one.
27
- */
28
-
29
18
  import process from 'process'
30
19
  import Hyperswarm from 'hyperswarm'
31
- import DHT from 'hyperdht'
32
-
33
20
  import { Swarmwire } from 'swarmwire-lib'
34
21
 
35
- const args = process.argv.slice(2)
36
- const flag = args.indexOf('--bootstrap')
37
- const bootstrap = flag === -1 ? null : args.splice(flag, 2)[1]
38
- const [mode, target, out] = args
22
+ const [mode, target, out] = process.argv.slice(2)
39
23
 
40
- const swarm = bootstrap ? privateSwarm(bootstrap) : new Hyperswarm()
24
+ const swarm = new Hyperswarm()
41
25
  const node = new Swarmwire({ swarm })
42
26
 
43
27
  if (mode === 'send') {
@@ -47,22 +31,16 @@ if (mode === 'send') {
47
31
  } else if (mode === 'get') {
48
32
  await node.download(target, out)
49
33
  console.log(out)
50
- } else {
51
- console.log('usage: send <file> | get <link> <out> [--bootstrap host:port]')
52
- process.exitCode = 1
53
34
  }
54
35
 
55
36
  await node.destroy()
56
37
  await swarm.destroy()
57
-
58
- // A loopback DHT only works if the node binds there and is not firewalled.
59
- function privateSwarm (address) {
60
- const [host, port] = address.split(':')
61
- const dht = new DHT({ bootstrap: [{ host, port: Number(port) }], ephemeral: false, firewalled: false, host: '127.0.0.1' })
62
- return new Hyperswarm({ dht })
63
- }
64
38
  ```
65
39
 
40
+ This is `examples/send-get.js` without its `--bootstrap` option, which points both sides at a
41
+ private DHT so the test suite needs no internet. It is an ES module, so your project needs
42
+ `"type": "module"` or a `.mjs` file.
43
+
66
44
  ```
67
45
  $ node examples/send-get.js send ./holidays.mp4
68
46
  swarmwire://10db6398a68dff502d002912f54502fdde9ab55fb30bec32861a2f004d874fc8?s=734003200&n=holidays.mp4
@@ -72,11 +50,13 @@ $ node examples/send-get.js get 'swarmwire://10db63…' ./holidays.mp4
72
50
  ```
73
51
 
74
52
  A folder goes the same way, as one link: `serveCollection` and `downloadCollection`.
75
- The command-line tool built on this library is moving to its own package, `swarmwire-cli`.
53
+ The command-line tool built on this library is moving to its own package,
54
+ [`swarmwire-cli`](https://github.com/tibocub/swarmwire-cli) ([npm](https://www.npmjs.com/package/swarmwire-cli)).
76
55
 
77
- > Status: **pre-alpha.** Transfers work end to end, over a real swarm, with resume and
78
- > verification. Multi-peer scheduling is still naive and there are no request timeouts yet —
79
- > see [docs/roadmap.md](docs/roadmap.md) for what M2 adds.
56
+ > Status: **pre-alpha.** Transfers work end to end, over a real swarm, with resume,
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.
80
60
 
81
61
  ## What it is
82
62
 
package/docs/contract.md CHANGED
@@ -19,12 +19,13 @@ code (`serveCollection`, `downloadCollection`). See `AGENTS.md` for the invarian
19
19
  | Serve a file in place, download by link, resume, verify every range | exists | - | `test/swarm/transfer.test.js` |
20
20
  | Serve and download a folder as one link | exists | - | `test/swarm/collection.test.js` |
21
21
  | Partial seeding: a download in progress serves what it holds | exists | - | `test/swarm/partial.test.js` |
22
- | Application supplies the network connection (any object with `join`, `leave`, a `connection` event) | exists | - | `test/helpers/network.js`, `test/net/real-swarm.test.js` |
22
+ | Application supplies the network connection (any object with `join(topic, opts)` returning a session with `destroy()`, and a `connection` event; Hyperswarm is one) | exists | - | `test/helpers/network.js`, `test/net/real-swarm.test.js` |
23
23
  | Peer identity: the remote public key of the connection | exists | - | `test/swarm/transfer.test.js` |
24
- | Peer hints (preferred peers, priorities) | planned | 002 | - |
25
- | Admission decisions | planned | 002 | - |
26
- | Peer activity events and counts | planned | 002 | - |
27
- | Folder file-list reader, public | planned | 002 | - |
24
+ | Peer activity events, peer count, seeder and leecher counts | exists | - | `test/swarm/peers.test.js` |
25
+ | Folder file-list reader (look-only, in memory), and downloading one listed file | exists | - | `test/swarm/list.test.js` |
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
 
@@ -39,8 +40,12 @@ a peer because of a hint. The application dials through the connection it owns.
39
40
  The application can decide, per peer and per content, whether content is served.
40
41
 
41
42
  - Decided **per content root**. Each file of a folder is its own root and is decided separately;
42
- the decision is told which folder root the file was requested through, if any.
43
- - **Deny on failure**: a decision that throws, times out or never answers is a deny.
43
+ the decision is told which folder root the file was requested through, if any. The folder's own
44
+ file list is content too, with its own root, and is decided like any other root.
45
+ - **Deny on failure**: a decision that throws, times out or never answers is a deny. How long a
46
+ decision may take is an option of the admission mechanism, set by the application (its
47
+ default is chosen when the mechanism is built); the contract promises only that running out
48
+ of time is a deny.
44
49
  - A deny is answered **exactly as content swarmwire does not hold**. A refused peer learns nothing
45
50
  about what is held, its size or its name.
46
51
  - With no decision installed, content is served as it is today.
@@ -49,10 +54,32 @@ The application can decide, per peer and per content, whether content is served.
49
54
 
50
55
  ### 3.3 Peer activity
51
56
 
52
- Events when a peer connects and disconnects, and, for one content root, the number of connected
53
- peers and how many hold all of it. **Direct connections only**: a snapshot that may lag, not a
54
- count of the swarm. Global counts and chunk rarity across the network are a tracker's job, not
55
- swarmwire's.
57
+ `Swarmwire` is an event emitter.
58
+
59
+ - `peer-connected` and `peer-disconnected`, each with `{ id }` (the remote public key, hex). Each
60
+ fires **once per distinct peer**, and only for a peer that completed the swarmwire handshake: a
61
+ connection that never speaks the protocol is neither announced nor counted. Two connections
62
+ from one key are one peer. A peer that vanishes without closing is reported when its connection
63
+ closes; `peerCount` is already correct when the event fires.
64
+ - `peerCount`: connected peers, never negative.
65
+ - `counts(link)` returns `{ peers, seeders, leechers }` for a file or folder link, synchronously.
66
+ A **seeder** holds all of it, a **leecher** holds part, and a peer that has said nothing about
67
+ that content is in `peers` only. For a folder, a seeder holds the file list and every listed
68
+ file; a peer holding the list alone is a leecher. Content the node knows nothing about, and a
69
+ folder whose list is not yet known, give zeros, never an error.
70
+ - A node that only serves still learns what its peers hold: it asks each connected peer about
71
+ the content it serves, with the ordinary `want` message. No message was added. A peer that does
72
+ not answer is simply uncounted.
73
+
74
+ **Direct connections only**: a snapshot that may lag, not a count of the swarm. Global counts and
75
+ chunk rarity across the network are a tracker's job, not swarmwire's.
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).
56
83
 
57
84
  ### 3.4 Identity
58
85
 
@@ -61,9 +88,13 @@ key to a user or device is the application's job (Principle II).
61
88
 
62
89
  ### 3.5 Choosing files of a folder
63
90
 
64
- Read the folder's file list without fetching content, then download the chosen files one by one.
65
- Which files, and any interface for choosing, belong to the application. *Planned: the list reader
66
- is not public yet.*
91
+ `readList(link)` reads a folder's file list without fetching any content: it resolves to
92
+ `{ files: [{ path, root, size }], size }`, and `stop()` gives up waiting, like a download. It
93
+ writes nothing to disk and only **looks** for peers: it never announces this node as a provider.
94
+ A link that is not a folder is refused, and so is a list with an unsafe path (the same paths a
95
+ whole-folder download refuses; nothing is repaired). `downloadFile(link, file, outPath)` then
96
+ downloads one listed file, using only the folder's link and the list entry, and does not announce
97
+ it separately. Which files, and any interface for choosing, belong to the application.
67
98
 
68
99
  ### 3.6 The shared connection
69
100
 
@@ -109,9 +140,10 @@ DEFAULT_GROUP_LOG
109
140
  ```
110
141
 
111
142
  **Stable** (changes bump the version and are in the changelog): `Swarmwire`
112
- (`serve`, `serveCollection`, `download`, `downloadCollection`, `unserve`, `destroy`), `Download`
113
- (awaitable; events `progress`, `rejected`, `timeout`, `stalled`), `rootOf`, `formatLink`,
114
- `parseLink`, `SCHEME`.
143
+ (`serve`, `serveCollection`, `download`, `downloadCollection`, `readList`, `downloadFile`,
144
+ `unserve`, `destroy`, `peerCount`, `counts`; events `peer-connected`, `peer-disconnected`),
145
+ `Download` (awaitable; events `progress`, `rejected`, `timeout`, `stalled`), `rootOf`,
146
+ `formatLink`, `parseLink`, `SCHEME`.
115
147
 
116
148
  **Exported for tests and advanced use, may change in any 0.x**: everything else.
117
149
 
package/docs/format.md CHANGED
@@ -166,8 +166,13 @@ announce.
166
166
  size, request block size, wire messages, scheduling, how outboard trees are cached, the sidecar
167
167
  format for partial downloads.
168
168
 
169
- Protocol messages carry a version and a capability set in the handshake. Unknown message types
170
- are ignored rather than treated as errors, so new message types never break old peers.
169
+ The handshake carries a version and the group size (`groupLog`); it carries no capability set.
170
+ Unknown message types are ignored rather than treated as errors, so new message types never
171
+ break old peers.
172
+
173
+ What a peer holds is learned by asking: `want` is answered with `have` (or `unavailable`) and
174
+ followed by `holds` as the peer gains groups. A node that only serves sends `want` for what it
175
+ serves, which is how it counts its seeders and leechers. No message exists for this.
171
176
 
172
177
  ## Open questions
173
178
 
package/lib/collection.js CHANGED
@@ -18,7 +18,7 @@ import b4a from 'b4a'
18
18
  export const MANIFEST_TYPE = 'collection/v0'
19
19
 
20
20
  /** Enough for a folder of a million files; past that, something has gone wrong. */
21
- const MAX_MANIFEST_BYTES = 64 * 1024 * 1024
21
+ export const MAX_MANIFEST_BYTES = 64 * 1024 * 1024
22
22
 
23
23
  /**
24
24
  * @param {{path: string, root: string, size: number}[]} files
@@ -0,0 +1,125 @@
1
+ /**
2
+ * What connected peers say they hold, and the counts that follow from it.
3
+ *
4
+ * A node only hears about a peer's holdings if it asked: `want` is answered with `have`, and
5
+ * the peer then pushes `holds` as it gains groups. A node that only serves never asks, so it
6
+ * keeps a watch set — the roots it serves — and sends `want` for those to every peer. That is
7
+ * the whole mechanism; there is no message for it.
8
+ *
9
+ * Counts are of direct connections. Nothing here knows about the rest of the swarm.
10
+ */
11
+
12
+ import b4a from 'b4a'
13
+
14
+ import { bao } from './blake3.js'
15
+ import { DEFAULT_GROUP_LOG, groupCount } from './tree.js'
16
+
17
+ export class Holdings {
18
+ constructor ({ groupLog = DEFAULT_GROUP_LOG } = {}) {
19
+ this.groupLog = groupLog
20
+ this.peers = new Map() // peer id → Map(root hex → { size, total, bits, count })
21
+ this.watched = new Map() // root hex → size: roots we ask every peer about
22
+ }
23
+
24
+ /** A peer described what it holds (`have`). Replaces whatever we knew. */
25
+ set (peerId, root, { size, groupLog, bitfield }) {
26
+ if (groupLog !== this.groupLog) return // we cannot read that granularity: unknown, not guessed
27
+
28
+ const total = groupCount(size, this.groupLog)
29
+ const bits = bao.createBitfield(total)
30
+ bits.set(b4a.from(bitfield ?? []).subarray(0, bits.length))
31
+
32
+ this._of(peerId).set(root, { size, total, bits, count: bao.countSetBits(bits, total) })
33
+ }
34
+
35
+ /**
36
+ * A peer gained one group (`holds`). Without a `have` to build on, a known `size` stands in:
37
+ * a peer that first said "unavailable" and then began downloading only ever sends this.
38
+ */
39
+ grow (peerId, root, index, size) {
40
+ let held = this.get(peerId, root)
41
+
42
+ if (!held) {
43
+ if (size === undefined) return
44
+ const total = groupCount(size, this.groupLog)
45
+ held = { size, total, bits: bao.createBitfield(total), count: 0 }
46
+ this._of(peerId).set(root, held)
47
+ }
48
+
49
+ if (index < 0 || index >= held.total || bao.getBit(held.bits, index)) return
50
+
51
+ bao.setBit(held.bits, index)
52
+ held.count++
53
+ }
54
+
55
+ /** The peer says it has none of this (`unavailable`). */
56
+ remove (peerId, root) {
57
+ this.peers.get(peerId)?.delete(root)
58
+ }
59
+
60
+ dropPeer (peerId) {
61
+ this.peers.delete(peerId)
62
+ }
63
+
64
+ get (peerId, root) {
65
+ return this.peers.get(peerId)?.get(root)
66
+ }
67
+
68
+ /**
69
+ * @param {Iterable<string>} peerIds - the connected peers to count among
70
+ * @param {string[]} roots - one root for a file; the manifest and every file for a folder
71
+ * @returns {{peers: number, seeders: number, leechers: number}} a seeder holds every group
72
+ * of every root, a leecher holds some but not all, anyone else is in neither
73
+ */
74
+ count (peerIds, roots) {
75
+ const counts = { peers: 0, seeders: 0, leechers: 0 }
76
+
77
+ for (const id of peerIds) {
78
+ counts.peers++
79
+ if (roots.length === 0) continue
80
+
81
+ let complete = true
82
+ let any = false
83
+
84
+ for (const root of roots) {
85
+ const held = this.get(id, root)
86
+ if (!held || held.count < held.total) complete = false
87
+ if (held && held.count > 0) any = true
88
+ }
89
+
90
+ if (complete) counts.seeders++
91
+ else if (any) counts.leechers++
92
+ }
93
+
94
+ return counts
95
+ }
96
+
97
+ // -- the watch set ---------------------------------------------------------
98
+
99
+ /** Ask every peer about a root we hold or follow, now and whenever a new peer connects. */
100
+ watch (root, size, peers, skip) {
101
+ this.watched.set(root, size)
102
+ for (const peer of peers) this._want(peer, root, size, skip)
103
+ }
104
+
105
+ unwatch (root) {
106
+ this.watched.delete(root)
107
+ }
108
+
109
+ /** A peer just connected: tell it what we are watching. */
110
+ wantAll (peer, skip) {
111
+ for (const [root, size] of this.watched) this._want(peer, root, size, skip)
112
+ }
113
+
114
+ _want (peer, root, size, skip) {
115
+ // shortcut: wants for every file of a served folder; watch lazily if folders grow past ~10k files
116
+ if (skip(root)) return // a download already asked this peer
117
+ peer.send.want({ root: b4a.from(root, 'hex'), size })
118
+ }
119
+
120
+ _of (peerId) {
121
+ let held = this.peers.get(peerId)
122
+ if (!held) this.peers.set(peerId, held = new Map())
123
+ return held
124
+ }
125
+ }
package/lib/listing.js ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Reading a folder's file list: fetch the manifest into memory, validate it, and leave nothing
3
+ * behind. Nothing here touches disk, and it only ever looks for peers — a node that is merely
4
+ * reading a list is not a provider of the folder.
5
+ */
6
+
7
+ import b4a from 'b4a'
8
+ import { bao } from './blake3.js'
9
+
10
+ import { decodeManifest, totalSize, MAX_MANIFEST_BYTES } from './collection.js'
11
+ import { discoveryTopic } from './address.js'
12
+ import { GroupTree, groupHash, verifyGroup, groupCount } from './tree.js'
13
+ import { NodeStore } from './nodes.js'
14
+
15
+ /**
16
+ * The same surface a `Download` uses on a file destination, kept in memory: verify a group
17
+ * before storing it, remember the proofs, hand groups back out. A manifest is small and capped,
18
+ * so there is no resume state to keep.
19
+ */
20
+ export class MemoryDestination {
21
+ constructor ({ root, size, groupLog }) {
22
+ if (size > MAX_MANIFEST_BYTES) throw new Error('manifest is implausibly large')
23
+
24
+ this.root = root
25
+ this.size = size
26
+ this.groupLog = groupLog
27
+ this.groupCount = groupCount(size, groupLog)
28
+
29
+ this._groups = new Map() // index → bytes
30
+ this._bitfield = bao.createBitfield(this.groupCount)
31
+ this._nodes = new NodeStore(this.groupCount)
32
+ }
33
+
34
+ get have () { return this._groups.size }
35
+ get complete () { return this._groups.size === this.groupCount }
36
+ has (index) { return bao.getBit(this._bitfield, index) }
37
+ bitfield () { return this._bitfield }
38
+
39
+ missing () {
40
+ const out = []
41
+ for (let i = 0; i < this.groupCount; i++) if (!this.has(i)) out.push(i)
42
+ return out
43
+ }
44
+
45
+ async put (index, data, proof) {
46
+ if (!Number.isInteger(index) || index < 0 || index >= this.groupCount) return false
47
+ if (this.has(index)) return true
48
+
49
+ const { root, size, groupLog } = this
50
+ if (!verifyGroup({ root, size, index, data, proof, groupLog })) return false
51
+
52
+ const hash = groupHash(data, index, { groupLog, only: this.groupCount === 1 })
53
+ this._nodes.learn(index, hash, proof)
54
+ this._groups.set(index, b4a.from(data))
55
+ bao.setBit(this._bitfield, index)
56
+
57
+ return true
58
+ }
59
+
60
+ get tree () {
61
+ return new GroupTree(this._nodes.leaves(), { size: this.size, groupLog: this.groupLog })
62
+ }
63
+
64
+ proof (index) {
65
+ return this.has(index) ? this._nodes.proof(index) : null
66
+ }
67
+
68
+ async readGroup (index) {
69
+ return this._groups.get(index) ?? null
70
+ }
71
+
72
+ /** The whole content, once every group is held. */
73
+ bytes () {
74
+ const parts = []
75
+ for (let i = 0; i < this.groupCount; i++) parts.push(this._groups.get(i))
76
+ return b4a.concat(parts)
77
+ }
78
+
79
+ async finish () {}
80
+ async close () {}
81
+ }
82
+
83
+ /**
84
+ * A folder's file list being read. Thenable, like a `Download`: `await listing` resolves to
85
+ * `{ files: [{ path, root, size }], size }`, and `stop()` gives up waiting, which resolves it
86
+ * to `undefined`.
87
+ */
88
+ export class Listing {
89
+ /** A read that failed before it began, such as a link that is not a folder. */
90
+ static failed (err) {
91
+ const listing = Object.create(Listing.prototype)
92
+ listing._promise = Promise.reject(err)
93
+ listing._promise.catch(() => {}) // the caller sees it; this only keeps it from being unhandled
94
+ listing.stop = async () => {}
95
+ return listing
96
+ }
97
+
98
+ /** A read that needs no network: the list is already known. */
99
+ static known (files) {
100
+ const listing = Object.create(Listing.prototype)
101
+ listing._promise = Promise.resolve({ files, size: totalSize(files) })
102
+ listing.stop = async () => {}
103
+ return listing
104
+ }
105
+
106
+ constructor (node, parsed) {
107
+ this._download = null
108
+ this._session = null
109
+ this._stopped = false
110
+
111
+ this._promise = this._run(node, parsed)
112
+ this._promise.catch(() => {})
113
+ }
114
+
115
+ async stop () {
116
+ this._stopped = true
117
+ await this._download?.stop()
118
+ }
119
+
120
+ then (onfulfilled, onrejected) {
121
+ return this._promise.then(onfulfilled, onrejected)
122
+ }
123
+
124
+ catch (onrejected) {
125
+ return this._promise.catch(onrejected)
126
+ }
127
+
128
+ finally (onfinally) {
129
+ return this._promise.finally(onfinally)
130
+ }
131
+
132
+ async _run (node, parsed) {
133
+ if (node.downloads.has(parsed.root)) throw new Error('that link is already being downloaded as a file')
134
+
135
+ let memory = null
136
+
137
+ // Only look: with no server session on the topic this node is never announced for it.
138
+ this._session = node.swarm.join(discoveryTopic(parsed.root, parsed.secret), { server: false, client: true })
139
+
140
+ try {
141
+ this._download = node.download({ ...parsed, name: null }, null, {
142
+ announce: false,
143
+ destination: (options) => (memory = new MemoryDestination(options))
144
+ })
145
+ if (this._stopped) await this._download.stop()
146
+
147
+ await this._download
148
+ if (this._stopped) return undefined
149
+
150
+ const files = decodeManifest(memory.bytes())
151
+ node._learnList(parsed.root, files)
152
+
153
+ return { files, size: totalSize(files) }
154
+ } finally {
155
+ await this._session?.destroy?.()
156
+ }
157
+ }
158
+ }
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
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * A swarmwire node: serve content you already have, download content you want.
3
3
  *
4
- * It takes a swarm — anything with `join(topic)`, `leave(topic)` and a `connection` event —
4
+ * It takes a swarm — anything with `join(topic, opts)` (returning a session with `destroy()`) and a `connection` event —
5
5
  * so the same code runs against Hyperswarm and against the deterministic test network.
6
6
  *
7
7
  * Scheduling is deliberately plain for now: ask every peer that has the content, keep a small
@@ -20,7 +20,9 @@ import { Destination } from './destination.js'
20
20
  import { discoveryTopic, formatLink, parseLink } from './address.js'
21
21
  import { DEFAULT_GROUP_LOG, groupCount } 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
+ import { Holdings } from './holdings.js'
25
+ import { Listing } from './listing.js'
24
26
  import { systemTimers } from './timers.js'
25
27
  import * as fsx from './fs.js'
26
28
 
@@ -52,7 +54,14 @@ const REQUEST_TIMEOUT = 15000
52
54
  /** Timeouts a peer gets before it stops being asked. */
53
55
  const MAX_TIMEOUTS = 3
54
56
 
55
- export class Swarmwire {
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 {
56
65
  /**
57
66
  * @param {{swarm: object, groupLog?: number, timers?: object, requestTimeout?: number,
58
67
  * window?: number}} opts
@@ -64,14 +73,20 @@ export class Swarmwire {
64
73
  requestTimeout = REQUEST_TIMEOUT,
65
74
  window = WINDOW
66
75
  }) {
76
+ super()
67
77
  this.swarm = swarm
68
78
  this.groupLog = groupLog
69
79
  this.timers = timers
70
80
  this.requestTimeout = requestTimeout
71
81
  this.window = window
82
+ this.random = Math.random // decides between equally rare groups; tests replace it with a seeded one
72
83
  this.serving = new Map() // root → ContentSource
73
84
  this.downloads = new Map() // root → Download
74
85
  this.peers = new Map() // peer id → Peer
86
+ this.holdings = new Holdings({ groupLog }) // what those peers say they hold
87
+ this.lists = new Map() // folder root → its file entries, once known
88
+ this._listings = new Map() // folder root → the list read in progress
89
+ this._sessions = new Map() // served root → its announce session, ended by unserve
75
90
 
76
91
  this._onconnection = (stream, info) => this._onPeer(stream, info)
77
92
  this.swarm.on('connection', this._onconnection)
@@ -91,10 +106,11 @@ export class Swarmwire {
91
106
  const existing = this.serving.get(source.root)
92
107
  if (existing && existing !== source) await source.close()
93
108
  else this.serving.set(source.root, source)
109
+ this._watch(source.root, source.size)
94
110
 
95
111
  // Wait for the announce to actually land. Until it has, a peer looking this content up
96
112
  // finds nobody, which is indistinguishable from "that link does not work".
97
- const discovery = this.swarm.join(discoveryTopic(source.root, secret))
113
+ const discovery = this._announce(source.root, discoveryTopic(source.root, secret))
98
114
  if (typeof discovery?.flushed === 'function') await discovery.flushed()
99
115
 
100
116
  return {
@@ -126,12 +142,15 @@ export class Swarmwire {
126
142
  else await source.close()
127
143
 
128
144
  entries.push({ path: relative, root: source.root, size: source.size })
145
+ this._watch(source.root, source.size)
129
146
  }
130
147
 
131
148
  const manifest = MemorySource.from(encodeManifest(entries), { groupLog: this.groupLog })
132
149
  this.serving.set(manifest.root, manifest)
150
+ this.lists.set(manifest.root, entries)
151
+ this._watch(manifest.root, manifest.size)
133
152
 
134
- const discovery = this.swarm.join(discoveryTopic(manifest.root, secret))
153
+ const discovery = this._announce(manifest.root, discoveryTopic(manifest.root, secret))
135
154
  if (typeof discovery?.flushed === 'function') await discovery.flushed()
136
155
 
137
156
  return {
@@ -143,7 +162,63 @@ export class Swarmwire {
143
162
  }
144
163
 
145
164
  /**
146
- * Fetch a collection: the manifest first, then everything it names, into `dir`.
165
+ * Read a folder's file list without fetching any of it: each file's path, root and size, and
166
+ * the total. Nothing is written to disk and this node is never announced as a provider.
167
+ *
168
+ * @param {string|object} link - a folder link
169
+ * @returns {Listing} thenable, resolving to `{ files, size }`; `stop()` gives up waiting
170
+ */
171
+ readList (link) {
172
+ const parsed = typeof link === 'string' ? parseLink(link) : link
173
+ if (parsed.type !== 'collection') return Listing.failed(new Error('not a folder: that link is a single file'))
174
+
175
+ return this._list(parsed)
176
+ }
177
+
178
+ /**
179
+ * Fetch one file of a folder, using only the folder's link and an entry from its list. It is
180
+ * not announced on its own: the peers holding it are the ones connected through the folder.
181
+ *
182
+ * @param {string|object} link - the folder's link
183
+ * @param {{path: string, root: string, size: number}} file - an entry of `readList`'s result
184
+ * @param {string} outPath - where to write it; its directory must exist
185
+ * @returns {Download}
186
+ */
187
+ downloadFile (link, file, outPath) {
188
+ const { secret } = typeof link === 'string' ? parseLink(link) : link
189
+
190
+ return this.download(
191
+ { root: file.root, size: file.size, secret, name: file.path },
192
+ outPath,
193
+ { announce: false }
194
+ )
195
+ }
196
+
197
+ /** One read per folder at a time: asking again while one runs shares it (and its `stop`). */
198
+ _list (parsed) {
199
+ // shortcut: a folder's list is kept for the node's lifetime; evict if a node reads thousands of folders
200
+ const known = this.lists.get(parsed.root)
201
+ if (known) return Listing.known(known)
202
+
203
+ let listing = this._listings.get(parsed.root)
204
+ if (!listing) {
205
+ listing = new Listing(this, parsed)
206
+ this._listings.set(parsed.root, listing)
207
+ const done = () => this._listings.delete(parsed.root)
208
+ listing.then(done, done)
209
+ }
210
+
211
+ return listing
212
+ }
213
+
214
+ /** A folder's list became known: remember it, and ask peers what they hold of its files. */
215
+ _learnList (root, files) {
216
+ this.lists.set(root, files)
217
+ for (const file of files) this._watch(file.root, file.size)
218
+ }
219
+
220
+ /**
221
+ * Fetch a collection: the list first, then every file it names, into `dir`.
147
222
  *
148
223
  * @param {string|object} link
149
224
  * @param {string} dir
@@ -154,43 +229,27 @@ export class Swarmwire {
154
229
  const parsed = typeof link === 'string' ? parseLink(link) : link
155
230
 
156
231
  await fsx.mkdir(dir)
157
- const files = await this._fetchManifest(parsed, dir)
232
+
233
+ // Reading the list only looks; downloading a folder also announces it, so whoever fetches
234
+ // it can be fetched from in turn.
235
+ this.swarm.join(discoveryTopic(parsed.root, parsed.secret), ANNOUNCE)
236
+
237
+ const list = await this._list(parsed)
238
+ if (!list) throw new Error('the folder list read was stopped')
158
239
 
159
240
  let bytes = 0
160
241
 
161
- for (const file of files) {
242
+ for (const file of list.files) {
162
243
  const target = join(dir, file.path)
163
244
  await fsx.mkdir(parentOf(target))
164
245
 
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
- )
246
+ await this.downloadFile(parsed, file, target)
172
247
 
173
248
  bytes += file.size
174
249
  if (onfile) onfile({ path: file.path, size: file.size })
175
250
  }
176
251
 
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
- }
252
+ return { files: list.files.length, bytes }
194
253
  }
195
254
 
196
255
  /** Stop serving content. The file itself is untouched. */
@@ -199,9 +258,54 @@ export class Swarmwire {
199
258
  if (!source) return
200
259
 
201
260
  this.serving.delete(root)
261
+ this.holdings.unwatch(root)
262
+ this.lists.delete(root)
263
+ await this._sessions.get(root)?.destroy?.()
264
+ this._sessions.delete(root)
202
265
  await source.close()
203
266
  }
204
267
 
268
+ _announce (root, topic) {
269
+ if (!this._sessions.has(root)) this._sessions.set(root, this.swarm.join(topic, ANNOUNCE))
270
+ return this._sessions.get(root)
271
+ }
272
+
273
+ /** Connected peers that completed the handshake, counting each distinct peer once. */
274
+ get peerCount () {
275
+ return this._ready().length
276
+ }
277
+
278
+ /**
279
+ * How many direct connections hold this content: all of it (seeders) or part of it
280
+ * (leechers). It describes who is connected to this node, not the swarm, and a peer that has
281
+ * said nothing about the content is counted in `peers` only. A folder's counts need its file
282
+ * list, which a node knows once it has served, read or downloaded it; until then they are 0.
283
+ *
284
+ * @param {string|object} link
285
+ * @returns {{peers: number, seeders: number, leechers: number}}
286
+ */
287
+ counts (link) {
288
+ const parsed = typeof link === 'string' ? parseLink(link) : link
289
+ const list = this.lists.get(parsed.root)
290
+
291
+ let roots = [parsed.root]
292
+ if (list) roots = [parsed.root, ...list.map((file) => file.root)]
293
+ else if (parsed.type === 'collection') roots = []
294
+
295
+ return this.holdings.count(this._ready(), roots)
296
+ }
297
+
298
+ _ready () {
299
+ const ids = []
300
+ for (const peer of this.peers.values()) if (peer.ready) ids.push(peer.id)
301
+ return ids
302
+ }
303
+
304
+ /** Ask every peer about a root we hold, unless a download already did. */
305
+ _watch (root, size) {
306
+ this.holdings.watch(root, size, this.peers.values(), (r) => this.downloads.has(r))
307
+ }
308
+
205
309
  /**
206
310
  * Fetch content by link. Returns immediately; await the result for completion, or
207
311
  * `await download.ready()` to know the destination has been opened.
@@ -210,19 +314,19 @@ export class Swarmwire {
210
314
  * @param {string} outPath
211
315
  * @returns {Download}
212
316
  */
213
- download (link, outPath, { announce = true } = {}) {
317
+ download (link, outPath, { announce = true, destination = null } = {}) {
214
318
  const parsed = typeof link === 'string' ? parseLink(link) : link
215
319
 
216
320
  const existing = this.downloads.get(parsed.root)
217
321
  if (existing) return existing
218
322
 
219
- const download = new Download(this, parsed, outPath, { announced: announce })
323
+ const download = new Download(this, parsed, outPath, { announced: announce, destination })
220
324
  this.downloads.set(parsed.root, download)
221
325
 
222
326
  // A file inside a collection is not announced on its own: whoever has it is already
223
327
  // connected through the collection's topic, and a folder of a thousand files should not
224
328
  // mean a thousand announces.
225
- if (announce) this.swarm.join(discoveryTopic(parsed.root, parsed.secret))
329
+ if (announce) download._session = this.swarm.join(discoveryTopic(parsed.root, parsed.secret), ANNOUNCE)
226
330
 
227
331
  download._start()
228
332
 
@@ -242,8 +346,10 @@ export class Swarmwire {
242
346
 
243
347
  _onPeer (stream, info) {
244
348
  const id = peerId(info)
349
+ let peer = null
350
+
245
351
  const channel = openChannel(stream, {
246
- onhandshake: () => {},
352
+ onhandshake: () => peer && this._onHandshake(peer),
247
353
  onwant: (message) => this._onWant(id, message),
248
354
  onhave: (message) => this._onHave(id, message),
249
355
  onrequest: (message) => this._onRequest(id, message),
@@ -251,12 +357,13 @@ export class Swarmwire {
251
357
  onunavailable: (message) => this._onUnavailable(id, message),
252
358
  onholds: (message) => this._onHolds(id, message),
253
359
  onreject: (message) => this._onReject(id, message),
254
- onclose: () => this._onPeerGone(id)
360
+ onclose: () => peer && this._onPeerGone(peer)
255
361
  })
256
362
 
257
363
  if (channel === null) return
258
364
 
259
- const peer = { id, send: channel.send, wants: new Set() }
365
+ // A second connection from the same peer replaces the first without announcing it again.
366
+ peer = { id, send: channel.send, wants: new Set(), ready: this.peers.get(id)?.ready === true }
260
367
  this.peers.set(id, peer)
261
368
 
262
369
  peer.send.handshake({ version: VERSION, groupLog: this.groupLog })
@@ -264,14 +371,26 @@ export class Swarmwire {
264
371
  // Tell a new peer what we are after. A peer that has it answers; one that does not stays
265
372
  // quiet and is simply never asked.
266
373
  for (const download of this.downloads.values()) download._askPeer(peer)
374
+ this.holdings.wantAll(peer, (root) => this.downloads.has(root))
267
375
  }
268
376
 
269
- _onPeerGone (id) {
270
- const peer = this.peers.get(id)
271
- if (!peer) return
377
+ /** The remote spoke swarmwire: from here on it is a peer, not just a connection. */
378
+ _onHandshake (peer) {
379
+ if (peer.ready) return
380
+
381
+ peer.ready = true
382
+ this.emit('peer-connected', { id: peer.id })
383
+ }
384
+
385
+ _onPeerGone (peer) {
386
+ // The connection that closed may be one a newer connection from the same peer replaced.
387
+ if (this.peers.get(peer.id) !== peer) return
272
388
 
273
- this.peers.delete(id)
389
+ this.peers.delete(peer.id)
390
+ this.holdings.dropPeer(peer.id)
274
391
  for (const download of this.downloads.values()) download._peerGone(peer)
392
+
393
+ if (peer.ready) this.emit('peer-disconnected', { id: peer.id })
275
394
  }
276
395
 
277
396
  _onWant (id, { root }) {
@@ -348,7 +467,9 @@ export class Swarmwire {
348
467
  if (this.serving.has(root)) return
349
468
 
350
469
  try {
351
- this.serving.set(root, await ContentSource.adopt(path, tree))
470
+ const source = await ContentSource.adopt(path, tree)
471
+ this.serving.set(root, source)
472
+ this._watch(root, source.size) // peers that connect from now on are asked what they hold
352
473
  } catch {
353
474
  // The file was moved or deleted between finishing and adopting it. Nothing to serve.
354
475
  }
@@ -364,9 +485,14 @@ export class Swarmwire {
364
485
  }
365
486
 
366
487
  _onHolds (id, message) {
367
- const download = this.downloads.get(hex(message.root))
488
+ const rootHex = hex(message.root)
489
+ const download = this.downloads.get(rootHex)
368
490
  const peer = this.peers.get(id)
369
- if (download && peer) download._onHolds(peer, message)
491
+ if (!peer) return
492
+
493
+ const size = this.serving.get(rootHex)?.size ?? download?.size ?? this.holdings.watched.get(rootHex)
494
+ this.holdings.grow(id, rootHex, message.index, size ?? undefined)
495
+ download?._onHolds(peer, message)
370
496
  }
371
497
 
372
498
  _onReject (id, message) {
@@ -376,9 +502,13 @@ export class Swarmwire {
376
502
  }
377
503
 
378
504
  _onHave (id, message) {
379
- const download = this.downloads.get(hex(message.root))
505
+ const rootHex = hex(message.root)
506
+ const download = this.downloads.get(rootHex)
380
507
  const peer = this.peers.get(id)
381
- if (download && peer) download._onHave(peer, message)
508
+ if (!peer) return
509
+
510
+ this.holdings.set(id, rootHex, message)
511
+ download?._onHave(peer, message)
382
512
  }
383
513
 
384
514
  _onData (id, message) {
@@ -388,9 +518,13 @@ export class Swarmwire {
388
518
  }
389
519
 
390
520
  _onUnavailable (id, message) {
391
- const download = this.downloads.get(hex(message.root))
521
+ const rootHex = hex(message.root)
522
+ const download = this.downloads.get(rootHex)
392
523
  const peer = this.peers.get(id)
393
- if (download && peer) download._sources.delete(peer.id)
524
+ if (!peer) return
525
+
526
+ this.holdings.remove(id, rootHex)
527
+ download?._sources.delete(peer.id)
394
528
  }
395
529
  }
396
530
 
@@ -398,10 +532,12 @@ export class Swarmwire {
398
532
  * One transfer in progress. Thenable, so `await download` waits for it to finish.
399
533
  */
400
534
  export class Download extends EventEmitter {
401
- constructor (node, { root, size, secret }, outPath, { announced = true } = {}) {
535
+ constructor (node, { root, size, secret }, outPath, { announced = true, destination = null } = {}) {
402
536
  super()
403
537
 
404
538
  this.announced = announced
539
+ this._session = null // the announce session, when there is one
540
+ this._destinationFactory = destination // where the content lands, when not a file at `outPath`
405
541
 
406
542
  this.node = node
407
543
  this.root = root
@@ -418,6 +554,7 @@ export class Download extends EventEmitter {
418
554
 
419
555
  this._destination = null
420
556
  this._bitfields = new Map() // peer id -> what that peer says it holds
557
+ this._availability = null // how many of those hold each group; built once the size is known
421
558
  this._sources = new Set() // peer ids worth asking right now
422
559
  this._liars = new Set() // peer ids that sent something that did not verify: never again
423
560
  this._asked = new Set() // peer ids already told what we want
@@ -497,7 +634,7 @@ export class Download extends EventEmitter {
497
634
 
498
635
  this._cancelEverything()
499
636
  this.node.downloads.delete(this.root)
500
- if (this.announced) this.node.swarm.leave?.(discoveryTopic(this.root, this.secret))
637
+ await this._session?.destroy?.() // only ours: the same topic may be served or read elsewhere
501
638
 
502
639
  if (this._destination) await this._destination.close()
503
640
 
@@ -530,17 +667,18 @@ export class Download extends EventEmitter {
530
667
  async _open () {
531
668
  if (this._destination) return
532
669
 
533
- const destination = await Destination.open(this.path, {
534
- root: this.root,
535
- size: this.size,
536
- groupLog: this.node.groupLog
537
- })
670
+ const options = { root: this.root, size: this.size, groupLog: this.node.groupLog }
671
+ const destination = this._destinationFactory
672
+ ? await this._destinationFactory(options)
673
+ : await Destination.open(this.path, options)
538
674
 
539
675
  // The download can be stopped while the file is still opening. Nobody would ever close
540
676
  // this handle otherwise.
541
677
  if (this.stopped) return destination.close()
542
678
 
543
679
  this._destination = destination
680
+ this._availability = new Availability(destination.groupCount)
681
+ for (const bitfield of this._bitfields.values()) this._availability.add(bitfield)
544
682
  if (this._destination.complete) return this._finish()
545
683
 
546
684
  for (const peer of this.node.peers.values()) this._askPeer(peer)
@@ -559,7 +697,7 @@ export class Download extends EventEmitter {
559
697
 
560
698
  if (this._liars.has(peer.id)) return // nothing it says is worth storing
561
699
 
562
- this._bitfields.set(peer.id, b4a.from(bitfield ?? []))
700
+ this._setBitfield(peer.id, b4a.from(bitfield ?? []))
563
701
  this._sources.add(peer.id)
564
702
 
565
703
  if (this.size === null) {
@@ -603,7 +741,7 @@ export class Download extends EventEmitter {
603
741
  this.emit('rejected', { index, from: peer.id })
604
742
  this._liars.add(peer.id) // a peer that sends rubbish is never asked again
605
743
  this._sources.delete(peer.id)
606
- this._bitfields.delete(peer.id)
744
+ this._dropBitfield(peer.id)
607
745
  }
608
746
 
609
747
  if (this.complete) return this._finish()
@@ -636,12 +774,20 @@ export class Download extends EventEmitter {
636
774
 
637
775
  const requests = this._requests(peer)
638
776
  const window = this._windowFor(peer)
639
- const wanted = rarestFirst(this._destination.missing(), (index) => this._holders(index))
777
+ const destination = this._destination
778
+ const random = this.node.random
779
+
780
+ const pool = candidates({
781
+ count: destination.groupCount,
782
+ has: (index) => destination.has(index),
783
+ peerHas: (index) => this._peerHas(peer.id, index),
784
+ claimed: (index) => this._claimed(index),
785
+ from: Math.floor(random() * destination.groupCount)
786
+ })
787
+ const wanted = rank(pool, this._availability, random)
640
788
 
641
789
  for (const index of wanted) {
642
790
  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
791
 
646
792
  this._claim(peer, index)
647
793
  peer.send.request({ root: b4a.from(this.root, 'hex'), index })
@@ -685,13 +831,17 @@ export class Download extends EventEmitter {
685
831
  this._windows.set(peer.id, next)
686
832
  }
687
833
 
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
834
+ /** Remember what a peer holds, keeping the per-group counts in step. */
835
+ _setBitfield (peerId, bitfield) {
836
+ this._dropBitfield(peerId)
837
+ this._bitfields.set(peerId, bitfield)
838
+ this._availability?.add(bitfield)
839
+ }
840
+
841
+ _dropBitfield (peerId) {
842
+ const old = this._bitfields.get(peerId)
843
+ if (old) this._availability?.remove(old)
844
+ this._bitfields.delete(peerId)
695
845
  }
696
846
 
697
847
  /** Does this peer hold that group? An empty bitfield means we were told nothing useful. */
@@ -714,7 +864,10 @@ export class Download extends EventEmitter {
714
864
  this._narrow(peer)
715
865
 
716
866
  const bitfield = this._bitfields.get(peer.id)
717
- if (bitfield && index < bitfield.length * 8) bao.clearBit(bitfield, index)
867
+ if (bitfield && index < bitfield.length * 8 && bao.getBit(bitfield, index)) {
868
+ bao.clearBit(bitfield, index)
869
+ this._availability?.unset(index)
870
+ }
718
871
 
719
872
  this._pumpAll()
720
873
  }
@@ -724,7 +877,10 @@ export class Download extends EventEmitter {
724
877
  const bitfield = this._bitfields.get(peer.id)
725
878
  if (!bitfield || index >= bitfield.length * 8) return
726
879
 
727
- bao.setBit(bitfield, index)
880
+ if (!bao.getBit(bitfield, index)) {
881
+ bao.setBit(bitfield, index)
882
+ this._availability?.set(index)
883
+ }
728
884
  this._pump(peer)
729
885
  }
730
886
 
@@ -772,7 +928,7 @@ export class Download extends EventEmitter {
772
928
 
773
929
  _peerGone (peer) {
774
930
  this._sources.delete(peer.id)
775
- this._bitfields.delete(peer.id)
931
+ this._dropBitfield(peer.id)
776
932
  this._asked.delete(peer.id)
777
933
  this._releaseAll(peer.id) // whatever it owed us is up for grabs again
778
934
 
@@ -792,7 +948,7 @@ export class Download extends EventEmitter {
792
948
  const tree = this._destination.tree
793
949
 
794
950
  await this._destination.finish()
795
- await this.node._adopt(this.root, this.path, tree)
951
+ if (!this._destinationFactory) await this.node._adopt(this.root, this.path, tree)
796
952
 
797
953
  this.emit('progress', { have: this.have, total: this.total, from: null })
798
954
  this._resolve()
package/lib/wire.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The wire: what peers say to each other, and nothing more.
3
3
  *
4
- * Six messages. A peer asks who has some content, holders say so, the asker requests groups
4
+ * Eight messages. A peer asks who has some content, holders say so, the asker requests groups
5
5
  * one at a time, and each answer carries the proof that makes it checkable on arrival.
6
6
  * Scheduling, windows and peer choice live in swarmwire.js — none of that is on the wire.
7
7
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swarmwire-lib",
3
- "version": "0.1.0",
3
+ "version": "0.3.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": {
@@ -87,7 +88,8 @@
87
88
  "bare-process": "^4.2.4",
88
89
  "bare-subprocess": "^5.0.0",
89
90
  "bare-url": "^2.1.7",
90
- "brittle": "^3.7.0"
91
+ "brittle": "^3.7.0",
92
+ "hyperdht": "^6.34.1"
91
93
  },
92
94
  "engines": {
93
95
  "bare": ">=1.16",