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 +14 -34
- package/docs/contract.md +49 -17
- package/docs/format.md +7 -2
- package/lib/collection.js +1 -1
- package/lib/holdings.js +125 -0
- package/lib/listing.js +158 -0
- package/lib/select.js +44 -18
- package/lib/swarmwire.js +228 -72
- package/lib/wire.js +1 -1
- package/package.json +4 -2
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
|
|
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
|
|
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 =
|
|
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,
|
|
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
|
|
78
|
-
> verification
|
|
79
|
-
>
|
|
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
|
|
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
|
|
25
|
-
|
|
|
26
|
-
|
|
|
27
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
swarmwire
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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`, `
|
|
113
|
-
|
|
114
|
-
`
|
|
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
|
-
|
|
170
|
-
are ignored rather than treated as errors, so new message types never
|
|
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
|
package/lib/holdings.js
ADDED
|
@@ -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
|
-
|
|
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
|
@@ -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)
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
360
|
+
onclose: () => peer && this._onPeerGone(peer)
|
|
255
361
|
})
|
|
256
362
|
|
|
257
363
|
if (channel === null) return
|
|
258
364
|
|
|
259
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
if (
|
|
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
|
-
|
|
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
|
|
488
|
+
const rootHex = hex(message.root)
|
|
489
|
+
const download = this.downloads.get(rootHex)
|
|
368
490
|
const peer = this.peers.get(id)
|
|
369
|
-
if (
|
|
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
|
|
505
|
+
const rootHex = hex(message.root)
|
|
506
|
+
const download = this.downloads.get(rootHex)
|
|
380
507
|
const peer = this.peers.get(id)
|
|
381
|
-
if (
|
|
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
|
|
521
|
+
const rootHex = hex(message.root)
|
|
522
|
+
const download = this.downloads.get(rootHex)
|
|
392
523
|
const peer = this.peers.get(id)
|
|
393
|
-
if (
|
|
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
|
-
|
|
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
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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.
|
|
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",
|