swarmwire-lib 0.1.0 → 0.2.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 +41 -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/swarmwire.js +190 -56
- package/lib/wire.js +1 -1
- package/package.json +3 -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,12 @@ 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
|
-
| Peer
|
|
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
|
+
| Peer hints (preferred peers, priorities) | planned | 003 | - |
|
|
27
|
+
| Admission decisions | planned | 003 | - |
|
|
28
28
|
|
|
29
29
|
## 3. Rules the application can rely on
|
|
30
30
|
|
|
@@ -39,8 +39,12 @@ a peer because of a hint. The application dials through the connection it owns.
|
|
|
39
39
|
The application can decide, per peer and per content, whether content is served.
|
|
40
40
|
|
|
41
41
|
- 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
|
-
|
|
42
|
+
the decision is told which folder root the file was requested through, if any. The folder's own
|
|
43
|
+
file list is content too, with its own root, and is decided like any other root.
|
|
44
|
+
- **Deny on failure**: a decision that throws, times out or never answers is a deny. How long a
|
|
45
|
+
decision may take is an option of the admission mechanism, set by the application (its
|
|
46
|
+
default is chosen when the mechanism is built); the contract promises only that running out
|
|
47
|
+
of time is a deny.
|
|
44
48
|
- A deny is answered **exactly as content swarmwire does not hold**. A refused peer learns nothing
|
|
45
49
|
about what is held, its size or its name.
|
|
46
50
|
- With no decision installed, content is served as it is today.
|
|
@@ -49,10 +53,25 @@ The application can decide, per peer and per content, whether content is served.
|
|
|
49
53
|
|
|
50
54
|
### 3.3 Peer activity
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
swarmwire
|
|
56
|
+
`Swarmwire` is an event emitter.
|
|
57
|
+
|
|
58
|
+
- `peer-connected` and `peer-disconnected`, each with `{ id }` (the remote public key, hex). Each
|
|
59
|
+
fires **once per distinct peer**, and only for a peer that completed the swarmwire handshake: a
|
|
60
|
+
connection that never speaks the protocol is neither announced nor counted. Two connections
|
|
61
|
+
from one key are one peer. A peer that vanishes without closing is reported when its connection
|
|
62
|
+
closes; `peerCount` is already correct when the event fires.
|
|
63
|
+
- `peerCount`: connected peers, never negative.
|
|
64
|
+
- `counts(link)` returns `{ peers, seeders, leechers }` for a file or folder link, synchronously.
|
|
65
|
+
A **seeder** holds all of it, a **leecher** holds part, and a peer that has said nothing about
|
|
66
|
+
that content is in `peers` only. For a folder, a seeder holds the file list and every listed
|
|
67
|
+
file; a peer holding the list alone is a leecher. Content the node knows nothing about, and a
|
|
68
|
+
folder whose list is not yet known, give zeros, never an error.
|
|
69
|
+
- A node that only serves still learns what its peers hold: it asks each connected peer about
|
|
70
|
+
the content it serves, with the ordinary `want` message. No message was added. A peer that does
|
|
71
|
+
not answer is simply uncounted.
|
|
72
|
+
|
|
73
|
+
**Direct connections only**: a snapshot that may lag, not a count of the swarm. Global counts and
|
|
74
|
+
chunk rarity across the network are a tracker's job, not swarmwire's.
|
|
56
75
|
|
|
57
76
|
### 3.4 Identity
|
|
58
77
|
|
|
@@ -61,9 +80,13 @@ key to a user or device is the application's job (Principle II).
|
|
|
61
80
|
|
|
62
81
|
### 3.5 Choosing files of a folder
|
|
63
82
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
83
|
+
`readList(link)` reads a folder's file list without fetching any content: it resolves to
|
|
84
|
+
`{ files: [{ path, root, size }], size }`, and `stop()` gives up waiting, like a download. It
|
|
85
|
+
writes nothing to disk and only **looks** for peers: it never announces this node as a provider.
|
|
86
|
+
A link that is not a folder is refused, and so is a list with an unsafe path (the same paths a
|
|
87
|
+
whole-folder download refuses; nothing is repaired). `downloadFile(link, file, outPath)` then
|
|
88
|
+
downloads one listed file, using only the folder's link and the list entry, and does not announce
|
|
89
|
+
it separately. Which files, and any interface for choosing, belong to the application.
|
|
67
90
|
|
|
68
91
|
### 3.6 The shared connection
|
|
69
92
|
|
|
@@ -109,9 +132,10 @@ DEFAULT_GROUP_LOG
|
|
|
109
132
|
```
|
|
110
133
|
|
|
111
134
|
**Stable** (changes bump the version and are in the changelog): `Swarmwire`
|
|
112
|
-
(`serve`, `serveCollection`, `download`, `downloadCollection`, `
|
|
113
|
-
|
|
114
|
-
`
|
|
135
|
+
(`serve`, `serveCollection`, `download`, `downloadCollection`, `readList`, `downloadFile`,
|
|
136
|
+
`unserve`, `destroy`, `peerCount`, `counts`; events `peer-connected`, `peer-disconnected`),
|
|
137
|
+
`Download` (awaitable; events `progress`, `rejected`, `timeout`, `stalled`), `rootOf`,
|
|
138
|
+
`formatLink`, `parseLink`, `SCHEME`.
|
|
115
139
|
|
|
116
140
|
**Exported for tests and advanced use, may change in any 0.x**: everything else.
|
|
117
141
|
|
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/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
|
|
@@ -21,6 +21,8 @@ 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
23
|
import { rarestFirst } 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,6 +73,7 @@ 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
|
|
@@ -72,6 +82,10 @@ export class Swarmwire {
|
|
|
72
82
|
this.serving = new Map() // root → ContentSource
|
|
73
83
|
this.downloads = new Map() // root → Download
|
|
74
84
|
this.peers = new Map() // peer id → Peer
|
|
85
|
+
this.holdings = new Holdings({ groupLog }) // what those peers say they hold
|
|
86
|
+
this.lists = new Map() // folder root → its file entries, once known
|
|
87
|
+
this._listings = new Map() // folder root → the list read in progress
|
|
88
|
+
this._sessions = new Map() // served root → its announce session, ended by unserve
|
|
75
89
|
|
|
76
90
|
this._onconnection = (stream, info) => this._onPeer(stream, info)
|
|
77
91
|
this.swarm.on('connection', this._onconnection)
|
|
@@ -91,10 +105,11 @@ export class Swarmwire {
|
|
|
91
105
|
const existing = this.serving.get(source.root)
|
|
92
106
|
if (existing && existing !== source) await source.close()
|
|
93
107
|
else this.serving.set(source.root, source)
|
|
108
|
+
this._watch(source.root, source.size)
|
|
94
109
|
|
|
95
110
|
// Wait for the announce to actually land. Until it has, a peer looking this content up
|
|
96
111
|
// finds nobody, which is indistinguishable from "that link does not work".
|
|
97
|
-
const discovery = this.
|
|
112
|
+
const discovery = this._announce(source.root, discoveryTopic(source.root, secret))
|
|
98
113
|
if (typeof discovery?.flushed === 'function') await discovery.flushed()
|
|
99
114
|
|
|
100
115
|
return {
|
|
@@ -126,12 +141,15 @@ export class Swarmwire {
|
|
|
126
141
|
else await source.close()
|
|
127
142
|
|
|
128
143
|
entries.push({ path: relative, root: source.root, size: source.size })
|
|
144
|
+
this._watch(source.root, source.size)
|
|
129
145
|
}
|
|
130
146
|
|
|
131
147
|
const manifest = MemorySource.from(encodeManifest(entries), { groupLog: this.groupLog })
|
|
132
148
|
this.serving.set(manifest.root, manifest)
|
|
149
|
+
this.lists.set(manifest.root, entries)
|
|
150
|
+
this._watch(manifest.root, manifest.size)
|
|
133
151
|
|
|
134
|
-
const discovery = this.
|
|
152
|
+
const discovery = this._announce(manifest.root, discoveryTopic(manifest.root, secret))
|
|
135
153
|
if (typeof discovery?.flushed === 'function') await discovery.flushed()
|
|
136
154
|
|
|
137
155
|
return {
|
|
@@ -143,7 +161,63 @@ export class Swarmwire {
|
|
|
143
161
|
}
|
|
144
162
|
|
|
145
163
|
/**
|
|
146
|
-
*
|
|
164
|
+
* Read a folder's file list without fetching any of it: each file's path, root and size, and
|
|
165
|
+
* the total. Nothing is written to disk and this node is never announced as a provider.
|
|
166
|
+
*
|
|
167
|
+
* @param {string|object} link - a folder link
|
|
168
|
+
* @returns {Listing} thenable, resolving to `{ files, size }`; `stop()` gives up waiting
|
|
169
|
+
*/
|
|
170
|
+
readList (link) {
|
|
171
|
+
const parsed = typeof link === 'string' ? parseLink(link) : link
|
|
172
|
+
if (parsed.type !== 'collection') return Listing.failed(new Error('not a folder: that link is a single file'))
|
|
173
|
+
|
|
174
|
+
return this._list(parsed)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Fetch one file of a folder, using only the folder's link and an entry from its list. It is
|
|
179
|
+
* not announced on its own: the peers holding it are the ones connected through the folder.
|
|
180
|
+
*
|
|
181
|
+
* @param {string|object} link - the folder's link
|
|
182
|
+
* @param {{path: string, root: string, size: number}} file - an entry of `readList`'s result
|
|
183
|
+
* @param {string} outPath - where to write it; its directory must exist
|
|
184
|
+
* @returns {Download}
|
|
185
|
+
*/
|
|
186
|
+
downloadFile (link, file, outPath) {
|
|
187
|
+
const { secret } = typeof link === 'string' ? parseLink(link) : link
|
|
188
|
+
|
|
189
|
+
return this.download(
|
|
190
|
+
{ root: file.root, size: file.size, secret, name: file.path },
|
|
191
|
+
outPath,
|
|
192
|
+
{ announce: false }
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** One read per folder at a time: asking again while one runs shares it (and its `stop`). */
|
|
197
|
+
_list (parsed) {
|
|
198
|
+
// shortcut: a folder's list is kept for the node's lifetime; evict if a node reads thousands of folders
|
|
199
|
+
const known = this.lists.get(parsed.root)
|
|
200
|
+
if (known) return Listing.known(known)
|
|
201
|
+
|
|
202
|
+
let listing = this._listings.get(parsed.root)
|
|
203
|
+
if (!listing) {
|
|
204
|
+
listing = new Listing(this, parsed)
|
|
205
|
+
this._listings.set(parsed.root, listing)
|
|
206
|
+
const done = () => this._listings.delete(parsed.root)
|
|
207
|
+
listing.then(done, done)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return listing
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A folder's list became known: remember it, and ask peers what they hold of its files. */
|
|
214
|
+
_learnList (root, files) {
|
|
215
|
+
this.lists.set(root, files)
|
|
216
|
+
for (const file of files) this._watch(file.root, file.size)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Fetch a collection: the list first, then every file it names, into `dir`.
|
|
147
221
|
*
|
|
148
222
|
* @param {string|object} link
|
|
149
223
|
* @param {string} dir
|
|
@@ -154,43 +228,27 @@ export class Swarmwire {
|
|
|
154
228
|
const parsed = typeof link === 'string' ? parseLink(link) : link
|
|
155
229
|
|
|
156
230
|
await fsx.mkdir(dir)
|
|
157
|
-
|
|
231
|
+
|
|
232
|
+
// Reading the list only looks; downloading a folder also announces it, so whoever fetches
|
|
233
|
+
// it can be fetched from in turn.
|
|
234
|
+
this.swarm.join(discoveryTopic(parsed.root, parsed.secret), ANNOUNCE)
|
|
235
|
+
|
|
236
|
+
const list = await this._list(parsed)
|
|
237
|
+
if (!list) throw new Error('the folder list read was stopped')
|
|
158
238
|
|
|
159
239
|
let bytes = 0
|
|
160
240
|
|
|
161
|
-
for (const file of files) {
|
|
241
|
+
for (const file of list.files) {
|
|
162
242
|
const target = join(dir, file.path)
|
|
163
243
|
await fsx.mkdir(parentOf(target))
|
|
164
244
|
|
|
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
|
-
)
|
|
245
|
+
await this.downloadFile(parsed, file, target)
|
|
172
246
|
|
|
173
247
|
bytes += file.size
|
|
174
248
|
if (onfile) onfile({ path: file.path, size: file.size })
|
|
175
249
|
}
|
|
176
250
|
|
|
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
|
-
}
|
|
251
|
+
return { files: list.files.length, bytes }
|
|
194
252
|
}
|
|
195
253
|
|
|
196
254
|
/** Stop serving content. The file itself is untouched. */
|
|
@@ -199,9 +257,54 @@ export class Swarmwire {
|
|
|
199
257
|
if (!source) return
|
|
200
258
|
|
|
201
259
|
this.serving.delete(root)
|
|
260
|
+
this.holdings.unwatch(root)
|
|
261
|
+
this.lists.delete(root)
|
|
262
|
+
await this._sessions.get(root)?.destroy?.()
|
|
263
|
+
this._sessions.delete(root)
|
|
202
264
|
await source.close()
|
|
203
265
|
}
|
|
204
266
|
|
|
267
|
+
_announce (root, topic) {
|
|
268
|
+
if (!this._sessions.has(root)) this._sessions.set(root, this.swarm.join(topic, ANNOUNCE))
|
|
269
|
+
return this._sessions.get(root)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Connected peers that completed the handshake, counting each distinct peer once. */
|
|
273
|
+
get peerCount () {
|
|
274
|
+
return this._ready().length
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* How many direct connections hold this content: all of it (seeders) or part of it
|
|
279
|
+
* (leechers). It describes who is connected to this node, not the swarm, and a peer that has
|
|
280
|
+
* said nothing about the content is counted in `peers` only. A folder's counts need its file
|
|
281
|
+
* list, which a node knows once it has served, read or downloaded it; until then they are 0.
|
|
282
|
+
*
|
|
283
|
+
* @param {string|object} link
|
|
284
|
+
* @returns {{peers: number, seeders: number, leechers: number}}
|
|
285
|
+
*/
|
|
286
|
+
counts (link) {
|
|
287
|
+
const parsed = typeof link === 'string' ? parseLink(link) : link
|
|
288
|
+
const list = this.lists.get(parsed.root)
|
|
289
|
+
|
|
290
|
+
let roots = [parsed.root]
|
|
291
|
+
if (list) roots = [parsed.root, ...list.map((file) => file.root)]
|
|
292
|
+
else if (parsed.type === 'collection') roots = []
|
|
293
|
+
|
|
294
|
+
return this.holdings.count(this._ready(), roots)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
_ready () {
|
|
298
|
+
const ids = []
|
|
299
|
+
for (const peer of this.peers.values()) if (peer.ready) ids.push(peer.id)
|
|
300
|
+
return ids
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Ask every peer about a root we hold, unless a download already did. */
|
|
304
|
+
_watch (root, size) {
|
|
305
|
+
this.holdings.watch(root, size, this.peers.values(), (r) => this.downloads.has(r))
|
|
306
|
+
}
|
|
307
|
+
|
|
205
308
|
/**
|
|
206
309
|
* Fetch content by link. Returns immediately; await the result for completion, or
|
|
207
310
|
* `await download.ready()` to know the destination has been opened.
|
|
@@ -210,19 +313,19 @@ export class Swarmwire {
|
|
|
210
313
|
* @param {string} outPath
|
|
211
314
|
* @returns {Download}
|
|
212
315
|
*/
|
|
213
|
-
download (link, outPath, { announce = true } = {}) {
|
|
316
|
+
download (link, outPath, { announce = true, destination = null } = {}) {
|
|
214
317
|
const parsed = typeof link === 'string' ? parseLink(link) : link
|
|
215
318
|
|
|
216
319
|
const existing = this.downloads.get(parsed.root)
|
|
217
320
|
if (existing) return existing
|
|
218
321
|
|
|
219
|
-
const download = new Download(this, parsed, outPath, { announced: announce })
|
|
322
|
+
const download = new Download(this, parsed, outPath, { announced: announce, destination })
|
|
220
323
|
this.downloads.set(parsed.root, download)
|
|
221
324
|
|
|
222
325
|
// A file inside a collection is not announced on its own: whoever has it is already
|
|
223
326
|
// connected through the collection's topic, and a folder of a thousand files should not
|
|
224
327
|
// mean a thousand announces.
|
|
225
|
-
if (announce) this.swarm.join(discoveryTopic(parsed.root, parsed.secret))
|
|
328
|
+
if (announce) download._session = this.swarm.join(discoveryTopic(parsed.root, parsed.secret), ANNOUNCE)
|
|
226
329
|
|
|
227
330
|
download._start()
|
|
228
331
|
|
|
@@ -242,8 +345,10 @@ export class Swarmwire {
|
|
|
242
345
|
|
|
243
346
|
_onPeer (stream, info) {
|
|
244
347
|
const id = peerId(info)
|
|
348
|
+
let peer = null
|
|
349
|
+
|
|
245
350
|
const channel = openChannel(stream, {
|
|
246
|
-
onhandshake: () =>
|
|
351
|
+
onhandshake: () => peer && this._onHandshake(peer),
|
|
247
352
|
onwant: (message) => this._onWant(id, message),
|
|
248
353
|
onhave: (message) => this._onHave(id, message),
|
|
249
354
|
onrequest: (message) => this._onRequest(id, message),
|
|
@@ -251,12 +356,13 @@ export class Swarmwire {
|
|
|
251
356
|
onunavailable: (message) => this._onUnavailable(id, message),
|
|
252
357
|
onholds: (message) => this._onHolds(id, message),
|
|
253
358
|
onreject: (message) => this._onReject(id, message),
|
|
254
|
-
onclose: () => this._onPeerGone(
|
|
359
|
+
onclose: () => peer && this._onPeerGone(peer)
|
|
255
360
|
})
|
|
256
361
|
|
|
257
362
|
if (channel === null) return
|
|
258
363
|
|
|
259
|
-
|
|
364
|
+
// A second connection from the same peer replaces the first without announcing it again.
|
|
365
|
+
peer = { id, send: channel.send, wants: new Set(), ready: this.peers.get(id)?.ready === true }
|
|
260
366
|
this.peers.set(id, peer)
|
|
261
367
|
|
|
262
368
|
peer.send.handshake({ version: VERSION, groupLog: this.groupLog })
|
|
@@ -264,14 +370,26 @@ export class Swarmwire {
|
|
|
264
370
|
// Tell a new peer what we are after. A peer that has it answers; one that does not stays
|
|
265
371
|
// quiet and is simply never asked.
|
|
266
372
|
for (const download of this.downloads.values()) download._askPeer(peer)
|
|
373
|
+
this.holdings.wantAll(peer, (root) => this.downloads.has(root))
|
|
267
374
|
}
|
|
268
375
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (
|
|
376
|
+
/** The remote spoke swarmwire: from here on it is a peer, not just a connection. */
|
|
377
|
+
_onHandshake (peer) {
|
|
378
|
+
if (peer.ready) return
|
|
379
|
+
|
|
380
|
+
peer.ready = true
|
|
381
|
+
this.emit('peer-connected', { id: peer.id })
|
|
382
|
+
}
|
|
272
383
|
|
|
273
|
-
|
|
384
|
+
_onPeerGone (peer) {
|
|
385
|
+
// The connection that closed may be one a newer connection from the same peer replaced.
|
|
386
|
+
if (this.peers.get(peer.id) !== peer) return
|
|
387
|
+
|
|
388
|
+
this.peers.delete(peer.id)
|
|
389
|
+
this.holdings.dropPeer(peer.id)
|
|
274
390
|
for (const download of this.downloads.values()) download._peerGone(peer)
|
|
391
|
+
|
|
392
|
+
if (peer.ready) this.emit('peer-disconnected', { id: peer.id })
|
|
275
393
|
}
|
|
276
394
|
|
|
277
395
|
_onWant (id, { root }) {
|
|
@@ -348,7 +466,9 @@ export class Swarmwire {
|
|
|
348
466
|
if (this.serving.has(root)) return
|
|
349
467
|
|
|
350
468
|
try {
|
|
351
|
-
|
|
469
|
+
const source = await ContentSource.adopt(path, tree)
|
|
470
|
+
this.serving.set(root, source)
|
|
471
|
+
this._watch(root, source.size) // peers that connect from now on are asked what they hold
|
|
352
472
|
} catch {
|
|
353
473
|
// The file was moved or deleted between finishing and adopting it. Nothing to serve.
|
|
354
474
|
}
|
|
@@ -364,9 +484,14 @@ export class Swarmwire {
|
|
|
364
484
|
}
|
|
365
485
|
|
|
366
486
|
_onHolds (id, message) {
|
|
367
|
-
const
|
|
487
|
+
const rootHex = hex(message.root)
|
|
488
|
+
const download = this.downloads.get(rootHex)
|
|
368
489
|
const peer = this.peers.get(id)
|
|
369
|
-
if (
|
|
490
|
+
if (!peer) return
|
|
491
|
+
|
|
492
|
+
const size = this.serving.get(rootHex)?.size ?? download?.size ?? this.holdings.watched.get(rootHex)
|
|
493
|
+
this.holdings.grow(id, rootHex, message.index, size ?? undefined)
|
|
494
|
+
download?._onHolds(peer, message)
|
|
370
495
|
}
|
|
371
496
|
|
|
372
497
|
_onReject (id, message) {
|
|
@@ -376,9 +501,13 @@ export class Swarmwire {
|
|
|
376
501
|
}
|
|
377
502
|
|
|
378
503
|
_onHave (id, message) {
|
|
379
|
-
const
|
|
504
|
+
const rootHex = hex(message.root)
|
|
505
|
+
const download = this.downloads.get(rootHex)
|
|
380
506
|
const peer = this.peers.get(id)
|
|
381
|
-
if (
|
|
507
|
+
if (!peer) return
|
|
508
|
+
|
|
509
|
+
this.holdings.set(id, rootHex, message)
|
|
510
|
+
download?._onHave(peer, message)
|
|
382
511
|
}
|
|
383
512
|
|
|
384
513
|
_onData (id, message) {
|
|
@@ -388,9 +517,13 @@ export class Swarmwire {
|
|
|
388
517
|
}
|
|
389
518
|
|
|
390
519
|
_onUnavailable (id, message) {
|
|
391
|
-
const
|
|
520
|
+
const rootHex = hex(message.root)
|
|
521
|
+
const download = this.downloads.get(rootHex)
|
|
392
522
|
const peer = this.peers.get(id)
|
|
393
|
-
if (
|
|
523
|
+
if (!peer) return
|
|
524
|
+
|
|
525
|
+
this.holdings.remove(id, rootHex)
|
|
526
|
+
download?._sources.delete(peer.id)
|
|
394
527
|
}
|
|
395
528
|
}
|
|
396
529
|
|
|
@@ -398,10 +531,12 @@ export class Swarmwire {
|
|
|
398
531
|
* One transfer in progress. Thenable, so `await download` waits for it to finish.
|
|
399
532
|
*/
|
|
400
533
|
export class Download extends EventEmitter {
|
|
401
|
-
constructor (node, { root, size, secret }, outPath, { announced = true } = {}) {
|
|
534
|
+
constructor (node, { root, size, secret }, outPath, { announced = true, destination = null } = {}) {
|
|
402
535
|
super()
|
|
403
536
|
|
|
404
537
|
this.announced = announced
|
|
538
|
+
this._session = null // the announce session, when there is one
|
|
539
|
+
this._destinationFactory = destination // where the content lands, when not a file at `outPath`
|
|
405
540
|
|
|
406
541
|
this.node = node
|
|
407
542
|
this.root = root
|
|
@@ -497,7 +632,7 @@ export class Download extends EventEmitter {
|
|
|
497
632
|
|
|
498
633
|
this._cancelEverything()
|
|
499
634
|
this.node.downloads.delete(this.root)
|
|
500
|
-
|
|
635
|
+
await this._session?.destroy?.() // only ours: the same topic may be served or read elsewhere
|
|
501
636
|
|
|
502
637
|
if (this._destination) await this._destination.close()
|
|
503
638
|
|
|
@@ -530,11 +665,10 @@ export class Download extends EventEmitter {
|
|
|
530
665
|
async _open () {
|
|
531
666
|
if (this._destination) return
|
|
532
667
|
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
})
|
|
668
|
+
const options = { root: this.root, size: this.size, groupLog: this.node.groupLog }
|
|
669
|
+
const destination = this._destinationFactory
|
|
670
|
+
? await this._destinationFactory(options)
|
|
671
|
+
: await Destination.open(this.path, options)
|
|
538
672
|
|
|
539
673
|
// The download can be stopped while the file is still opening. Nobody would ever close
|
|
540
674
|
// this handle otherwise.
|
|
@@ -792,7 +926,7 @@ export class Download extends EventEmitter {
|
|
|
792
926
|
const tree = this._destination.tree
|
|
793
927
|
|
|
794
928
|
await this._destination.finish()
|
|
795
|
-
await this.node._adopt(this.root, this.path, tree)
|
|
929
|
+
if (!this._destinationFactory) await this.node._adopt(this.root, this.path, tree)
|
|
796
930
|
|
|
797
931
|
this.emit('progress', { have: this.have, total: this.total, from: null })
|
|
798
932
|
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.2.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",
|
|
@@ -87,7 +87,8 @@
|
|
|
87
87
|
"bare-process": "^4.2.4",
|
|
88
88
|
"bare-subprocess": "^5.0.0",
|
|
89
89
|
"bare-url": "^2.1.7",
|
|
90
|
-
"brittle": "^3.7.0"
|
|
90
|
+
"brittle": "^3.7.0",
|
|
91
|
+
"hyperdht": "^6.34.1"
|
|
91
92
|
},
|
|
92
93
|
"engines": {
|
|
93
94
|
"bare": ">=1.16",
|