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/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
|
+
}
|