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