swarmwire-lib 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +117 -2
- package/docs/contract.md +145 -0
- package/docs/format.md +185 -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/holdings.js +125 -0
- package/lib/listing.js +158 -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 +975 -0
- package/lib/timers.js +21 -0
- package/lib/tree.js +326 -0
- package/lib/wire.js +95 -0
- package/package.json +95 -4
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/nodes.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every tree node a download has learned.
|
|
3
|
+
*
|
|
4
|
+
* Verifying a group teaches you more than the group: the proof that came with it names the
|
|
5
|
+
* hash of each sibling subtree along the way. Keeping those is what makes a half-finished
|
|
6
|
+
* download useful to anyone else — it can hand over a group it holds *with the proof it was
|
|
7
|
+
* given*, instead of being able to prove nothing until it is nearly complete.
|
|
8
|
+
*
|
|
9
|
+
* Nodes are keyed by the range of groups they cover, so a hash learned once is shared by every
|
|
10
|
+
* proof that needs it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { bao } from './blake3.js'
|
|
14
|
+
import b4a from 'b4a'
|
|
15
|
+
|
|
16
|
+
import { siblingRanges, splitPoint } from './tree.js'
|
|
17
|
+
|
|
18
|
+
export class NodeStore {
|
|
19
|
+
/** @param {number} count - total groups in the content */
|
|
20
|
+
constructor (count) {
|
|
21
|
+
this.count = count
|
|
22
|
+
this._nodes = new Map()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {number} start - first group covered
|
|
27
|
+
* @param {number} end - one past the last
|
|
28
|
+
* @param {Uint8Array} hash
|
|
29
|
+
* @returns {boolean} false when this node was already known
|
|
30
|
+
*/
|
|
31
|
+
set (start, end, hash) {
|
|
32
|
+
const key = rangeKey(start, end)
|
|
33
|
+
if (this._nodes.has(key)) return false
|
|
34
|
+
|
|
35
|
+
this._nodes.set(key, b4a.from(hash))
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The hash of a subtree, directly known or derived from its halves. Null if unknown. */
|
|
40
|
+
get (start, end) {
|
|
41
|
+
const known = this._nodes.get(rangeKey(start, end))
|
|
42
|
+
if (known) return known
|
|
43
|
+
if (end - start === 1) return null
|
|
44
|
+
|
|
45
|
+
const mid = start + splitPoint(end - start)
|
|
46
|
+
const left = this.get(start, mid)
|
|
47
|
+
if (left === null) return null
|
|
48
|
+
|
|
49
|
+
const right = this.get(mid, end)
|
|
50
|
+
if (right === null) return null
|
|
51
|
+
|
|
52
|
+
return bao.parentCV(left, right, false)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
has (index) {
|
|
56
|
+
return this.get(index, index + 1) !== null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A proof that group `index` belongs to the root, built from what is known.
|
|
61
|
+
*
|
|
62
|
+
* @param {number} index
|
|
63
|
+
* @returns {Uint8Array[]|null} null when some sibling has never been learned
|
|
64
|
+
*/
|
|
65
|
+
proof (index) {
|
|
66
|
+
const proof = []
|
|
67
|
+
|
|
68
|
+
for (const { start, end } of siblingRanges(this.count, index)) {
|
|
69
|
+
const hash = this.get(start, end)
|
|
70
|
+
if (hash === null) return null
|
|
71
|
+
|
|
72
|
+
proof.push(hash)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return proof
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Can this group be handed to another peer with a proof? */
|
|
79
|
+
canProve (index) {
|
|
80
|
+
return this.has(index) && this.proof(index) !== null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Record everything a verified group taught us: its own hash, and the sibling hashes from
|
|
85
|
+
* the proof that came with it.
|
|
86
|
+
*
|
|
87
|
+
* @param {number} index
|
|
88
|
+
* @param {Uint8Array} hash - the group's own hash
|
|
89
|
+
* @param {Uint8Array[]} proof
|
|
90
|
+
* @returns {{start: number, end: number, hash: Uint8Array}[]} the nodes that were new
|
|
91
|
+
*/
|
|
92
|
+
learn (index, hash, proof) {
|
|
93
|
+
const learned = []
|
|
94
|
+
|
|
95
|
+
if (this.set(index, index + 1, hash)) {
|
|
96
|
+
learned.push({ start: index, end: index + 1, hash })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ranges = siblingRanges(this.count, index)
|
|
100
|
+
for (let i = 0; i < ranges.length && i < proof.length; i++) {
|
|
101
|
+
const { start, end } = ranges[i]
|
|
102
|
+
if (this.set(start, end, proof[i])) learned.push({ start, end, hash: proof[i] })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return learned
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Leaf hashes, with holes where groups are unknown. For building a finished tree. */
|
|
109
|
+
leaves () {
|
|
110
|
+
const out = new Array(this.count).fill(null)
|
|
111
|
+
for (let i = 0; i < this.count; i++) out[i] = this.get(i, i + 1)
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function rangeKey (start, end) {
|
|
117
|
+
return start * 0x100000000 + end
|
|
118
|
+
}
|
package/lib/partfile.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `.swarmwire-part` file: what a half-finished download knows.
|
|
3
|
+
*
|
|
4
|
+
* It holds a fixed header describing the content, then one 40-byte record per tree node
|
|
5
|
+
* learned. Records are appended as groups arrive, so progress survives a crash to the last
|
|
6
|
+
* group rather than to the last checkpoint, and an append costs 40 bytes rather than
|
|
7
|
+
* rewriting a bitfield.
|
|
8
|
+
*
|
|
9
|
+
* Nodes, not just groups: a record covers a range of groups, so a leaf is the range [i, i+1)
|
|
10
|
+
* and a proof's sibling hashes are the wider ranges. Keeping those is what lets a resumed,
|
|
11
|
+
* half-finished download still prove what it holds to other peers rather than only taking.
|
|
12
|
+
*
|
|
13
|
+
* header "SWPT" | version u8 | groupLog u8 | size u64 | root 32B
|
|
14
|
+
* record start u32 | end u32 | held u8 | hash 32B
|
|
15
|
+
*
|
|
16
|
+
* `held` separates the two things a record can mean. A hash learned from somebody's proof
|
|
17
|
+
* tells you what a group *would* hash to; only a group you actually received is on disk.
|
|
18
|
+
* Confusing the two would have a resumed download claim data it never had.
|
|
19
|
+
*
|
|
20
|
+
* A trailing partial record is ignored: a crash mid-append costs that one node.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import b4a from 'b4a'
|
|
24
|
+
|
|
25
|
+
import * as fsx from './fs.js'
|
|
26
|
+
|
|
27
|
+
export const SUFFIX = '.swarmwire-part'
|
|
28
|
+
|
|
29
|
+
const MAGIC = 'SWPT'
|
|
30
|
+
const VERSION = 2
|
|
31
|
+
const HEADER_SIZE = 4 + 1 + 1 + 8 + 32
|
|
32
|
+
const RECORD_SIZE = 4 + 4 + 1 + 32
|
|
33
|
+
|
|
34
|
+
export class PartFile {
|
|
35
|
+
/**
|
|
36
|
+
* Open the part file for this download, reusing it only if it describes the same content.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} path - the destination path (the suffix is added here)
|
|
39
|
+
* @param {{root: string, size: number, groupLog: number}} content
|
|
40
|
+
* @returns {Promise<{part: PartFile, nodes: {start: number, end: number, hash: Uint8Array}[]}>}
|
|
41
|
+
*/
|
|
42
|
+
static async open (path, { root, size, groupLog }) {
|
|
43
|
+
const file = await fsx.openAppend(path + SUFFIX)
|
|
44
|
+
const part = new PartFile(file)
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const header = file.size >= HEADER_SIZE ? decodeHeader(await file.read(0, HEADER_SIZE)) : null
|
|
48
|
+
const matches = header &&
|
|
49
|
+
header.root === root &&
|
|
50
|
+
header.size === size &&
|
|
51
|
+
header.groupLog === groupLog
|
|
52
|
+
|
|
53
|
+
if (!matches) {
|
|
54
|
+
// Someone else's progress, or ours from before the content changed. Never merge it.
|
|
55
|
+
await file.truncate(0)
|
|
56
|
+
await file.append(encodeHeader({ root, size, groupLog }))
|
|
57
|
+
return { part, nodes: [] }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { part, nodes: await readRecords(file) }
|
|
61
|
+
} catch (err) {
|
|
62
|
+
await file.close()
|
|
63
|
+
throw err
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
constructor (file) {
|
|
68
|
+
this._file = file
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Append the nodes learned from one verified group.
|
|
73
|
+
*
|
|
74
|
+
* @param {{start: number, end: number, hash: Uint8Array, held?: boolean}[]} nodes
|
|
75
|
+
*/
|
|
76
|
+
async record (nodes) {
|
|
77
|
+
if (nodes.length === 0) return
|
|
78
|
+
|
|
79
|
+
const batch = b4a.alloc(RECORD_SIZE * nodes.length)
|
|
80
|
+
|
|
81
|
+
nodes.forEach(({ start, end, hash, held = false }, i) => {
|
|
82
|
+
const at = i * RECORD_SIZE
|
|
83
|
+
writeUint32(batch, at, start)
|
|
84
|
+
writeUint32(batch, at + 4, end)
|
|
85
|
+
batch[at + 8] = held ? 1 : 0
|
|
86
|
+
b4a.copy(b4a.from(hash), batch, at + 9)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
await this._file.append(batch)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
close () {
|
|
93
|
+
return this._file.close()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Delete the part file for a destination, if there is one. */
|
|
97
|
+
static remove (path) {
|
|
98
|
+
return fsx.unlink(path + SUFFIX)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function readRecords (file) {
|
|
103
|
+
const nodes = []
|
|
104
|
+
const count = Math.floor((file.size - HEADER_SIZE) / RECORD_SIZE)
|
|
105
|
+
if (count <= 0) return nodes
|
|
106
|
+
|
|
107
|
+
// One read: a few megabytes at most, and only once per resume.
|
|
108
|
+
const body = await file.read(HEADER_SIZE, count * RECORD_SIZE)
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < count; i++) {
|
|
111
|
+
const at = i * RECORD_SIZE
|
|
112
|
+
nodes.push({
|
|
113
|
+
start: readUint32(body, at),
|
|
114
|
+
end: readUint32(body, at + 4),
|
|
115
|
+
held: body[at + 8] === 1,
|
|
116
|
+
hash: b4a.from(body.subarray(at + 9, at + RECORD_SIZE))
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return nodes
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function encodeHeader ({ root, size, groupLog }) {
|
|
124
|
+
const header = b4a.alloc(HEADER_SIZE)
|
|
125
|
+
|
|
126
|
+
b4a.write(header, MAGIC, 0, 'ascii')
|
|
127
|
+
header[4] = VERSION
|
|
128
|
+
header[5] = groupLog
|
|
129
|
+
writeUint64(header, 6, size)
|
|
130
|
+
b4a.copy(b4a.from(root, 'hex'), header, 14)
|
|
131
|
+
|
|
132
|
+
return header
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function decodeHeader (bytes) {
|
|
136
|
+
if (bytes.length < HEADER_SIZE) return null
|
|
137
|
+
if (b4a.toString(bytes.subarray(0, 4), 'ascii') !== MAGIC) return null
|
|
138
|
+
if (bytes[4] !== VERSION) return null
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
groupLog: bytes[5],
|
|
142
|
+
size: readUint64(bytes, 6),
|
|
143
|
+
root: b4a.toString(bytes.subarray(14, 46), 'hex')
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function writeUint32 (buffer, offset, value) {
|
|
148
|
+
buffer[offset] = value & 0xff
|
|
149
|
+
buffer[offset + 1] = (value >>> 8) & 0xff
|
|
150
|
+
buffer[offset + 2] = (value >>> 16) & 0xff
|
|
151
|
+
buffer[offset + 3] = (value >>> 24) & 0xff
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readUint32 (buffer, offset) {
|
|
155
|
+
return buffer[offset] |
|
|
156
|
+
(buffer[offset + 1] << 8) |
|
|
157
|
+
(buffer[offset + 2] << 16) |
|
|
158
|
+
(buffer[offset + 3] << 24)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Sizes stay well inside 2^53, so splitting into two 32-bit halves is enough. */
|
|
162
|
+
function writeUint64 (buffer, offset, value) {
|
|
163
|
+
writeUint32(buffer, offset, value >>> 0)
|
|
164
|
+
writeUint32(buffer, offset + 4, Math.floor(value / 0x100000000))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readUint64 (buffer, offset) {
|
|
168
|
+
return (readUint32(buffer, offset) >>> 0) + (readUint32(buffer, offset + 4) >>> 0) * 0x100000000
|
|
169
|
+
}
|
package/lib/select.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which group to ask for next.
|
|
3
|
+
*
|
|
4
|
+
* Rarest first, as BitTorrent does it: fetch what few peers have before what everyone has.
|
|
5
|
+
* The reason is not speed on this download but survival of the swarm — a group held by one
|
|
6
|
+
* peer disappears when that peer leaves, and a swarm where every copy of some group lives on
|
|
7
|
+
* one machine is one disconnection away from nobody being able to finish.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** How many missing groups to consider at once. */
|
|
11
|
+
export const SCAN_LIMIT = 256
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Order candidate groups by how few peers hold them, keeping file order as the tie-break so
|
|
15
|
+
* a transfer still roughly streams.
|
|
16
|
+
*
|
|
17
|
+
* Only the first `limit` missing groups are considered: on a large file, sorting every
|
|
18
|
+
* outstanding group on every request would cost more than it saves, and rarity among the next
|
|
19
|
+
* few hundred is what actually decides the next request.
|
|
20
|
+
*
|
|
21
|
+
* @param {number[]} missing - group indices still wanted, in ascending order
|
|
22
|
+
* @param {(index: number) => number} holders - how many peers hold this group
|
|
23
|
+
* @param {{limit?: number}} [opts]
|
|
24
|
+
* @returns {number[]}
|
|
25
|
+
*/
|
|
26
|
+
export function rarestFirst (missing, holders, { limit = SCAN_LIMIT } = {}) {
|
|
27
|
+
const candidates = missing.length > limit ? missing.slice(0, limit) : missing.slice()
|
|
28
|
+
|
|
29
|
+
return candidates
|
|
30
|
+
.map((index) => ({ index, holders: holders(index) }))
|
|
31
|
+
.sort((a, b) => a.holders - b.holders || a.index - b.index)
|
|
32
|
+
.map((candidate) => candidate.index)
|
|
33
|
+
}
|
package/lib/source.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serving content that already exists somewhere on disk.
|
|
3
|
+
*
|
|
4
|
+
* There is no import step and no store: a file is addressed where it lies, read where it lies,
|
|
5
|
+
* and nothing is written next to it. The only state kept is its group hashes, which are derived
|
|
6
|
+
* on first use and remembered for as long as the file keeps its size and mtime.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { GroupTree, GroupTreeBuilder, groupRange, groupLength, DEFAULT_GROUP_LOG } from './tree.js'
|
|
10
|
+
import * as fsx from './fs.js'
|
|
11
|
+
|
|
12
|
+
/** path+size+mtime → group hashes. Process-local; a persistent cache can come later. */
|
|
13
|
+
const hashes = new Map()
|
|
14
|
+
|
|
15
|
+
export function clearHashCache () {
|
|
16
|
+
hashes.clear()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ContentSource {
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} path
|
|
22
|
+
* @param {{groupLog?: number}} [opts]
|
|
23
|
+
* @returns {Promise<ContentSource>}
|
|
24
|
+
*/
|
|
25
|
+
static async open (path, { groupLog = DEFAULT_GROUP_LOG } = {}) {
|
|
26
|
+
const info = await fsx.stat(path)
|
|
27
|
+
if (info === null) throw new Error(`no such file: ${path}`)
|
|
28
|
+
|
|
29
|
+
const file = await fsx.openRead(path)
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const key = `${path}\u0000${info.size}\u0000${info.mtimeMs}\u0000${groupLog}`
|
|
33
|
+
const remembered = hashes.get(key)
|
|
34
|
+
|
|
35
|
+
const tree = remembered
|
|
36
|
+
? GroupTree.fromGroupHashes(remembered, { size: info.size, groupLog })
|
|
37
|
+
: await hashFile(file, info.size, groupLog)
|
|
38
|
+
|
|
39
|
+
if (!remembered) hashes.set(key, tree.groupHashes)
|
|
40
|
+
|
|
41
|
+
return new ContentSource(path, file, tree, { cached: !!remembered })
|
|
42
|
+
} catch (err) {
|
|
43
|
+
await file.close()
|
|
44
|
+
throw err
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Take over a file whose tree is already known — a download that just finished. Re-hashing
|
|
50
|
+
* it would be pointless work: every group was hashed on the way in.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} path
|
|
53
|
+
* @param {import('./tree.js').GroupTree} tree
|
|
54
|
+
* @returns {Promise<ContentSource>}
|
|
55
|
+
*/
|
|
56
|
+
static async adopt (path, tree) {
|
|
57
|
+
return new ContentSource(path, await fsx.openRead(path), tree, { cached: true })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
constructor (path, file, tree, { cached }) {
|
|
61
|
+
this.path = path
|
|
62
|
+
this.tree = tree
|
|
63
|
+
this.cached = cached
|
|
64
|
+
|
|
65
|
+
this._file = file
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The content address: the plain BLAKE3 hash, hex encoded. */
|
|
69
|
+
get root () {
|
|
70
|
+
return this.tree.root
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get size () {
|
|
74
|
+
return this.tree.size
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get groupLog () {
|
|
78
|
+
return this.tree.groupLog
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get groupCount () {
|
|
82
|
+
return this.tree.groupCount
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read one group straight from the file.
|
|
87
|
+
*
|
|
88
|
+
* @param {number} index
|
|
89
|
+
* @returns {Promise<Uint8Array>}
|
|
90
|
+
*/
|
|
91
|
+
async readGroup (index) {
|
|
92
|
+
const { offset, length } = groupRange(index, this.size, this.groupLog)
|
|
93
|
+
return this._file.read(offset, length)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The hashes proving a group belongs to this root.
|
|
98
|
+
*
|
|
99
|
+
* @param {number} index
|
|
100
|
+
* @returns {Uint8Array[]}
|
|
101
|
+
*/
|
|
102
|
+
proof (index) {
|
|
103
|
+
return this.tree.proof(index)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
close () {
|
|
107
|
+
return this._file.close()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Content held in memory rather than on disk, with the same shape as a ContentSource.
|
|
113
|
+
*
|
|
114
|
+
* A collection's manifest is content like any other and has to be served, but writing it
|
|
115
|
+
* beside the folder it describes would mean the act of sharing a folder changed it.
|
|
116
|
+
*/
|
|
117
|
+
export class MemorySource {
|
|
118
|
+
/**
|
|
119
|
+
* @param {Uint8Array} bytes
|
|
120
|
+
* @param {{groupLog?: number}} [opts]
|
|
121
|
+
*/
|
|
122
|
+
static from (bytes, { groupLog = DEFAULT_GROUP_LOG } = {}) {
|
|
123
|
+
return new MemorySource(bytes, GroupTree.fromContent(bytes, { groupLog }))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
constructor (bytes, tree) {
|
|
127
|
+
this.path = null
|
|
128
|
+
this.tree = tree
|
|
129
|
+
this.cached = true
|
|
130
|
+
|
|
131
|
+
this._bytes = bytes
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get root () {
|
|
135
|
+
return this.tree.root
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get size () {
|
|
139
|
+
return this.tree.size
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get groupLog () {
|
|
143
|
+
return this.tree.groupLog
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get groupCount () {
|
|
147
|
+
return this.tree.groupCount
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async readGroup (index) {
|
|
151
|
+
const { offset, length } = groupRange(index, this.size, this.groupLog)
|
|
152
|
+
return this._bytes.subarray(offset, offset + length)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
proof (index) {
|
|
156
|
+
return this.tree.proof(index)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async close () {}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function hashFile (file, size, groupLog) {
|
|
163
|
+
const builder = new GroupTreeBuilder({ groupLog })
|
|
164
|
+
const len = groupLength(groupLog)
|
|
165
|
+
|
|
166
|
+
for (let offset = 0; offset < size; offset += len) {
|
|
167
|
+
builder.push(await file.read(offset, Math.min(len, size - offset)))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return builder.finalize()
|
|
171
|
+
}
|