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.
@@ -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
+ }