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 CHANGED
@@ -9,35 +9,19 @@ Files are served in place, from wherever they already live on disk. Nothing is c
9
9
  store, a core, or a cache first.
10
10
 
11
11
  ```
12
- npm i swarmwire-lib
12
+ npm i swarmwire-lib hyperswarm
13
13
  ```
14
14
 
15
- One side serves a file and prints a link. The other side hands the link to `download`. (This is
16
- `examples/send-get.js`; in the repository it imports `../index.js` instead of `swarmwire-lib`. It is
17
- an ES module, so your project needs `"type": "module"` or a `.mjs` file.)
15
+ One side serves a file and prints a link. The other side hands the link to `download`:
18
16
 
19
17
  ```js
20
- /**
21
- * Send a file to someone, or get one.
22
- *
23
- * node examples/send-get.js send ./holidays.mp4
24
- * node examples/send-get.js get 'swarmwire://…' ./holidays.mp4
25
- *
26
- * `--bootstrap host:port` points both sides at a private DHT; leave it out for the real one.
27
- */
28
-
29
18
  import process from 'process'
30
19
  import Hyperswarm from 'hyperswarm'
31
- import DHT from 'hyperdht'
32
-
33
20
  import { Swarmwire } from 'swarmwire-lib'
34
21
 
35
- const args = process.argv.slice(2)
36
- const flag = args.indexOf('--bootstrap')
37
- const bootstrap = flag === -1 ? null : args.splice(flag, 2)[1]
38
- const [mode, target, out] = args
22
+ const [mode, target, out] = process.argv.slice(2)
39
23
 
40
- const swarm = bootstrap ? privateSwarm(bootstrap) : new Hyperswarm()
24
+ const swarm = new Hyperswarm()
41
25
  const node = new Swarmwire({ swarm })
42
26
 
43
27
  if (mode === 'send') {
@@ -47,22 +31,16 @@ if (mode === 'send') {
47
31
  } else if (mode === 'get') {
48
32
  await node.download(target, out)
49
33
  console.log(out)
50
- } else {
51
- console.log('usage: send <file> | get <link> <out> [--bootstrap host:port]')
52
- process.exitCode = 1
53
34
  }
54
35
 
55
36
  await node.destroy()
56
37
  await swarm.destroy()
57
-
58
- // A loopback DHT only works if the node binds there and is not firewalled.
59
- function privateSwarm (address) {
60
- const [host, port] = address.split(':')
61
- const dht = new DHT({ bootstrap: [{ host, port: Number(port) }], ephemeral: false, firewalled: false, host: '127.0.0.1' })
62
- return new Hyperswarm({ dht })
63
- }
64
38
  ```
65
39
 
40
+ This is `examples/send-get.js` without its `--bootstrap` option, which points both sides at a
41
+ private DHT so the test suite needs no internet. It is an ES module, so your project needs
42
+ `"type": "module"` or a `.mjs` file.
43
+
66
44
  ```
67
45
  $ node examples/send-get.js send ./holidays.mp4
68
46
  swarmwire://10db6398a68dff502d002912f54502fdde9ab55fb30bec32861a2f004d874fc8?s=734003200&n=holidays.mp4
@@ -72,11 +50,13 @@ $ node examples/send-get.js get 'swarmwire://10db63…' ./holidays.mp4
72
50
  ```
73
51
 
74
52
  A folder goes the same way, as one link: `serveCollection` and `downloadCollection`.
75
- The command-line tool built on this library is moving to its own package, `swarmwire-cli`.
53
+ The command-line tool built on this library is moving to its own package,
54
+ [`swarmwire-cli`](https://github.com/tibocub/swarmwire-cli) ([npm](https://www.npmjs.com/package/swarmwire-cli)).
76
55
 
77
- > Status: **pre-alpha.** Transfers work end to end, over a real swarm, with resume and
78
- > verification. Multi-peer scheduling is still naive and there are no request timeouts yet —
79
- > see [docs/roadmap.md](docs/roadmap.md) for what M2 adds.
56
+ > Status: **pre-alpha.** Transfers work end to end, over a real swarm, with resume,
57
+ > verification, request timeouts, partial seeding, rarest-first selection and an adaptive
58
+ > request window. Behaviour with very many peers is not measured yet. See
59
+ > [docs/roadmap.md](docs/roadmap.md) for what is built and what is next.
80
60
 
81
61
  ## What it is
82
62
 
package/docs/contract.md CHANGED
@@ -19,12 +19,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`, `leave`, a `connection` event) | exists | - | `test/helpers/network.js`, `test/net/real-swarm.test.js` |
22
+ | Application supplies the network connection (any object with `join(topic, opts)` returning a session with `destroy()`, and a `connection` event; Hyperswarm is one) | exists | - | `test/helpers/network.js`, `test/net/real-swarm.test.js` |
23
23
  | Peer identity: the remote public key of the connection | exists | - | `test/swarm/transfer.test.js` |
24
- | Peer hints (preferred peers, priorities) | planned | 002 | - |
25
- | Admission decisions | planned | 002 | - |
26
- | Peer activity events and counts | planned | 002 | - |
27
- | Folder file-list reader, public | planned | 002 | - |
24
+ | Peer activity events, peer count, seeder and leecher counts | exists | - | `test/swarm/peers.test.js` |
25
+ | Folder file-list reader (look-only, in memory), and downloading one listed file | exists | - | `test/swarm/list.test.js` |
26
+ | 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
- - **Deny on failure**: a decision that throws, times out or never answers is a deny.
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
- Events when a peer connects and disconnects, and, for one content root, the number of connected
53
- peers and how many hold all of it. **Direct connections only**: a snapshot that may lag, not a
54
- count of the swarm. Global counts and chunk rarity across the network are a tracker's job, not
55
- swarmwire's.
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
- Read the folder's file list without fetching content, then download the chosen files one by one.
65
- Which files, and any interface for choosing, belong to the application. *Planned: the list reader
66
- is not public yet.*
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`, `unserve`, `destroy`), `Download`
113
- (awaitable; events `progress`, `rejected`, `timeout`, `stalled`), `rootOf`, `formatLink`,
114
- `parseLink`, `SCHEME`.
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
- Protocol messages carry a version and a capability set in the handshake. Unknown message types
170
- are ignored rather than treated as errors, so new message types never break old peers.
169
+ The handshake carries a version and the group size (`groupLog`); it carries no capability set.
170
+ Unknown message types are ignored rather than treated as errors, so new message types never
171
+ break old peers.
172
+
173
+ What a peer holds is learned by asking: `want` is answered with `have` (or `unavailable`) and
174
+ followed by `holds` as the peer gains groups. A node that only serves sends `want` for what it
175
+ serves, which is how it counts its seeders and leechers. No message exists for this.
171
176
 
172
177
  ## Open questions
173
178
 
package/lib/collection.js CHANGED
@@ -18,7 +18,7 @@ import b4a from 'b4a'
18
18
  export const MANIFEST_TYPE = 'collection/v0'
19
19
 
20
20
  /** Enough for a folder of a million files; past that, something has gone wrong. */
21
- const MAX_MANIFEST_BYTES = 64 * 1024 * 1024
21
+ export const MAX_MANIFEST_BYTES = 64 * 1024 * 1024
22
22
 
23
23
  /**
24
24
  * @param {{path: string, root: string, size: number}[]} files
@@ -0,0 +1,125 @@
1
+ /**
2
+ * What connected peers say they hold, and the counts that follow from it.
3
+ *
4
+ * A node only hears about a peer's holdings if it asked: `want` is answered with `have`, and
5
+ * the peer then pushes `holds` as it gains groups. A node that only serves never asks, so it
6
+ * keeps a watch set — the roots it serves — and sends `want` for those to every peer. That is
7
+ * the whole mechanism; there is no message for it.
8
+ *
9
+ * Counts are of direct connections. Nothing here knows about the rest of the swarm.
10
+ */
11
+
12
+ import b4a from 'b4a'
13
+
14
+ import { bao } from './blake3.js'
15
+ import { DEFAULT_GROUP_LOG, groupCount } from './tree.js'
16
+
17
+ export class Holdings {
18
+ constructor ({ groupLog = DEFAULT_GROUP_LOG } = {}) {
19
+ this.groupLog = groupLog
20
+ this.peers = new Map() // peer id → Map(root hex → { size, total, bits, count })
21
+ this.watched = new Map() // root hex → size: roots we ask every peer about
22
+ }
23
+
24
+ /** A peer described what it holds (`have`). Replaces whatever we knew. */
25
+ set (peerId, root, { size, groupLog, bitfield }) {
26
+ if (groupLog !== this.groupLog) return // we cannot read that granularity: unknown, not guessed
27
+
28
+ const total = groupCount(size, this.groupLog)
29
+ const bits = bao.createBitfield(total)
30
+ bits.set(b4a.from(bitfield ?? []).subarray(0, bits.length))
31
+
32
+ this._of(peerId).set(root, { size, total, bits, count: bao.countSetBits(bits, total) })
33
+ }
34
+
35
+ /**
36
+ * A peer gained one group (`holds`). Without a `have` to build on, a known `size` stands in:
37
+ * a peer that first said "unavailable" and then began downloading only ever sends this.
38
+ */
39
+ grow (peerId, root, index, size) {
40
+ let held = this.get(peerId, root)
41
+
42
+ if (!held) {
43
+ if (size === undefined) return
44
+ const total = groupCount(size, this.groupLog)
45
+ held = { size, total, bits: bao.createBitfield(total), count: 0 }
46
+ this._of(peerId).set(root, held)
47
+ }
48
+
49
+ if (index < 0 || index >= held.total || bao.getBit(held.bits, index)) return
50
+
51
+ bao.setBit(held.bits, index)
52
+ held.count++
53
+ }
54
+
55
+ /** The peer says it has none of this (`unavailable`). */
56
+ remove (peerId, root) {
57
+ this.peers.get(peerId)?.delete(root)
58
+ }
59
+
60
+ dropPeer (peerId) {
61
+ this.peers.delete(peerId)
62
+ }
63
+
64
+ get (peerId, root) {
65
+ return this.peers.get(peerId)?.get(root)
66
+ }
67
+
68
+ /**
69
+ * @param {Iterable<string>} peerIds - the connected peers to count among
70
+ * @param {string[]} roots - one root for a file; the manifest and every file for a folder
71
+ * @returns {{peers: number, seeders: number, leechers: number}} a seeder holds every group
72
+ * of every root, a leecher holds some but not all, anyone else is in neither
73
+ */
74
+ count (peerIds, roots) {
75
+ const counts = { peers: 0, seeders: 0, leechers: 0 }
76
+
77
+ for (const id of peerIds) {
78
+ counts.peers++
79
+ if (roots.length === 0) continue
80
+
81
+ let complete = true
82
+ let any = false
83
+
84
+ for (const root of roots) {
85
+ const held = this.get(id, root)
86
+ if (!held || held.count < held.total) complete = false
87
+ if (held && held.count > 0) any = true
88
+ }
89
+
90
+ if (complete) counts.seeders++
91
+ else if (any) counts.leechers++
92
+ }
93
+
94
+ return counts
95
+ }
96
+
97
+ // -- the watch set ---------------------------------------------------------
98
+
99
+ /** Ask every peer about a root we hold or follow, now and whenever a new peer connects. */
100
+ watch (root, size, peers, skip) {
101
+ this.watched.set(root, size)
102
+ for (const peer of peers) this._want(peer, root, size, skip)
103
+ }
104
+
105
+ unwatch (root) {
106
+ this.watched.delete(root)
107
+ }
108
+
109
+ /** A peer just connected: tell it what we are watching. */
110
+ wantAll (peer, skip) {
111
+ for (const [root, size] of this.watched) this._want(peer, root, size, skip)
112
+ }
113
+
114
+ _want (peer, root, size, skip) {
115
+ // shortcut: wants for every file of a served folder; watch lazily if folders grow past ~10k files
116
+ if (skip(root)) return // a download already asked this peer
117
+ peer.send.want({ root: b4a.from(root, 'hex'), size })
118
+ }
119
+
120
+ _of (peerId) {
121
+ let held = this.peers.get(peerId)
122
+ if (!held) this.peers.set(peerId, held = new Map())
123
+ return held
124
+ }
125
+ }
package/lib/listing.js ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Reading a folder's file list: fetch the manifest into memory, validate it, and leave nothing
3
+ * behind. Nothing here touches disk, and it only ever looks for peers — a node that is merely
4
+ * reading a list is not a provider of the folder.
5
+ */
6
+
7
+ import b4a from 'b4a'
8
+ import { bao } from './blake3.js'
9
+
10
+ import { decodeManifest, totalSize, MAX_MANIFEST_BYTES } from './collection.js'
11
+ import { discoveryTopic } from './address.js'
12
+ import { GroupTree, groupHash, verifyGroup, groupCount } from './tree.js'
13
+ import { NodeStore } from './nodes.js'
14
+
15
+ /**
16
+ * The same surface a `Download` uses on a file destination, kept in memory: verify a group
17
+ * before storing it, remember the proofs, hand groups back out. A manifest is small and capped,
18
+ * so there is no resume state to keep.
19
+ */
20
+ export class MemoryDestination {
21
+ constructor ({ root, size, groupLog }) {
22
+ if (size > MAX_MANIFEST_BYTES) throw new Error('manifest is implausibly large')
23
+
24
+ this.root = root
25
+ this.size = size
26
+ this.groupLog = groupLog
27
+ this.groupCount = groupCount(size, groupLog)
28
+
29
+ this._groups = new Map() // index → bytes
30
+ this._bitfield = bao.createBitfield(this.groupCount)
31
+ this._nodes = new NodeStore(this.groupCount)
32
+ }
33
+
34
+ get have () { return this._groups.size }
35
+ get complete () { return this._groups.size === this.groupCount }
36
+ has (index) { return bao.getBit(this._bitfield, index) }
37
+ bitfield () { return this._bitfield }
38
+
39
+ missing () {
40
+ const out = []
41
+ for (let i = 0; i < this.groupCount; i++) if (!this.has(i)) out.push(i)
42
+ return out
43
+ }
44
+
45
+ async put (index, data, proof) {
46
+ if (!Number.isInteger(index) || index < 0 || index >= this.groupCount) return false
47
+ if (this.has(index)) return true
48
+
49
+ const { root, size, groupLog } = this
50
+ if (!verifyGroup({ root, size, index, data, proof, groupLog })) return false
51
+
52
+ const hash = groupHash(data, index, { groupLog, only: this.groupCount === 1 })
53
+ this._nodes.learn(index, hash, proof)
54
+ this._groups.set(index, b4a.from(data))
55
+ bao.setBit(this._bitfield, index)
56
+
57
+ return true
58
+ }
59
+
60
+ get tree () {
61
+ return new GroupTree(this._nodes.leaves(), { size: this.size, groupLog: this.groupLog })
62
+ }
63
+
64
+ proof (index) {
65
+ return this.has(index) ? this._nodes.proof(index) : null
66
+ }
67
+
68
+ async readGroup (index) {
69
+ return this._groups.get(index) ?? null
70
+ }
71
+
72
+ /** The whole content, once every group is held. */
73
+ bytes () {
74
+ const parts = []
75
+ for (let i = 0; i < this.groupCount; i++) parts.push(this._groups.get(i))
76
+ return b4a.concat(parts)
77
+ }
78
+
79
+ async finish () {}
80
+ async close () {}
81
+ }
82
+
83
+ /**
84
+ * A folder's file list being read. Thenable, like a `Download`: `await listing` resolves to
85
+ * `{ files: [{ path, root, size }], size }`, and `stop()` gives up waiting, which resolves it
86
+ * to `undefined`.
87
+ */
88
+ export class Listing {
89
+ /** A read that failed before it began, such as a link that is not a folder. */
90
+ static failed (err) {
91
+ const listing = Object.create(Listing.prototype)
92
+ listing._promise = Promise.reject(err)
93
+ listing._promise.catch(() => {}) // the caller sees it; this only keeps it from being unhandled
94
+ listing.stop = async () => {}
95
+ return listing
96
+ }
97
+
98
+ /** A read that needs no network: the list is already known. */
99
+ static known (files) {
100
+ const listing = Object.create(Listing.prototype)
101
+ listing._promise = Promise.resolve({ files, size: totalSize(files) })
102
+ listing.stop = async () => {}
103
+ return listing
104
+ }
105
+
106
+ constructor (node, parsed) {
107
+ this._download = null
108
+ this._session = null
109
+ this._stopped = false
110
+
111
+ this._promise = this._run(node, parsed)
112
+ this._promise.catch(() => {})
113
+ }
114
+
115
+ async stop () {
116
+ this._stopped = true
117
+ await this._download?.stop()
118
+ }
119
+
120
+ then (onfulfilled, onrejected) {
121
+ return this._promise.then(onfulfilled, onrejected)
122
+ }
123
+
124
+ catch (onrejected) {
125
+ return this._promise.catch(onrejected)
126
+ }
127
+
128
+ finally (onfinally) {
129
+ return this._promise.finally(onfinally)
130
+ }
131
+
132
+ async _run (node, parsed) {
133
+ if (node.downloads.has(parsed.root)) throw new Error('that link is already being downloaded as a file')
134
+
135
+ let memory = null
136
+
137
+ // Only look: with no server session on the topic this node is never announced for it.
138
+ this._session = node.swarm.join(discoveryTopic(parsed.root, parsed.secret), { server: false, client: true })
139
+
140
+ try {
141
+ this._download = node.download({ ...parsed, name: null }, null, {
142
+ announce: false,
143
+ destination: (options) => (memory = new MemoryDestination(options))
144
+ })
145
+ if (this._stopped) await this._download.stop()
146
+
147
+ await this._download
148
+ if (this._stopped) return undefined
149
+
150
+ const files = decodeManifest(memory.bytes())
151
+ node._learnList(parsed.root, files)
152
+
153
+ return { files, size: totalSize(files) }
154
+ } finally {
155
+ await this._session?.destroy?.()
156
+ }
157
+ }
158
+ }
package/lib/swarmwire.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * A swarmwire node: serve content you already have, download content you want.
3
3
  *
4
- * It takes a swarm — anything with `join(topic)`, `leave(topic)` and a `connection` event —
4
+ * It takes a swarm — anything with `join(topic, opts)` (returning a session with `destroy()`) and a `connection` event —
5
5
  * so the same code runs against Hyperswarm and against the deterministic test network.
6
6
  *
7
7
  * Scheduling is deliberately plain for now: ask every peer that has the content, keep a small
@@ -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
- export class Swarmwire {
57
+ /** Passing options makes `join` return a session that leaves only itself. */
58
+ const ANNOUNCE = { server: true, client: true }
59
+
60
+ /**
61
+ * Emits `peer-connected` and `peer-disconnected`, each with `{ id }`, for peers that completed
62
+ * the swarmwire handshake. `peerCount` and `counts(link)` read the same state.
63
+ */
64
+ export class Swarmwire extends EventEmitter {
56
65
  /**
57
66
  * @param {{swarm: object, groupLog?: number, timers?: object, requestTimeout?: number,
58
67
  * window?: number}} opts
@@ -64,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.swarm.join(discoveryTopic(source.root, secret))
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.swarm.join(discoveryTopic(manifest.root, secret))
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
- * Fetch a collection: the manifest first, then everything it names, into `dir`.
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
- const files = await this._fetchManifest(parsed, dir)
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
- // The files are not announced separately: the peers holding them are the ones already
166
- // connected through the manifest's topic.
167
- await this.download(
168
- { root: file.root, size: file.size, secret: parsed.secret, name: file.path },
169
- target,
170
- { announce: false }
171
- )
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(id)
359
+ onclose: () => peer && this._onPeerGone(peer)
255
360
  })
256
361
 
257
362
  if (channel === null) return
258
363
 
259
- const peer = { id, send: channel.send, wants: new Set() }
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
- _onPeerGone (id) {
270
- const peer = this.peers.get(id)
271
- if (!peer) return
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
- this.peers.delete(id)
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
- this.serving.set(root, await ContentSource.adopt(path, tree))
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 download = this.downloads.get(hex(message.root))
487
+ const rootHex = hex(message.root)
488
+ const download = this.downloads.get(rootHex)
368
489
  const peer = this.peers.get(id)
369
- if (download && peer) download._onHolds(peer, message)
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 download = this.downloads.get(hex(message.root))
504
+ const rootHex = hex(message.root)
505
+ const download = this.downloads.get(rootHex)
380
506
  const peer = this.peers.get(id)
381
- if (download && peer) download._onHave(peer, message)
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 download = this.downloads.get(hex(message.root))
520
+ const rootHex = hex(message.root)
521
+ const download = this.downloads.get(rootHex)
392
522
  const peer = this.peers.get(id)
393
- if (download && peer) download._sources.delete(peer.id)
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
- if (this.announced) this.node.swarm.leave?.(discoveryTopic(this.root, this.secret))
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 destination = await Destination.open(this.path, {
534
- root: this.root,
535
- size: this.size,
536
- groupLog: this.node.groupLog
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
- * Six messages. A peer asks who has some content, holders say so, the asker requests groups
4
+ * Eight messages. A peer asks who has some content, holders say so, the asker requests groups
5
5
  * one at a time, and each answer carries the proof that makes it checkable on arrival.
6
6
  * Scheduling, windows and peer choice live in swarmwire.js — none of that is on the wire.
7
7
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swarmwire-lib",
3
- "version": "0.1.0",
3
+ "version": "0.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",