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/blake3.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The hashing primitives, imported from one place.
3
+ *
4
+ * Everything that needs BLAKE3 or bao goes through here rather than reaching for
5
+ * `blake3-bao` directly, so the CommonJS detour that keeps us running on Bare — see
6
+ * `blake3.cjs` — stays in a single file.
7
+ */
8
+
9
+ import bridge from './blake3.cjs'
10
+
11
+ export const blake3 = bridge.blake3
12
+ export const bao = bridge.bao
@@ -0,0 +1,133 @@
1
+ /**
2
+ * A collection: a folder reduced to a list of content addresses.
3
+ *
4
+ * The manifest is itself content, so it has an address like anything else — sending a folder
5
+ * is sending one link, and the receiver fetches the manifest first and then the files it
6
+ * names. Nothing about it is mutable: change a file and you have a different manifest with a
7
+ * different address. Folders that change belong to SwarmFS, one layer up.
8
+ *
9
+ * The encoding is canonical, because the manifest's bytes are its identity: the same folder
10
+ * must produce the same address on every machine. Entries are sorted by path, there is no
11
+ * incidental whitespace, and paths are normalised before they are hashed.
12
+ *
13
+ * {"swarmwire":"collection/v0","files":[{"path":"a.txt","root":"<hex>","size":12}]}
14
+ */
15
+
16
+ import b4a from 'b4a'
17
+
18
+ export const MANIFEST_TYPE = 'collection/v0'
19
+
20
+ /** Enough for a folder of a million files; past that, something has gone wrong. */
21
+ export const MAX_MANIFEST_BYTES = 64 * 1024 * 1024
22
+
23
+ /**
24
+ * @param {{path: string, root: string, size: number}[]} files
25
+ * @returns {Uint8Array} the manifest's bytes, which are what gets addressed and served
26
+ */
27
+ export function encodeManifest (files) {
28
+ const entries = files
29
+ .map((file) => ({
30
+ path: normalisePath(file.path),
31
+ root: file.root,
32
+ size: file.size
33
+ }))
34
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
35
+
36
+ for (const entry of entries) assertEntry(entry)
37
+ assertNoDuplicates(entries)
38
+
39
+ return b4a.from(JSON.stringify({ swarmwire: MANIFEST_TYPE, files: entries }), 'utf8')
40
+ }
41
+
42
+ /**
43
+ * Read a manifest, refusing anything that is not one.
44
+ *
45
+ * Everything here arrived from a stranger, so nothing is taken on trust: a path that climbs
46
+ * out of the destination directory is the obvious attack, and the content addresses are
47
+ * meaningless if the paths they are written to are not.
48
+ *
49
+ * @param {Uint8Array} bytes
50
+ * @returns {{path: string, root: string, size: number}[]}
51
+ */
52
+ export function decodeManifest (bytes) {
53
+ if (bytes.length > MAX_MANIFEST_BYTES) throw new Error('manifest is implausibly large')
54
+
55
+ let parsed
56
+ try {
57
+ parsed = JSON.parse(b4a.toString(bytes, 'utf8'))
58
+ } catch {
59
+ throw new Error('manifest is not valid JSON')
60
+ }
61
+
62
+ if (parsed?.swarmwire !== MANIFEST_TYPE) throw new Error('not a swarmwire collection')
63
+ if (!Array.isArray(parsed.files)) throw new Error('manifest has no file list')
64
+
65
+ // Paths are checked as they arrived, never normalised first. Quietly turning
66
+ // "/etc/passwd" into "etc/passwd" would write somewhere the sender did not name, and a
67
+ // manifest whose paths are not already canonical is not one we made.
68
+ const entries = parsed.files.map((file) => ({
69
+ path: file?.path,
70
+ root: file?.root,
71
+ size: file?.size
72
+ }))
73
+
74
+ for (const entry of entries) assertEntry(entry)
75
+ assertNoDuplicates(entries)
76
+
77
+ return entries
78
+ }
79
+
80
+ /** The total size of everything a manifest names. */
81
+ export function totalSize (files) {
82
+ return files.reduce((sum, file) => sum + file.size, 0)
83
+ }
84
+
85
+ /**
86
+ * Normalise a path for the manifest: forward slashes, no leading slash, no `.` segments.
87
+ *
88
+ * This does not decide whether a path is safe — `assertEntry` does that — it only makes the
89
+ * same folder produce the same bytes whichever platform packed it.
90
+ */
91
+ export function normalisePath (path) {
92
+ return String(path)
93
+ .replace(/\\/g, '/')
94
+ .split('/')
95
+ .filter((segment) => segment !== '' && segment !== '.')
96
+ .join('/')
97
+ }
98
+
99
+ /** Is this a path we are willing to write to, under a destination directory? */
100
+ export function isSafePath (path) {
101
+ if (typeof path !== 'string' || path === '') return false
102
+ if (path.length > 4096) return false
103
+ if (path !== normalisePath(path)) return false
104
+
105
+ const segments = path.split('/')
106
+ if (segments.some((segment) => segment === '..')) return false
107
+
108
+ // Windows refuses these as filenames, and a path that works on one machine and not another
109
+ // is a collection that cannot be received everywhere.
110
+ if (/[<>:"|?*\u0000-\u001f]/.test(path)) return false
111
+ if (/^[a-zA-Z]:/.test(path)) return false
112
+
113
+ return true
114
+ }
115
+
116
+ function assertEntry (entry) {
117
+ if (!isSafePath(entry.path)) throw new Error(`unsafe path in manifest: ${JSON.stringify(entry.path)}`)
118
+ if (typeof entry.root !== 'string' || !/^[0-9a-f]{64}$/.test(entry.root)) {
119
+ throw new Error(`bad root in manifest for ${entry.path}`)
120
+ }
121
+ if (!Number.isInteger(entry.size) || entry.size < 0) {
122
+ throw new Error(`bad size in manifest for ${entry.path}`)
123
+ }
124
+ }
125
+
126
+ function assertNoDuplicates (entries) {
127
+ const seen = new Set()
128
+
129
+ for (const entry of entries) {
130
+ if (seen.has(entry.path)) throw new Error(`duplicate path in manifest: ${entry.path}`)
131
+ seen.add(entry.path)
132
+ }
133
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Where a download lands — and, while it is still going, something other peers can fetch from.
3
+ *
4
+ * Three rules, and everything else follows:
5
+ *
6
+ * 1. A group is verified against the root *before* it is written. A peer that sends anything
7
+ * else is refused and gains nothing: not a byte in the file, not a bit in the bitfield.
8
+ * 2. Progress survives a crash. Every accepted group appends a 36-byte record to the part
9
+ * file beside the destination, so resuming costs nothing and rehashes nothing.
10
+ * 3. What is held can be proved. Those records carry each group's hash, which is what lets a
11
+ * half-finished download serve its groups to somebody else instead of only taking.
12
+ */
13
+
14
+ import { bao } from './blake3.js'
15
+ import b4a from 'b4a'
16
+
17
+ import { GroupTree, groupHash, verifyGroup, groupCount, groupRange, DEFAULT_GROUP_LOG } from './tree.js'
18
+ import { NodeStore } from './nodes.js'
19
+ import { PartFile, SUFFIX } from './partfile.js'
20
+ import * as fsx from './fs.js'
21
+
22
+ export const SIDECAR_SUFFIX = SUFFIX
23
+
24
+ export class Destination {
25
+ /**
26
+ * @param {string} path - where the content is being written
27
+ * @param {{root: string, size: number, groupLog?: number}} opts
28
+ * @returns {Promise<Destination>}
29
+ */
30
+ static async open (path, { root, size, groupLog = DEFAULT_GROUP_LOG }) {
31
+ const count = groupCount(size, groupLog)
32
+ const { part, nodes } = await PartFile.open(path, { root, size, groupLog })
33
+
34
+ try {
35
+ const file = await fsx.openWrite(path, { size })
36
+ return new Destination(path, file, part, { root, size, groupLog, count, nodes })
37
+ } catch (err) {
38
+ await part.close()
39
+ throw err
40
+ }
41
+ }
42
+
43
+ constructor (path, file, part, { root, size, groupLog, count, nodes }) {
44
+ this.path = path
45
+ this.root = root
46
+ this.size = size
47
+ this.groupLog = groupLog
48
+ this.groupCount = count
49
+
50
+ this._file = file
51
+ this._part = part
52
+ this._writing = new Set()
53
+ this._pending = new Set() // puts still running, which must land before anything closes
54
+ this._closed = false
55
+
56
+ this._bitfield = bao.createBitfield(count)
57
+ this._nodes = new NodeStore(count)
58
+ this._have = 0
59
+
60
+ for (const { start, end, hash, held } of nodes) {
61
+ if (start >= count || end > count || end <= start) continue
62
+
63
+ this._nodes.set(start, end, hash)
64
+
65
+ // Only a group we received is on disk. Knowing a hash from somebody else's proof says
66
+ // nothing about whether we have the bytes.
67
+ if (held && !bao.getBit(this._bitfield, start)) {
68
+ bao.setBit(this._bitfield, start)
69
+ this._have++
70
+ }
71
+ }
72
+ }
73
+
74
+ /** Groups held so far. */
75
+ get have () {
76
+ return this._have
77
+ }
78
+
79
+ get complete () {
80
+ return this._have === this.groupCount
81
+ }
82
+
83
+ has (index) {
84
+ return bao.getBit(this._bitfield, index)
85
+ }
86
+
87
+ /** @returns {number[]} indices still wanted */
88
+ missing () {
89
+ const out = []
90
+ for (let i = 0; i < this.groupCount; i++) {
91
+ if (!this.has(i)) out.push(i)
92
+ }
93
+ return out
94
+ }
95
+
96
+ /** The bitfield as advertised to peers. */
97
+ bitfield () {
98
+ return this._bitfield
99
+ }
100
+
101
+ /**
102
+ * Offer a group. Returns false if it does not verify — in which case nothing is written and
103
+ * nothing is recorded, so a lying peer makes no progress by trying again.
104
+ *
105
+ * @param {number} index
106
+ * @param {Uint8Array} data
107
+ * @param {Uint8Array[]} proof
108
+ * @returns {Promise<boolean>}
109
+ */
110
+ async put (index, data, proof) {
111
+ if (this._closed) throw new Error('destination is closed')
112
+
113
+ const op = this._put(index, data, proof)
114
+ this._pending.add(op)
115
+
116
+ try {
117
+ return await op
118
+ } finally {
119
+ this._pending.delete(op)
120
+ }
121
+ }
122
+
123
+ async _put (index, data, proof) {
124
+ if (!Number.isInteger(index) || index < 0 || index >= this.groupCount) return false
125
+ if (this.has(index)) return true
126
+
127
+ const ok = verifyGroup({
128
+ root: this.root,
129
+ size: this.size,
130
+ index,
131
+ data,
132
+ proof,
133
+ groupLog: this.groupLog
134
+ })
135
+ if (!ok) return false
136
+
137
+ // Two peers can answer the same request at once. Both copies are valid — they verified —
138
+ // but the group must be counted once, or `have` overshoots and the download calls itself
139
+ // finished with a hole in it.
140
+ if (this._writing.has(index)) return true
141
+ this._writing.add(index)
142
+
143
+ try {
144
+ const { offset } = groupRange(index, this.size, this.groupLog)
145
+ await this._file.write(offset, data)
146
+ } finally {
147
+ this._writing.delete(index)
148
+ }
149
+
150
+ if (this.has(index)) return true
151
+
152
+ const hash = groupHash(data, index, {
153
+ groupLog: this.groupLog,
154
+ only: this.groupCount === 1
155
+ })
156
+
157
+ // Keep the proof as well as the group: those sibling hashes are exactly what we will need
158
+ // to prove this group to the next peer that asks for it.
159
+ const learned = this._nodes.learn(index, hash, proof)
160
+
161
+ bao.setBit(this._bitfield, index)
162
+ this._have++
163
+
164
+ // The group itself is marked as held; the proof hashes are knowledge, not data.
165
+ const records = learned.map((node) => ({ ...node, held: node.start === index && node.end === index + 1 }))
166
+ if (!records.some((node) => node.held)) {
167
+ records.push({ start: index, end: index + 1, hash, held: true })
168
+ }
169
+
170
+ await this._part.record(records)
171
+
172
+ return true
173
+ }
174
+
175
+ // -- serving what we already have ------------------------------------------
176
+
177
+ /** Wait for puts that started before the close. */
178
+ async _drain () {
179
+ while (this._pending.size > 0) {
180
+ await Promise.allSettled([...this._pending])
181
+ }
182
+ }
183
+
184
+ /** The finished tree. Only meaningful once every group is held. */
185
+ get tree () {
186
+ return new GroupTree(this._nodes.leaves(), { size: this.size, groupLog: this.groupLog })
187
+ }
188
+
189
+ /** Can this group be served with a proof right now? */
190
+ canProve (index) {
191
+ return this._nodes.canProve(index)
192
+ }
193
+
194
+ /** @returns {Uint8Array[]|null} null when some hash the proof needs was never learned */
195
+ proof (index) {
196
+ return this.has(index) ? this._nodes.proof(index) : null
197
+ }
198
+
199
+ /** Read a group back out of the destination file. */
200
+ async readGroup (index) {
201
+ if (!this.has(index)) return null
202
+
203
+ const { offset, length } = groupRange(index, this.size, this.groupLog)
204
+ return this._file.read(offset, length)
205
+ }
206
+
207
+ // -- lifecycle -------------------------------------------------------------
208
+
209
+ /** Complete the download: flush, drop the resume state, close. */
210
+ async finish () {
211
+ if (!this.complete) throw new Error('download is not complete')
212
+
213
+ // Closed before awaiting anything, so a group arriving now is refused cleanly rather
214
+ // than writing into a handle that is about to shut.
215
+ this._closed = true
216
+ await this._drain()
217
+
218
+ await this._file.sync()
219
+ await this._file.close()
220
+ await this._part.close()
221
+ await PartFile.remove(this.path)
222
+ }
223
+
224
+ /** Stop for now, keeping the resume state. */
225
+ async close () {
226
+ if (this._closed) return
227
+ this._closed = true
228
+
229
+ await this._drain()
230
+
231
+ try {
232
+ await this._part.close()
233
+ } finally {
234
+ await this._file.close() // never leak the handle, whatever else fails
235
+ }
236
+ }
237
+ }
package/lib/fs.js ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The only module that touches a filesystem.
3
+ *
4
+ * Everything else goes through here so that Bare can supply `bare-fs` without the rest of the
5
+ * library knowing. Keep this thin: open, read at an offset, write at an offset, stat, unlink.
6
+ */
7
+
8
+ import fs from 'fs'
9
+ import b4a from 'b4a'
10
+
11
+ /**
12
+ * @param {string} path
13
+ * @returns {Promise<{size: number, mtimeMs: number, directory: boolean}|null>} null if it
14
+ * does not exist
15
+ */
16
+ export async function stat (path) {
17
+ try {
18
+ const st = await fs.promises.stat(path)
19
+ return { size: st.size, mtimeMs: Math.floor(st.mtimeMs), directory: st.isDirectory() }
20
+ } catch (err) {
21
+ if (err.code === 'ENOENT') return null
22
+ throw err
23
+ }
24
+ }
25
+
26
+ /** Open a file for reading byte ranges out of it, in place. */
27
+ export async function openRead (path) {
28
+ const handle = await fs.promises.open(path, 'r')
29
+
30
+ return {
31
+ async read (offset, length) {
32
+ const buffer = b4a.alloc(length)
33
+ if (length === 0) return buffer
34
+
35
+ const { bytesRead } = await handle.read(buffer, 0, length, offset)
36
+ return bytesRead === length ? buffer : buffer.subarray(0, bytesRead)
37
+ },
38
+
39
+ close () {
40
+ return handle.close()
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Open a file for writing byte ranges into it, creating it and giving it its final size up
47
+ * front so ranges can land in any order.
48
+ */
49
+ export async function openWrite (path, { size }) {
50
+ const handle = await fs.promises.open(path, (await stat(path)) ? 'r+' : 'w+')
51
+ await handle.truncate(size)
52
+
53
+ return {
54
+ async write (offset, data) {
55
+ if (data.length === 0) return
56
+ await handle.write(data, 0, data.length, offset)
57
+ },
58
+
59
+ /** Reading back matters: a partial download serves the groups it already holds. */
60
+ async read (offset, length) {
61
+ const buffer = b4a.alloc(length)
62
+ if (length === 0) return buffer
63
+
64
+ const { bytesRead } = await handle.read(buffer, 0, length, offset)
65
+ return bytesRead === length ? buffer : buffer.subarray(0, bytesRead)
66
+ },
67
+
68
+ sync () {
69
+ return handle.sync()
70
+ },
71
+
72
+ close () {
73
+ return handle.close()
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Open a file for appending, readable too. Used for the append-only part file, where every
80
+ * write goes to the end and reads walk back over what was written.
81
+ */
82
+ export async function openAppend (path) {
83
+ // Not 'a+': Windows refuses to truncate a handle opened in append mode, and we need to
84
+ // truncate when the file turns out to describe different content. Track the end ourselves.
85
+ const handle = await fs.promises.open(path, (await stat(path)) ? 'r+' : 'w+')
86
+ const info = await handle.stat()
87
+
88
+ // Appends are serialised. Groups are verified concurrently, and two appends that each read
89
+ // the end of the file before either wrote would land on top of each other.
90
+ let tail = Promise.resolve()
91
+
92
+ return {
93
+ size: info.size,
94
+
95
+ async read (offset, length) {
96
+ const buffer = b4a.alloc(length)
97
+ if (length === 0) return buffer
98
+
99
+ const { bytesRead } = await handle.read(buffer, 0, length, offset)
100
+ return bytesRead === length ? buffer : buffer.subarray(0, bytesRead)
101
+ },
102
+
103
+ append (data) {
104
+ const write = tail.then(async () => {
105
+ await handle.write(data, 0, data.length, this.size)
106
+ this.size += data.length
107
+ })
108
+
109
+ tail = write.catch(() => {}) // a failed append must not wedge the queue
110
+ return write
111
+ },
112
+
113
+ async truncate (size) {
114
+ await handle.truncate(size)
115
+ this.size = size
116
+ },
117
+
118
+ close () {
119
+ return handle.close()
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Every file under a directory, as paths relative to it, in a stable order.
126
+ *
127
+ * Symbolic links are skipped rather than followed: a link pointing outside the folder would
128
+ * quietly put somebody else's file into a collection.
129
+ *
130
+ * @param {string} dir
131
+ * @returns {Promise<string[]>}
132
+ */
133
+ export async function list (dir) {
134
+ const found = []
135
+
136
+ async function walk (current, prefix) {
137
+ const entries = await fs.promises.readdir(current, { withFileTypes: true })
138
+
139
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
140
+ const relative = prefix ? prefix + '/' + entry.name : entry.name
141
+
142
+ if (entry.isDirectory()) await walk(current + '/' + entry.name, relative)
143
+ else if (entry.isFile()) found.push(relative)
144
+ }
145
+ }
146
+
147
+ await walk(dir, '')
148
+ return found
149
+ }
150
+
151
+ /** Create a directory and any parents it needs. */
152
+ export function mkdir (dir) {
153
+ return fs.promises.mkdir(dir, { recursive: true })
154
+ }
155
+
156
+ /** Small whole-file helpers, for sidecars only. */
157
+ export async function readFile (path) {
158
+ try {
159
+ return await fs.promises.readFile(path)
160
+ } catch (err) {
161
+ if (err.code === 'ENOENT') return null
162
+ throw err
163
+ }
164
+ }
165
+
166
+ export function writeFile (path, data) {
167
+ return fs.promises.writeFile(path, data)
168
+ }
169
+
170
+ export async function unlink (path) {
171
+ try {
172
+ await fs.promises.unlink(path)
173
+ } catch (err) {
174
+ if (err.code !== 'ENOENT') throw err
175
+ }
176
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * What connected peers say they hold, and the counts that follow from it.
3
+ *
4
+ * A node only hears about a peer's holdings if it asked: `want` is answered with `have`, and
5
+ * the peer then pushes `holds` as it gains groups. A node that only serves never asks, so it
6
+ * keeps a watch set — the roots it serves — and sends `want` for those to every peer. That is
7
+ * the whole mechanism; there is no message for it.
8
+ *
9
+ * Counts are of direct connections. Nothing here knows about the rest of the swarm.
10
+ */
11
+
12
+ import b4a from 'b4a'
13
+
14
+ import { bao } from './blake3.js'
15
+ import { DEFAULT_GROUP_LOG, groupCount } from './tree.js'
16
+
17
+ export class Holdings {
18
+ constructor ({ groupLog = DEFAULT_GROUP_LOG } = {}) {
19
+ this.groupLog = groupLog
20
+ this.peers = new Map() // peer id → Map(root hex → { size, total, bits, count })
21
+ this.watched = new Map() // root hex → size: roots we ask every peer about
22
+ }
23
+
24
+ /** A peer described what it holds (`have`). Replaces whatever we knew. */
25
+ set (peerId, root, { size, groupLog, bitfield }) {
26
+ if (groupLog !== this.groupLog) return // we cannot read that granularity: unknown, not guessed
27
+
28
+ const total = groupCount(size, this.groupLog)
29
+ const bits = bao.createBitfield(total)
30
+ bits.set(b4a.from(bitfield ?? []).subarray(0, bits.length))
31
+
32
+ this._of(peerId).set(root, { size, total, bits, count: bao.countSetBits(bits, total) })
33
+ }
34
+
35
+ /**
36
+ * A peer gained one group (`holds`). Without a `have` to build on, a known `size` stands in:
37
+ * a peer that first said "unavailable" and then began downloading only ever sends this.
38
+ */
39
+ grow (peerId, root, index, size) {
40
+ let held = this.get(peerId, root)
41
+
42
+ if (!held) {
43
+ if (size === undefined) return
44
+ const total = groupCount(size, this.groupLog)
45
+ held = { size, total, bits: bao.createBitfield(total), count: 0 }
46
+ this._of(peerId).set(root, held)
47
+ }
48
+
49
+ if (index < 0 || index >= held.total || bao.getBit(held.bits, index)) return
50
+
51
+ bao.setBit(held.bits, index)
52
+ held.count++
53
+ }
54
+
55
+ /** The peer says it has none of this (`unavailable`). */
56
+ remove (peerId, root) {
57
+ this.peers.get(peerId)?.delete(root)
58
+ }
59
+
60
+ dropPeer (peerId) {
61
+ this.peers.delete(peerId)
62
+ }
63
+
64
+ get (peerId, root) {
65
+ return this.peers.get(peerId)?.get(root)
66
+ }
67
+
68
+ /**
69
+ * @param {Iterable<string>} peerIds - the connected peers to count among
70
+ * @param {string[]} roots - one root for a file; the manifest and every file for a folder
71
+ * @returns {{peers: number, seeders: number, leechers: number}} a seeder holds every group
72
+ * of every root, a leecher holds some but not all, anyone else is in neither
73
+ */
74
+ count (peerIds, roots) {
75
+ const counts = { peers: 0, seeders: 0, leechers: 0 }
76
+
77
+ for (const id of peerIds) {
78
+ counts.peers++
79
+ if (roots.length === 0) continue
80
+
81
+ let complete = true
82
+ let any = false
83
+
84
+ for (const root of roots) {
85
+ const held = this.get(id, root)
86
+ if (!held || held.count < held.total) complete = false
87
+ if (held && held.count > 0) any = true
88
+ }
89
+
90
+ if (complete) counts.seeders++
91
+ else if (any) counts.leechers++
92
+ }
93
+
94
+ return counts
95
+ }
96
+
97
+ // -- the watch set ---------------------------------------------------------
98
+
99
+ /** Ask every peer about a root we hold or follow, now and whenever a new peer connects. */
100
+ watch (root, size, peers, skip) {
101
+ this.watched.set(root, size)
102
+ for (const peer of peers) this._want(peer, root, size, skip)
103
+ }
104
+
105
+ unwatch (root) {
106
+ this.watched.delete(root)
107
+ }
108
+
109
+ /** A peer just connected: tell it what we are watching. */
110
+ wantAll (peer, skip) {
111
+ for (const [root, size] of this.watched) this._want(peer, root, size, skip)
112
+ }
113
+
114
+ _want (peer, root, size, skip) {
115
+ // shortcut: wants for every file of a served folder; watch lazily if folders grow past ~10k files
116
+ if (skip(root)) return // a download already asked this peer
117
+ peer.send.want({ root: b4a.from(root, 'hex'), size })
118
+ }
119
+
120
+ _of (peerId) {
121
+ let held = this.peers.get(peerId)
122
+ if (!held) this.peers.set(peerId, held = new Map())
123
+ return held
124
+ }
125
+ }