swarmwire-lib 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +137 -2
- package/docs/contract.md +121 -0
- package/docs/format.md +180 -0
- package/index.js +12 -0
- package/lib/address.js +166 -0
- package/lib/blake3.cjs +15 -0
- package/lib/blake3.js +12 -0
- package/lib/collection.js +133 -0
- package/lib/destination.js +237 -0
- package/lib/fs.js +176 -0
- package/lib/nodes.js +118 -0
- package/lib/partfile.js +169 -0
- package/lib/select.js +33 -0
- package/lib/source.js +171 -0
- package/lib/swarmwire.js +841 -0
- package/lib/timers.js +21 -0
- package/lib/tree.js +326 -0
- package/lib/wire.js +95 -0
- package/package.json +94 -4
package/lib/swarmwire.js
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A swarmwire node: serve content you already have, download content you want.
|
|
3
|
+
*
|
|
4
|
+
* It takes a swarm — anything with `join(topic)`, `leave(topic)` and a `connection` event —
|
|
5
|
+
* so the same code runs against Hyperswarm and against the deterministic test network.
|
|
6
|
+
*
|
|
7
|
+
* Scheduling is deliberately plain for now: ask every peer that has the content, keep a small
|
|
8
|
+
* window of requests in flight with each, take whichever groups come back. Rarest-first,
|
|
9
|
+
* measured queue depths and peer banning are M2, and the harness is where they will be proved.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { EventEmitter } from 'events'
|
|
13
|
+
import b4a from 'b4a'
|
|
14
|
+
|
|
15
|
+
import { bao } from './blake3.js'
|
|
16
|
+
|
|
17
|
+
import { ContentSource, MemorySource } from './source.js'
|
|
18
|
+
import { encodeManifest, decodeManifest, totalSize } from './collection.js'
|
|
19
|
+
import { Destination } from './destination.js'
|
|
20
|
+
import { discoveryTopic, formatLink, parseLink } from './address.js'
|
|
21
|
+
import { DEFAULT_GROUP_LOG, groupCount } from './tree.js'
|
|
22
|
+
import { openChannel, VERSION } from './wire.js'
|
|
23
|
+
import { rarestFirst } from './select.js'
|
|
24
|
+
import { systemTimers } from './timers.js'
|
|
25
|
+
import * as fsx from './fs.js'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Requests in flight per peer, to begin with.
|
|
29
|
+
*
|
|
30
|
+
* No fixed number is right. Keeping a link busy needs roughly (2 x latency x bandwidth) /
|
|
31
|
+
* group size requests outstanding: about 20 for a nearby link, 1600 for a fast distant one —
|
|
32
|
+
* `npm run bench` measures both. So the window starts here and adapts upwards.
|
|
33
|
+
*
|
|
34
|
+
* 32 is where the ramp stops being the bottleneck: starting at 8 costs 1.46x against an
|
|
35
|
+
* oracle-tuned fixed window on a distant link, 32 costs 1.25x, and 64 costs 1.19x for twice
|
|
36
|
+
* the memory. 32 outstanding requests is half a megabyte in flight per peer.
|
|
37
|
+
*/
|
|
38
|
+
const WINDOW = 32
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The most requests to have outstanding with one peer: 512 groups is 8 MiB of memory at the
|
|
42
|
+
* default group size, which is as much as one peer should be able to reserve.
|
|
43
|
+
*/
|
|
44
|
+
const WINDOW_MAX = 512
|
|
45
|
+
|
|
46
|
+
/** Never go below this: a peer that keeps failing still deserves a chance to answer. */
|
|
47
|
+
const WINDOW_MIN = 4
|
|
48
|
+
|
|
49
|
+
/** How long to wait for a group before assuming the peer is not going to send it. */
|
|
50
|
+
const REQUEST_TIMEOUT = 15000
|
|
51
|
+
|
|
52
|
+
/** Timeouts a peer gets before it stops being asked. */
|
|
53
|
+
const MAX_TIMEOUTS = 3
|
|
54
|
+
|
|
55
|
+
export class Swarmwire {
|
|
56
|
+
/**
|
|
57
|
+
* @param {{swarm: object, groupLog?: number, timers?: object, requestTimeout?: number,
|
|
58
|
+
* window?: number}} opts
|
|
59
|
+
*/
|
|
60
|
+
constructor ({
|
|
61
|
+
swarm,
|
|
62
|
+
groupLog = DEFAULT_GROUP_LOG,
|
|
63
|
+
timers = systemTimers,
|
|
64
|
+
requestTimeout = REQUEST_TIMEOUT,
|
|
65
|
+
window = WINDOW
|
|
66
|
+
}) {
|
|
67
|
+
this.swarm = swarm
|
|
68
|
+
this.groupLog = groupLog
|
|
69
|
+
this.timers = timers
|
|
70
|
+
this.requestTimeout = requestTimeout
|
|
71
|
+
this.window = window
|
|
72
|
+
this.serving = new Map() // root → ContentSource
|
|
73
|
+
this.downloads = new Map() // root → Download
|
|
74
|
+
this.peers = new Map() // peer id → Peer
|
|
75
|
+
|
|
76
|
+
this._onconnection = (stream, info) => this._onPeer(stream, info)
|
|
77
|
+
this.swarm.on('connection', this._onconnection)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Share a file that already exists on disk. Nothing is copied, nothing is written next to
|
|
82
|
+
* it: the file is hashed where it lies and served from there.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} path
|
|
85
|
+
* @param {{secret?: string|null, name?: string|null}} [opts]
|
|
86
|
+
* @returns {Promise<{root: string, size: number, link: string}>}
|
|
87
|
+
*/
|
|
88
|
+
async serve (path, { secret = null, name = basename(path) } = {}) {
|
|
89
|
+
const source = await ContentSource.open(path, { groupLog: this.groupLog })
|
|
90
|
+
|
|
91
|
+
const existing = this.serving.get(source.root)
|
|
92
|
+
if (existing && existing !== source) await source.close()
|
|
93
|
+
else this.serving.set(source.root, source)
|
|
94
|
+
|
|
95
|
+
// Wait for the announce to actually land. Until it has, a peer looking this content up
|
|
96
|
+
// finds nobody, which is indistinguishable from "that link does not work".
|
|
97
|
+
const discovery = this.swarm.join(discoveryTopic(source.root, secret))
|
|
98
|
+
if (typeof discovery?.flushed === 'function') await discovery.flushed()
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
root: source.root,
|
|
102
|
+
size: source.size,
|
|
103
|
+
link: formatLink({ root: source.root, size: source.size, name, secret })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Share a whole folder under one link.
|
|
109
|
+
*
|
|
110
|
+
* Every file is hashed where it lies and a manifest naming them is served from memory, so
|
|
111
|
+
* sharing a folder does not add anything to it. The link addresses the manifest; the files
|
|
112
|
+
* are fetched over the same connections afterwards, so a thousand files still cost one
|
|
113
|
+
* announce.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} dir
|
|
116
|
+
* @param {{secret?: string|null, name?: string|null}} [opts]
|
|
117
|
+
* @returns {Promise<{root: string, size: number, files: number, link: string}>}
|
|
118
|
+
*/
|
|
119
|
+
async serveCollection (dir, { secret = null, name = basename(dir) } = {}) {
|
|
120
|
+
const paths = await fsx.list(dir)
|
|
121
|
+
const entries = []
|
|
122
|
+
|
|
123
|
+
for (const relative of paths) {
|
|
124
|
+
const source = await ContentSource.open(join(dir, relative), { groupLog: this.groupLog })
|
|
125
|
+
if (!this.serving.has(source.root)) this.serving.set(source.root, source)
|
|
126
|
+
else await source.close()
|
|
127
|
+
|
|
128
|
+
entries.push({ path: relative, root: source.root, size: source.size })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const manifest = MemorySource.from(encodeManifest(entries), { groupLog: this.groupLog })
|
|
132
|
+
this.serving.set(manifest.root, manifest)
|
|
133
|
+
|
|
134
|
+
const discovery = this.swarm.join(discoveryTopic(manifest.root, secret))
|
|
135
|
+
if (typeof discovery?.flushed === 'function') await discovery.flushed()
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
root: manifest.root,
|
|
139
|
+
size: totalSize(entries),
|
|
140
|
+
files: entries.length,
|
|
141
|
+
link: formatLink({ root: manifest.root, size: manifest.size, name, secret, type: 'collection' })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Fetch a collection: the manifest first, then everything it names, into `dir`.
|
|
147
|
+
*
|
|
148
|
+
* @param {string|object} link
|
|
149
|
+
* @param {string} dir
|
|
150
|
+
* @param {{onfile?: Function}} [opts]
|
|
151
|
+
* @returns {Promise<{files: number, bytes: number}>}
|
|
152
|
+
*/
|
|
153
|
+
async downloadCollection (link, dir, { onfile = null } = {}) {
|
|
154
|
+
const parsed = typeof link === 'string' ? parseLink(link) : link
|
|
155
|
+
|
|
156
|
+
await fsx.mkdir(dir)
|
|
157
|
+
const files = await this._fetchManifest(parsed, dir)
|
|
158
|
+
|
|
159
|
+
let bytes = 0
|
|
160
|
+
|
|
161
|
+
for (const file of files) {
|
|
162
|
+
const target = join(dir, file.path)
|
|
163
|
+
await fsx.mkdir(parentOf(target))
|
|
164
|
+
|
|
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
|
+
)
|
|
172
|
+
|
|
173
|
+
bytes += file.size
|
|
174
|
+
if (onfile) onfile({ path: file.path, size: file.size })
|
|
175
|
+
}
|
|
176
|
+
|
|
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
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Stop serving content. The file itself is untouched. */
|
|
197
|
+
async unserve (root) {
|
|
198
|
+
const source = this.serving.get(root)
|
|
199
|
+
if (!source) return
|
|
200
|
+
|
|
201
|
+
this.serving.delete(root)
|
|
202
|
+
await source.close()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Fetch content by link. Returns immediately; await the result for completion, or
|
|
207
|
+
* `await download.ready()` to know the destination has been opened.
|
|
208
|
+
*
|
|
209
|
+
* @param {string|object} link
|
|
210
|
+
* @param {string} outPath
|
|
211
|
+
* @returns {Download}
|
|
212
|
+
*/
|
|
213
|
+
download (link, outPath, { announce = true } = {}) {
|
|
214
|
+
const parsed = typeof link === 'string' ? parseLink(link) : link
|
|
215
|
+
|
|
216
|
+
const existing = this.downloads.get(parsed.root)
|
|
217
|
+
if (existing) return existing
|
|
218
|
+
|
|
219
|
+
const download = new Download(this, parsed, outPath, { announced: announce })
|
|
220
|
+
this.downloads.set(parsed.root, download)
|
|
221
|
+
|
|
222
|
+
// A file inside a collection is not announced on its own: whoever has it is already
|
|
223
|
+
// connected through the collection's topic, and a folder of a thousand files should not
|
|
224
|
+
// mean a thousand announces.
|
|
225
|
+
if (announce) this.swarm.join(discoveryTopic(parsed.root, parsed.secret))
|
|
226
|
+
|
|
227
|
+
download._start()
|
|
228
|
+
|
|
229
|
+
return download
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async destroy () {
|
|
233
|
+
this.swarm.removeListener?.('connection', this._onconnection)
|
|
234
|
+
|
|
235
|
+
for (const download of [...this.downloads.values()]) await download.stop()
|
|
236
|
+
for (const root of [...this.serving.keys()]) await this.unserve(root)
|
|
237
|
+
|
|
238
|
+
this.peers.clear()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// -- peers ----------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
_onPeer (stream, info) {
|
|
244
|
+
const id = peerId(info)
|
|
245
|
+
const channel = openChannel(stream, {
|
|
246
|
+
onhandshake: () => {},
|
|
247
|
+
onwant: (message) => this._onWant(id, message),
|
|
248
|
+
onhave: (message) => this._onHave(id, message),
|
|
249
|
+
onrequest: (message) => this._onRequest(id, message),
|
|
250
|
+
ondata: (message) => this._onData(id, message),
|
|
251
|
+
onunavailable: (message) => this._onUnavailable(id, message),
|
|
252
|
+
onholds: (message) => this._onHolds(id, message),
|
|
253
|
+
onreject: (message) => this._onReject(id, message),
|
|
254
|
+
onclose: () => this._onPeerGone(id)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
if (channel === null) return
|
|
258
|
+
|
|
259
|
+
const peer = { id, send: channel.send, wants: new Set() }
|
|
260
|
+
this.peers.set(id, peer)
|
|
261
|
+
|
|
262
|
+
peer.send.handshake({ version: VERSION, groupLog: this.groupLog })
|
|
263
|
+
|
|
264
|
+
// Tell a new peer what we are after. A peer that has it answers; one that does not stays
|
|
265
|
+
// quiet and is simply never asked.
|
|
266
|
+
for (const download of this.downloads.values()) download._askPeer(peer)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
_onPeerGone (id) {
|
|
270
|
+
const peer = this.peers.get(id)
|
|
271
|
+
if (!peer) return
|
|
272
|
+
|
|
273
|
+
this.peers.delete(id)
|
|
274
|
+
for (const download of this.downloads.values()) download._peerGone(peer)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
_onWant (id, { root }) {
|
|
278
|
+
const peer = this.peers.get(id)
|
|
279
|
+
if (!peer) return
|
|
280
|
+
|
|
281
|
+
const rootHex = hex(root)
|
|
282
|
+
peer.wants.add(rootHex) // so new groups can be announced to them later
|
|
283
|
+
|
|
284
|
+
const source = this.serving.get(rootHex)
|
|
285
|
+
if (source) {
|
|
286
|
+
peer.send.have({
|
|
287
|
+
root,
|
|
288
|
+
size: source.size,
|
|
289
|
+
groupLog: source.groupLog,
|
|
290
|
+
bitfield: fullBitfield(source.groupCount)
|
|
291
|
+
})
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// A download in progress is a source too, for whatever it has verified so far. This is
|
|
296
|
+
// what keeps content alive once the original sender leaves.
|
|
297
|
+
const partial = this.downloads.get(rootHex)
|
|
298
|
+
if (partial?.servable) {
|
|
299
|
+
peer.send.have({
|
|
300
|
+
root,
|
|
301
|
+
size: partial.size,
|
|
302
|
+
groupLog: this.groupLog,
|
|
303
|
+
bitfield: partial.bitfield()
|
|
304
|
+
})
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
peer.send.unavailable({ root })
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async _onRequest (id, { root, index }) {
|
|
312
|
+
const peer = this.peers.get(id)
|
|
313
|
+
if (!peer) return
|
|
314
|
+
|
|
315
|
+
const rootHex = hex(root)
|
|
316
|
+
const source = this.serving.get(rootHex)
|
|
317
|
+
|
|
318
|
+
if (source) {
|
|
319
|
+
if (index < 0 || index >= source.groupCount) return peer.send.reject({ root, index })
|
|
320
|
+
|
|
321
|
+
const data = await source.readGroup(index)
|
|
322
|
+
if (!this.peers.has(id)) return // they left while we were reading
|
|
323
|
+
|
|
324
|
+
peer.send.data({ root, index, proof: source.proof(index), data })
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Anything we cannot answer gets a refusal rather than silence, so the asker can give the
|
|
329
|
+
// group to someone else now instead of after a timeout.
|
|
330
|
+
const partial = this.downloads.get(rootHex)
|
|
331
|
+
if (!partial?.servable) return peer.send.reject({ root, index })
|
|
332
|
+
|
|
333
|
+
const served = await partial.serve(index)
|
|
334
|
+
if (!this.peers.has(id)) return
|
|
335
|
+
if (served === null) return peer.send.reject({ root, index })
|
|
336
|
+
|
|
337
|
+
peer.send.data({ root, index, proof: served.proof, data: served.data })
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Keep serving content we just finished downloading, from where it landed.
|
|
342
|
+
*
|
|
343
|
+
* @param {string} root
|
|
344
|
+
* @param {string} path
|
|
345
|
+
* @param {import('./tree.js').GroupTree} tree
|
|
346
|
+
*/
|
|
347
|
+
async _adopt (root, path, tree) {
|
|
348
|
+
if (this.serving.has(root)) return
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
this.serving.set(root, await ContentSource.adopt(path, tree))
|
|
352
|
+
} catch {
|
|
353
|
+
// The file was moved or deleted between finishing and adopting it. Nothing to serve.
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Tell everyone who asked for this content that we now hold one more piece of it. */
|
|
358
|
+
_announceHolds (rootHex, index) {
|
|
359
|
+
const root = b4a.from(rootHex, 'hex')
|
|
360
|
+
|
|
361
|
+
for (const peer of this.peers.values()) {
|
|
362
|
+
if (peer.wants.has(rootHex)) peer.send.holds({ root, index })
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
_onHolds (id, message) {
|
|
367
|
+
const download = this.downloads.get(hex(message.root))
|
|
368
|
+
const peer = this.peers.get(id)
|
|
369
|
+
if (download && peer) download._onHolds(peer, message)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
_onReject (id, message) {
|
|
373
|
+
const download = this.downloads.get(hex(message.root))
|
|
374
|
+
const peer = this.peers.get(id)
|
|
375
|
+
if (download && peer) download._onReject(peer, message)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
_onHave (id, message) {
|
|
379
|
+
const download = this.downloads.get(hex(message.root))
|
|
380
|
+
const peer = this.peers.get(id)
|
|
381
|
+
if (download && peer) download._onHave(peer, message)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
_onData (id, message) {
|
|
385
|
+
const download = this.downloads.get(hex(message.root))
|
|
386
|
+
const peer = this.peers.get(id)
|
|
387
|
+
if (download && peer) download._onData(peer, message)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
_onUnavailable (id, message) {
|
|
391
|
+
const download = this.downloads.get(hex(message.root))
|
|
392
|
+
const peer = this.peers.get(id)
|
|
393
|
+
if (download && peer) download._sources.delete(peer.id)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* One transfer in progress. Thenable, so `await download` waits for it to finish.
|
|
399
|
+
*/
|
|
400
|
+
export class Download extends EventEmitter {
|
|
401
|
+
constructor (node, { root, size, secret }, outPath, { announced = true } = {}) {
|
|
402
|
+
super()
|
|
403
|
+
|
|
404
|
+
this.announced = announced
|
|
405
|
+
|
|
406
|
+
this.node = node
|
|
407
|
+
this.root = root
|
|
408
|
+
this.size = size // may be null until a peer tells us
|
|
409
|
+
this.secret = secret ?? null
|
|
410
|
+
this.path = outPath
|
|
411
|
+
|
|
412
|
+
this.received = 0 // groups accepted over the wire this session
|
|
413
|
+
this.rejected = 0 // groups that failed verification
|
|
414
|
+
this.timedOut = 0 // requests a peer never answered
|
|
415
|
+
this.rejectedByPeers = 0 // requests a peer declined
|
|
416
|
+
this.peersUsed = new Set()
|
|
417
|
+
this.stopped = false
|
|
418
|
+
|
|
419
|
+
this._destination = null
|
|
420
|
+
this._bitfields = new Map() // peer id -> what that peer says it holds
|
|
421
|
+
this._sources = new Set() // peer ids worth asking right now
|
|
422
|
+
this._liars = new Set() // peer ids that sent something that did not verify: never again
|
|
423
|
+
this._asked = new Set() // peer ids already told what we want
|
|
424
|
+
this._inflight = new Map() // peer id → Map(group index → cancel its timer)
|
|
425
|
+
this._strikes = new Map() // peer id → requests it has failed to answer
|
|
426
|
+
this._windows = new Map() // peer id → how many requests to keep outstanding with it
|
|
427
|
+
this._ready = null
|
|
428
|
+
this._resolve = null
|
|
429
|
+
this._reject = null
|
|
430
|
+
this._promise = new Promise((resolve, reject) => {
|
|
431
|
+
this._resolve = resolve
|
|
432
|
+
this._reject = reject
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Resolves once the destination file is open and progress is known. */
|
|
437
|
+
ready () {
|
|
438
|
+
return this._ready ?? Promise.resolve()
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
get total () {
|
|
442
|
+
return this._destination ? this._destination.groupCount : (this.size === null ? 0 : groupCount(this.size, this.node.groupLog))
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
get have () {
|
|
446
|
+
return this._destination ? this._destination.have : 0
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
get complete () {
|
|
450
|
+
return this._destination ? this._destination.complete : false
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Is there anything here worth offering other peers? A download with verified groups is a
|
|
455
|
+
* source like any other — this is what keeps content alive after the sender leaves.
|
|
456
|
+
*/
|
|
457
|
+
get servable () {
|
|
458
|
+
return !this.stopped && this._destination !== null && this._destination.have > 0
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** What this download currently holds, for advertising to peers. */
|
|
462
|
+
bitfield () {
|
|
463
|
+
return this._destination ? this._destination.bitfield() : new Uint8Array(0)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Serve one group out of what has been verified so far.
|
|
468
|
+
*
|
|
469
|
+
* @param {number} index
|
|
470
|
+
* @returns {Promise<{data: Uint8Array, proof: Uint8Array[]}|null>} null when it cannot be
|
|
471
|
+
* proved yet: holding a group is not enough, the sibling hashes have to be there too
|
|
472
|
+
*/
|
|
473
|
+
async serve (index) {
|
|
474
|
+
if (!this.servable) return null
|
|
475
|
+
|
|
476
|
+
const proof = this._destination.proof(index)
|
|
477
|
+
if (proof === null) return null
|
|
478
|
+
|
|
479
|
+
const data = await this._destination.readGroup(index)
|
|
480
|
+
return data === null ? null : { data, proof }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Requests sent and not yet answered. */
|
|
484
|
+
get inflight () {
|
|
485
|
+
let n = 0
|
|
486
|
+
for (const requests of this._inflight.values()) n += requests.size
|
|
487
|
+
return n
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Stop without finishing. What has been verified stays on disk, ready to resume. */
|
|
491
|
+
async stop () {
|
|
492
|
+
if (this.stopped) return
|
|
493
|
+
this.stopped = true
|
|
494
|
+
|
|
495
|
+
// Let a destination that is mid-open finish, so there is something to close.
|
|
496
|
+
await this.ready().catch(() => {})
|
|
497
|
+
|
|
498
|
+
this._cancelEverything()
|
|
499
|
+
this.node.downloads.delete(this.root)
|
|
500
|
+
if (this.announced) this.node.swarm.leave?.(discoveryTopic(this.root, this.secret))
|
|
501
|
+
|
|
502
|
+
if (this._destination) await this._destination.close()
|
|
503
|
+
|
|
504
|
+
// Never resolve with `this`: a Download is itself a thenable, and a promise resolved with
|
|
505
|
+
// its own thenable waits for itself forever.
|
|
506
|
+
this._resolve()
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
then (onfulfilled, onrejected) {
|
|
510
|
+
return this._promise.then(onfulfilled, onrejected)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
catch (onrejected) {
|
|
514
|
+
return this._promise.catch(onrejected)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
finally (onfinally) {
|
|
518
|
+
return this._promise.finally(onfinally)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// -- internals ------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
_start () {
|
|
524
|
+
if (this.size === null) return // wait for a peer to tell us how big it is
|
|
525
|
+
|
|
526
|
+
this._ready = this._open()
|
|
527
|
+
this._ready.catch((err) => this._fail(err))
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async _open () {
|
|
531
|
+
if (this._destination) return
|
|
532
|
+
|
|
533
|
+
const destination = await Destination.open(this.path, {
|
|
534
|
+
root: this.root,
|
|
535
|
+
size: this.size,
|
|
536
|
+
groupLog: this.node.groupLog
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
// The download can be stopped while the file is still opening. Nobody would ever close
|
|
540
|
+
// this handle otherwise.
|
|
541
|
+
if (this.stopped) return destination.close()
|
|
542
|
+
|
|
543
|
+
this._destination = destination
|
|
544
|
+
if (this._destination.complete) return this._finish()
|
|
545
|
+
|
|
546
|
+
for (const peer of this.node.peers.values()) this._askPeer(peer)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
_askPeer (peer) {
|
|
550
|
+
if (this.stopped || this._asked.has(peer.id)) return
|
|
551
|
+
|
|
552
|
+
this._asked.add(peer.id)
|
|
553
|
+
peer.send.want({ root: b4a.from(this.root, 'hex'), size: this.size ?? 0 })
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async _onHave (peer, { size, groupLog, bitfield }) {
|
|
557
|
+
if (this.stopped) return
|
|
558
|
+
if (groupLog !== this.node.groupLog) return // we do not speak that granularity yet
|
|
559
|
+
|
|
560
|
+
if (this._liars.has(peer.id)) return // nothing it says is worth storing
|
|
561
|
+
|
|
562
|
+
this._bitfields.set(peer.id, b4a.from(bitfield ?? []))
|
|
563
|
+
this._sources.add(peer.id)
|
|
564
|
+
|
|
565
|
+
if (this.size === null) {
|
|
566
|
+
// No size hint in the link, so take the peer's word for it. A lie here cannot corrupt
|
|
567
|
+
// anything: every group is still checked against the root, and a wrong size fails.
|
|
568
|
+
this.size = size
|
|
569
|
+
this._ready = this._open()
|
|
570
|
+
this._ready.catch((err) => this._fail(err))
|
|
571
|
+
await this._ready
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
await this.ready()
|
|
575
|
+
this._pump(peer)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async _onData (peer, { index, proof, data }) {
|
|
579
|
+
if (this.stopped || !this._destination) return
|
|
580
|
+
|
|
581
|
+
// Were we asking this peer for as much as we allow when the answer came? If so the
|
|
582
|
+
// window, not the link, is what limits this peer, and it should grow.
|
|
583
|
+
const saturated = this._requests(peer).size >= this._windowFor(peer)
|
|
584
|
+
|
|
585
|
+
// Late answers still count. A group whose timer already fired went back in the pool, but
|
|
586
|
+
// the bytes verify against the root either way: throwing away something we still need, in
|
|
587
|
+
// order to go and ask somebody else for it, would be perverse.
|
|
588
|
+
const accepted = await this._destination.put(index, data, proof).catch(() => false)
|
|
589
|
+
this._release(peer, index) // only now: the group stays claimed while it is being written
|
|
590
|
+
|
|
591
|
+
if (accepted) {
|
|
592
|
+
this.received++
|
|
593
|
+
this.peersUsed.add(peer.id)
|
|
594
|
+
// Answering clears the record of not answering, and earns back a place in the pool: a
|
|
595
|
+
// peer dropped for being slow has just proved it is worth asking again.
|
|
596
|
+
this._strikes.delete(peer.id)
|
|
597
|
+
this._widen(peer, saturated)
|
|
598
|
+
if (!this._liars.has(peer.id) && this._bitfields.has(peer.id)) this._sources.add(peer.id)
|
|
599
|
+
this.node._announceHolds(this.root, index) // we can serve this one now
|
|
600
|
+
this.emit('progress', { have: this.have, total: this.total, from: peer.id })
|
|
601
|
+
} else {
|
|
602
|
+
this.rejected++
|
|
603
|
+
this.emit('rejected', { index, from: peer.id })
|
|
604
|
+
this._liars.add(peer.id) // a peer that sends rubbish is never asked again
|
|
605
|
+
this._sources.delete(peer.id)
|
|
606
|
+
this._bitfields.delete(peer.id)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (this.complete) return this._finish()
|
|
610
|
+
this._pumpAll()
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* A peer took a request and never answered. Give the group back to the pool, and stop
|
|
615
|
+
* asking a peer that keeps doing it — silence costs more than refusal, because the group
|
|
616
|
+
* sits reserved while nothing happens.
|
|
617
|
+
*/
|
|
618
|
+
_onTimeout (peer, index) {
|
|
619
|
+
if (this.stopped) return
|
|
620
|
+
|
|
621
|
+
this._requests(peer).delete(index)
|
|
622
|
+
this.timedOut++
|
|
623
|
+
|
|
624
|
+
const strikes = (this._strikes.get(peer.id) ?? 0) + 1
|
|
625
|
+
this._strikes.set(peer.id, strikes)
|
|
626
|
+
if (strikes >= MAX_TIMEOUTS) this._sources.delete(peer.id)
|
|
627
|
+
|
|
628
|
+
this._narrow(peer) // asking for less is the first thing to try when a peer struggles
|
|
629
|
+
|
|
630
|
+
this.emit('timeout', { index, from: peer.id, strikes })
|
|
631
|
+
this._pumpAll()
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
_pump (peer) {
|
|
635
|
+
if (this.stopped || !this._destination || !this._sources.has(peer.id)) return
|
|
636
|
+
|
|
637
|
+
const requests = this._requests(peer)
|
|
638
|
+
const window = this._windowFor(peer)
|
|
639
|
+
const wanted = rarestFirst(this._destination.missing(), (index) => this._holders(index))
|
|
640
|
+
|
|
641
|
+
for (const index of wanted) {
|
|
642
|
+
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
|
+
|
|
646
|
+
this._claim(peer, index)
|
|
647
|
+
peer.send.request({ root: b4a.from(this.root, 'hex'), index })
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
_pumpAll () {
|
|
652
|
+
for (const peer of this.node.peers.values()) {
|
|
653
|
+
if (this._sources.has(peer.id)) this._pump(peer)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Nobody usable and nothing outstanding: say so rather than sitting silent forever.
|
|
657
|
+
// Whether to wait for new peers or give up belongs to the caller.
|
|
658
|
+
if (this._sources.size === 0 && this.inflight === 0 && !this.complete) {
|
|
659
|
+
this.emit('stalled', { have: this.have, total: this.total })
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The request window for one peer.
|
|
665
|
+
*
|
|
666
|
+
* It grows by one each time an answer arrives while we were already asking for as much as
|
|
667
|
+
* the window allows, and halves whenever a request is refused or times out. Additive
|
|
668
|
+
* increase and multiplicative decrease, the same shape as congestion control, for the same
|
|
669
|
+
* reason: the right depth depends on a link's latency and speed, which only the traffic
|
|
670
|
+
* knows.
|
|
671
|
+
*/
|
|
672
|
+
_windowFor (peer) {
|
|
673
|
+
return this._windows.get(peer.id) ?? this.node.window
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
_widen (peer, saturated) {
|
|
677
|
+
if (!saturated) return // the link, not the window, is what we are waiting on
|
|
678
|
+
|
|
679
|
+
const next = Math.min(this._windowFor(peer) + 1, WINDOW_MAX)
|
|
680
|
+
this._windows.set(peer.id, next)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
_narrow (peer) {
|
|
684
|
+
const next = Math.max(Math.floor(this._windowFor(peer) / 2), WINDOW_MIN)
|
|
685
|
+
this._windows.set(peer.id, next)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** How many connected peers hold this group. Drives rarest-first selection. */
|
|
689
|
+
_holders (index) {
|
|
690
|
+
let n = 0
|
|
691
|
+
for (const peerId of this._sources) {
|
|
692
|
+
if (this._peerHas(peerId, index)) n++
|
|
693
|
+
}
|
|
694
|
+
return n
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Does this peer hold that group? An empty bitfield means we were told nothing useful. */
|
|
698
|
+
_peerHas (peerId, index) {
|
|
699
|
+
const bitfield = this._bitfields.get(peerId)
|
|
700
|
+
if (!bitfield || bitfield.length === 0) return false
|
|
701
|
+
|
|
702
|
+
return bao.getBit(bitfield, index)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* A peer saying it will not serve a group after all. Take the group back, and stop
|
|
707
|
+
* believing it has that one until it says otherwise.
|
|
708
|
+
*/
|
|
709
|
+
_onReject (peer, { index }) {
|
|
710
|
+
if (this.stopped) return
|
|
711
|
+
|
|
712
|
+
this._release(peer, index)
|
|
713
|
+
this.rejectedByPeers++
|
|
714
|
+
this._narrow(peer)
|
|
715
|
+
|
|
716
|
+
const bitfield = this._bitfields.get(peer.id)
|
|
717
|
+
if (bitfield && index < bitfield.length * 8) bao.clearBit(bitfield, index)
|
|
718
|
+
|
|
719
|
+
this._pumpAll()
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** A peer announcing one more group it now holds. */
|
|
723
|
+
_onHolds (peer, { index }) {
|
|
724
|
+
const bitfield = this._bitfields.get(peer.id)
|
|
725
|
+
if (!bitfield || index >= bitfield.length * 8) return
|
|
726
|
+
|
|
727
|
+
bao.setBit(bitfield, index)
|
|
728
|
+
this._pump(peer)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
_requests (peer) {
|
|
732
|
+
let requests = this._inflight.get(peer.id)
|
|
733
|
+
if (!requests) {
|
|
734
|
+
requests = new Map()
|
|
735
|
+
this._inflight.set(peer.id, requests)
|
|
736
|
+
}
|
|
737
|
+
return requests
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
_claim (peer, index) {
|
|
741
|
+
const cancel = this.node.timers.setTimer(
|
|
742
|
+
this.node.requestTimeout,
|
|
743
|
+
() => this._onTimeout(peer, index)
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
this._requests(peer).set(index, cancel)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
_release (peer, index) {
|
|
750
|
+
const requests = this._requests(peer)
|
|
751
|
+
const cancel = requests.get(index)
|
|
752
|
+
|
|
753
|
+
if (cancel) cancel()
|
|
754
|
+
requests.delete(index)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
_releaseAll (peerId) {
|
|
758
|
+
const requests = this._inflight.get(peerId)
|
|
759
|
+
if (!requests) return
|
|
760
|
+
|
|
761
|
+
for (const cancel of requests.values()) cancel()
|
|
762
|
+
this._inflight.delete(peerId)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** Is some peer already fetching this group? */
|
|
766
|
+
_claimed (index) {
|
|
767
|
+
for (const requests of this._inflight.values()) {
|
|
768
|
+
if (requests.has(index)) return true
|
|
769
|
+
}
|
|
770
|
+
return false
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
_peerGone (peer) {
|
|
774
|
+
this._sources.delete(peer.id)
|
|
775
|
+
this._bitfields.delete(peer.id)
|
|
776
|
+
this._asked.delete(peer.id)
|
|
777
|
+
this._releaseAll(peer.id) // whatever it owed us is up for grabs again
|
|
778
|
+
|
|
779
|
+
this._pumpAll()
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async _finish () {
|
|
783
|
+
if (this.stopped) return
|
|
784
|
+
this.stopped = true
|
|
785
|
+
|
|
786
|
+
this._cancelEverything()
|
|
787
|
+
this.node.downloads.delete(this.root)
|
|
788
|
+
|
|
789
|
+
// Every group was hashed on the way in, so the finished file can be served without
|
|
790
|
+
// reading it again. A download that stops seeding the moment it finishes would make the
|
|
791
|
+
// swarm no more durable than its original sender.
|
|
792
|
+
const tree = this._destination.tree
|
|
793
|
+
|
|
794
|
+
await this._destination.finish()
|
|
795
|
+
await this.node._adopt(this.root, this.path, tree)
|
|
796
|
+
|
|
797
|
+
this.emit('progress', { have: this.have, total: this.total, from: null })
|
|
798
|
+
this._resolve()
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
_cancelEverything () {
|
|
802
|
+
for (const peerId of [...this._inflight.keys()]) this._releaseAll(peerId)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
_fail (err) {
|
|
806
|
+
if (this.stopped) return
|
|
807
|
+
this.stopped = true
|
|
808
|
+
this._cancelEverything()
|
|
809
|
+
this.node.downloads.delete(this.root)
|
|
810
|
+
this._reject(err)
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Every bit set: what a peer holding the whole thing advertises. */
|
|
815
|
+
function fullBitfield (count) {
|
|
816
|
+
const bitfield = bao.createBitfield(count)
|
|
817
|
+
for (let i = 0; i < count; i++) bao.setBit(bitfield, i)
|
|
818
|
+
return bitfield
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function peerId (info) {
|
|
822
|
+
return info?.publicKey ? hex(info.publicKey) : String(info?.name ?? 'unknown')
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function hex (bytes) {
|
|
826
|
+
return typeof bytes === 'string' ? bytes : b4a.toString(b4a.from(bytes), 'hex')
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function join (dir, relative) {
|
|
830
|
+
return dir.replace(/[\/]+$/, '') + '/' + relative
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function parentOf (path) {
|
|
834
|
+
const at = path.lastIndexOf('/')
|
|
835
|
+
return at === -1 ? '.' : path.slice(0, at)
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function basename (path) {
|
|
839
|
+
const parts = String(path).split(/[\\/]/)
|
|
840
|
+
return parts[parts.length - 1] || null
|
|
841
|
+
}
|