@leofcoin/peernet 1.2.29 → 1.2.30
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/README.md +44 -1
- package/exports/browser/client-86Tnax66.js +2224 -0
- package/exports/browser/client-BFxJts6j.js +2224 -0
- package/exports/browser/client-BzvWzzLv.js +2224 -0
- package/exports/browser/client-C9GBz2Tf.js +2224 -0
- package/exports/browser/client-CQzuu6At.js +2224 -0
- package/exports/browser/client-CVkFejdx.js +2224 -0
- package/exports/browser/client-Cj_UUO8W.js +2224 -0
- package/exports/browser/client-CrGaeBjj.js +2224 -0
- package/exports/browser/client-ERocgKET.js +2224 -0
- package/exports/browser/client-a8rws3cu.js +2224 -0
- package/exports/browser/messages-BFCmg6Vb.js +212 -0
- package/exports/browser/messages-BHIC2mQu.js +212 -0
- package/exports/browser/messages-CimzJ4wr.js +210 -0
- package/exports/browser/messages-Cjoa_LGO.js +210 -0
- package/exports/browser/messages-D7JxkkDW.js +212 -0
- package/exports/browser/messages-DVHcNvs_.js +212 -0
- package/exports/browser/messages-DqL_g2qw.js +210 -0
- package/exports/browser/messages-XgZZUMma.js +212 -0
- package/exports/browser/messages-kfFyctPL.js +210 -0
- package/exports/browser/messages-psROnfRi.js +212 -0
- package/exports/browser/peernet-B9VL4Xwe.js +9522 -0
- package/exports/browser/peernet-C-HNdrcd.js +9528 -0
- package/exports/browser/peernet-C4rOvyyU.js +9661 -0
- package/exports/browser/peernet-CbI0-jFn.js +9651 -0
- package/exports/browser/peernet-CtHScDyh.js +9524 -0
- package/exports/browser/peernet-DfuxUK3u.js +9525 -0
- package/exports/browser/peernet-DiEcNDLD.js +9655 -0
- package/exports/browser/peernet-DtuRXOzP.js +9653 -0
- package/exports/browser/peernet-rcD5XL1y.js +9590 -0
- package/exports/browser/peernet-rw8HJGOz.js +9597 -0
- package/exports/browser/peernet.js +1 -1
- package/exports/messages-0uXHmQH7.js +209 -0
- package/exports/messages-DaUtFfBh.js +207 -0
- package/exports/peernet.js +358 -33
- package/exports/types/file-transfer.d.ts +34 -0
- package/exports/types/peernet.d.ts +20 -4
- package/exports/types/proto/file.proto.d.ts +5 -0
- package/package.json +2 -2
- package/rollup.config.js +2 -6
- package/src/file-transfer.ts +194 -0
- package/src/peernet.ts +170 -28
- package/src/proto/file.proto.js +6 -1
- package/test/file-transfer.test.js +81 -0
- package/test/peernet.test.js +31 -2
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
export type FileTransferState = 'idle' | 'running' | 'paused' | 'completed' | 'cancelled' | 'failed'
|
|
2
|
+
|
|
3
|
+
export type FileTransferProgress = {
|
|
4
|
+
hash: string
|
|
5
|
+
state: FileTransferState
|
|
6
|
+
completedChunks: number
|
|
7
|
+
totalChunks: number
|
|
8
|
+
transferredBytes: number
|
|
9
|
+
totalBytes: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type ProgressListener = (progress: FileTransferProgress) => void
|
|
13
|
+
|
|
14
|
+
/** A resumable download. Completed chunks remain cached on the transfer instance. */
|
|
15
|
+
export default class FileTransfer {
|
|
16
|
+
readonly hash: string
|
|
17
|
+
state: FileTransferState = 'idle'
|
|
18
|
+
result: Promise<Uint8Array>
|
|
19
|
+
error?: unknown
|
|
20
|
+
|
|
21
|
+
#fetch: (hash: string, index?: number) => Promise<Uint8Array | undefined>
|
|
22
|
+
#decode: (data: Uint8Array) => Promise<any>
|
|
23
|
+
#verify: (data: Uint8Array, hash: string) => Promise<boolean>
|
|
24
|
+
#pin?: (hash: string, data: Uint8Array) => Promise<any>
|
|
25
|
+
#verifyManifest: boolean
|
|
26
|
+
#concurrency: number
|
|
27
|
+
#stopped = false
|
|
28
|
+
#listeners = new Set<ProgressListener>()
|
|
29
|
+
#chunks = new Map<number, Uint8Array>()
|
|
30
|
+
#resumeWaiters: Array<() => void> = []
|
|
31
|
+
#resolve!: (value: Uint8Array) => void
|
|
32
|
+
#reject!: (reason?: unknown) => void
|
|
33
|
+
#totalChunks = 0
|
|
34
|
+
#totalBytes = 0
|
|
35
|
+
#started = false
|
|
36
|
+
|
|
37
|
+
constructor(options: {
|
|
38
|
+
hash: string
|
|
39
|
+
fetch: (hash: string, index?: number) => Promise<Uint8Array | undefined>
|
|
40
|
+
decode: (data: Uint8Array) => Promise<any>
|
|
41
|
+
verify: (data: Uint8Array, hash: string) => Promise<boolean>
|
|
42
|
+
pin?: (hash: string, data: Uint8Array) => Promise<any>
|
|
43
|
+
verifyManifest?: boolean
|
|
44
|
+
concurrency?: number
|
|
45
|
+
}) {
|
|
46
|
+
this.hash = options.hash
|
|
47
|
+
this.#fetch = options.fetch
|
|
48
|
+
this.#decode = options.decode
|
|
49
|
+
this.#verify = options.verify
|
|
50
|
+
this.#pin = options.pin
|
|
51
|
+
this.#verifyManifest = options.verifyManifest !== false
|
|
52
|
+
this.#concurrency = options.concurrency ?? 4
|
|
53
|
+
if (!Number.isSafeInteger(this.#concurrency) || this.#concurrency <= 0)
|
|
54
|
+
throw new TypeError('concurrency must be a positive integer')
|
|
55
|
+
this.result = new Promise((resolve, reject) => {
|
|
56
|
+
this.#resolve = resolve
|
|
57
|
+
this.#reject = reject
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get progress(): FileTransferProgress {
|
|
62
|
+
let transferredBytes = 0
|
|
63
|
+
for (const chunk of this.#chunks.values()) transferredBytes += chunk.length
|
|
64
|
+
return {
|
|
65
|
+
hash: this.hash,
|
|
66
|
+
state: this.state,
|
|
67
|
+
completedChunks: this.#chunks.size,
|
|
68
|
+
totalChunks: this.#totalChunks,
|
|
69
|
+
transferredBytes,
|
|
70
|
+
totalBytes: this.#totalBytes
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
onProgress(listener: ProgressListener): () => void {
|
|
75
|
+
this.#listeners.add(listener)
|
|
76
|
+
listener(this.progress)
|
|
77
|
+
return () => this.#listeners.delete(listener)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
start(): this {
|
|
81
|
+
if (!this.#started) {
|
|
82
|
+
this.#started = true
|
|
83
|
+
this.state = 'running'
|
|
84
|
+
this.#emit()
|
|
85
|
+
void this.#run()
|
|
86
|
+
}
|
|
87
|
+
return this
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
pause(): void {
|
|
91
|
+
if (this.state === 'running') {
|
|
92
|
+
this.state = 'paused'
|
|
93
|
+
this.#emit()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
resume(): void {
|
|
98
|
+
if (this.state !== 'paused') return
|
|
99
|
+
this.state = 'running'
|
|
100
|
+
const waiters = this.#resumeWaiters.splice(0)
|
|
101
|
+
for (const resolve of waiters) resolve()
|
|
102
|
+
this.#emit()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
cancel(): void {
|
|
106
|
+
if (this.state === 'completed' || this.state === 'failed' || this.state === 'cancelled') return
|
|
107
|
+
this.state = 'cancelled'
|
|
108
|
+
this.#stopped = true
|
|
109
|
+
const error = new Error(`Transfer ${this.hash} was cancelled`)
|
|
110
|
+
this.error = error
|
|
111
|
+
const waiters = this.#resumeWaiters.splice(0)
|
|
112
|
+
for (const resolve of waiters) resolve()
|
|
113
|
+
this.#reject(error)
|
|
114
|
+
this.#emit()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async #waitUntilRunning(): Promise<void> {
|
|
118
|
+
if (this.state === 'paused') await new Promise<void>((resolve) => this.#resumeWaiters.push(resolve))
|
|
119
|
+
if (this.state === 'cancelled') throw this.error
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async #run(): Promise<void> {
|
|
123
|
+
try {
|
|
124
|
+
const manifestData = await this.#fetch(this.hash)
|
|
125
|
+
if (!manifestData) throw new Error(`Unable to download ${this.hash}`)
|
|
126
|
+
if (this.#verifyManifest && !(await this.#verify(manifestData, this.hash)))
|
|
127
|
+
throw new Error(`Manifest hash mismatch for ${this.hash}`)
|
|
128
|
+
const manifest = await this.#decode(manifestData)
|
|
129
|
+
if (this.#pin) await this.#pin(this.hash, manifestData)
|
|
130
|
+
|
|
131
|
+
if (!manifest.chunked) {
|
|
132
|
+
if (manifest.links?.length) throw new Error(`${this.hash} is a directory`)
|
|
133
|
+
const content = manifest.content || new Uint8Array()
|
|
134
|
+
this.#totalChunks = 1
|
|
135
|
+
this.#totalBytes = content.length
|
|
136
|
+
this.#chunks.set(0, content)
|
|
137
|
+
this.state = 'completed'
|
|
138
|
+
this.#resolve(content)
|
|
139
|
+
this.#emit()
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const links = manifest.links || []
|
|
144
|
+
this.#totalChunks = links.length
|
|
145
|
+
this.#totalBytes =
|
|
146
|
+
Number(manifest.size) || links.reduce((sum: number, link: { size?: number }) => sum + (Number(link.size) || 0), 0)
|
|
147
|
+
this.#emit()
|
|
148
|
+
|
|
149
|
+
let nextIndex = 0
|
|
150
|
+
const worker = async () => {
|
|
151
|
+
while (!this.#stopped) {
|
|
152
|
+
await this.#waitUntilRunning()
|
|
153
|
+
const index = nextIndex++
|
|
154
|
+
if (index >= links.length) return
|
|
155
|
+
if (this.#chunks.has(index)) continue
|
|
156
|
+
const encoded = await this.#fetch(links[index].hash, index)
|
|
157
|
+
if (this.#stopped) return
|
|
158
|
+
if (!encoded) throw new Error(`Missing chunk ${index + 1}/${links.length}`)
|
|
159
|
+
if (!(await this.#verify(encoded, links[index].hash))) throw new Error(`Hash mismatch for chunk ${index + 1}`)
|
|
160
|
+
const chunkNode = await this.#decode(encoded)
|
|
161
|
+
const content = chunkNode.content || new Uint8Array()
|
|
162
|
+
this.#chunks.set(index, content)
|
|
163
|
+
if (this.#pin) await this.#pin(links[index].hash, encoded)
|
|
164
|
+
this.#emit()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
await Promise.all(Array.from({ length: Math.min(this.#concurrency, links.length) }, () => worker()))
|
|
168
|
+
|
|
169
|
+
const output = new Uint8Array(this.#totalBytes)
|
|
170
|
+
let offset = 0
|
|
171
|
+
for (let index = 0; index < this.#totalChunks; index++) {
|
|
172
|
+
const chunk = this.#chunks.get(index)
|
|
173
|
+
if (!chunk) throw new Error(`Missing downloaded chunk ${index + 1}`)
|
|
174
|
+
output.set(chunk, offset)
|
|
175
|
+
offset += chunk.length
|
|
176
|
+
}
|
|
177
|
+
this.state = 'completed'
|
|
178
|
+
this.#resolve(offset === output.length ? output : output.slice(0, offset))
|
|
179
|
+
this.#emit()
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (this.state === 'cancelled') return
|
|
182
|
+
this.state = 'failed'
|
|
183
|
+
this.#stopped = true
|
|
184
|
+
this.error = error
|
|
185
|
+
this.#reject(error)
|
|
186
|
+
this.#emit()
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#emit(): void {
|
|
191
|
+
const progress = this.progress
|
|
192
|
+
for (const listener of this.#listeners) listener(progress)
|
|
193
|
+
}
|
|
194
|
+
}
|
package/src/peernet.ts
CHANGED
|
@@ -10,6 +10,18 @@ import { Storage as LeofcoinStorageClass } from '@leofcoin/storage'
|
|
|
10
10
|
import { utils as codecUtils } from '@leofcoin/codecs'
|
|
11
11
|
import Identity from './identity.js'
|
|
12
12
|
import swarm from '@netpeer/swarm/client'
|
|
13
|
+
import FileTransfer from './file-transfer.js'
|
|
14
|
+
|
|
15
|
+
const DEFAULT_CHUNK_SIZE = 256 * 1024
|
|
16
|
+
const DEFAULT_BLOCK_CHUNK_SIZE = 256 * 1024
|
|
17
|
+
const DEFAULT_BLOCK_CHUNK_THRESHOLD = 1024 * 1024
|
|
18
|
+
const DEFAULT_TRANSFER_CONCURRENCY = 4
|
|
19
|
+
|
|
20
|
+
const adaptiveChunkSize = (size: number, minimum = DEFAULT_CHUNK_SIZE) => {
|
|
21
|
+
if (size >= 256 * 1024 * 1024) return Math.max(minimum, 4 * 1024 * 1024)
|
|
22
|
+
if (size >= 32 * 1024 * 1024) return Math.max(minimum, 1024 * 1024)
|
|
23
|
+
return minimum
|
|
24
|
+
}
|
|
13
25
|
|
|
14
26
|
globalThis.LeofcoinStorage = LeofcoinStorageClass
|
|
15
27
|
|
|
@@ -67,6 +79,9 @@ export default class Peernet {
|
|
|
67
79
|
_peerHandler: PeerDiscovery
|
|
68
80
|
protos: {}
|
|
69
81
|
version
|
|
82
|
+
blockChunkSize: number
|
|
83
|
+
blockChunkThreshold: number
|
|
84
|
+
transferConcurrency: number
|
|
70
85
|
|
|
71
86
|
#peerAttempts: { [key: string]: number } = {}
|
|
72
87
|
private _inMemoryBroadcasts: any
|
|
@@ -94,6 +109,15 @@ export default class Peernet {
|
|
|
94
109
|
this.stars = options.stars
|
|
95
110
|
this.transport = options.transport
|
|
96
111
|
this.version = options.version
|
|
112
|
+
this.blockChunkSize = options.blockChunkSize ?? DEFAULT_BLOCK_CHUNK_SIZE
|
|
113
|
+
this.blockChunkThreshold = options.blockChunkThreshold ?? DEFAULT_BLOCK_CHUNK_THRESHOLD
|
|
114
|
+
this.transferConcurrency = options.transferConcurrency ?? DEFAULT_TRANSFER_CONCURRENCY
|
|
115
|
+
if (!Number.isSafeInteger(this.blockChunkSize) || this.blockChunkSize <= 0)
|
|
116
|
+
throw new TypeError('blockChunkSize must be a positive integer')
|
|
117
|
+
if (!Number.isSafeInteger(this.blockChunkThreshold) || this.blockChunkThreshold < 0)
|
|
118
|
+
throw new TypeError('blockChunkThreshold must be a non-negative integer')
|
|
119
|
+
if (!Number.isSafeInteger(this.transferConcurrency) || this.transferConcurrency <= 0)
|
|
120
|
+
throw new TypeError('transferConcurrency must be a positive integer')
|
|
97
121
|
const parts = this.network.split(':')
|
|
98
122
|
this.networkVersion = options.networkVersion || parts.length > 1 ? parts[1] : 'mainnet'
|
|
99
123
|
|
|
@@ -372,10 +396,31 @@ export default class Peernet {
|
|
|
372
396
|
|
|
373
397
|
* @returns {Promise<string>} The hash that can be shared for direct download
|
|
374
398
|
*/
|
|
375
|
-
async broadcast(
|
|
399
|
+
async broadcast(
|
|
400
|
+
path: string,
|
|
401
|
+
{ content, links, chunkSize }: { content?: Uint8Array; links?: any[]; chunkSize?: number }
|
|
402
|
+
): Promise<string> {
|
|
403
|
+
chunkSize = chunkSize ?? (content ? adaptiveChunkSize(content.length) : DEFAULT_CHUNK_SIZE)
|
|
404
|
+
if (!Number.isSafeInteger(chunkSize) || chunkSize <= 0) throw new TypeError('chunkSize must be a positive integer')
|
|
376
405
|
let protoInput: any
|
|
377
|
-
if (content
|
|
406
|
+
if (content && content.length > chunkSize) {
|
|
407
|
+
const chunkLinks = []
|
|
408
|
+
for (let offset = 0, index = 0; offset < content.length; offset += chunkSize, index++) {
|
|
409
|
+
const chunkNode = await new globalThis.peernet.protos['peernet-file']({
|
|
410
|
+
path: `${path}.part-${index}`,
|
|
411
|
+
content: content.slice(offset, Math.min(offset + chunkSize, content.length))
|
|
412
|
+
})
|
|
413
|
+
const chunkHash = await chunkNode.hash()
|
|
414
|
+
const chunkEncoded = await chunkNode.encoded
|
|
415
|
+
if (!this._inMemoryBroadcasts) this._inMemoryBroadcasts = new Map()
|
|
416
|
+
this._inMemoryBroadcasts.set(chunkHash, chunkEncoded)
|
|
417
|
+
await shareStore.put(chunkHash, chunkEncoded)
|
|
418
|
+
chunkLinks.push({ hash: chunkHash, path: String(index), size: chunkNode.decoded.content.length })
|
|
419
|
+
}
|
|
420
|
+
protoInput = { path, links: chunkLinks, size: content.length, chunkSize, chunked: true }
|
|
421
|
+
} else if (content) protoInput = { path, content, size: content.length }
|
|
378
422
|
else if (links) protoInput = { path, links }
|
|
423
|
+
else throw new TypeError('broadcast requires content or links')
|
|
379
424
|
|
|
380
425
|
const protoNode = await new globalThis.peernet.protos['peernet-file'](protoInput)
|
|
381
426
|
const hash = await protoNode.hash()
|
|
@@ -390,6 +435,43 @@ export default class Peernet {
|
|
|
390
435
|
return hash
|
|
391
436
|
}
|
|
392
437
|
|
|
438
|
+
/** Create and immediately start a resumable, integrity-checked file download. */
|
|
439
|
+
download(hash: string, options: { pin?: boolean; autoStart?: boolean } = {}): FileTransfer {
|
|
440
|
+
const FileProto = globalThis.peernet.protos['peernet-file']
|
|
441
|
+
const transfer = new FileTransfer({
|
|
442
|
+
hash,
|
|
443
|
+
fetch: async (wantedHash, index) => {
|
|
444
|
+
if (wantedHash !== hash) {
|
|
445
|
+
const providers = Object.values(this.dht.providersFor(hash) || {}) as DHTProvider[]
|
|
446
|
+
for (const provider of providers) this.dht.addProvider(provider, wantedHash)
|
|
447
|
+
}
|
|
448
|
+
const store = await this.whichStore([...this.stores], wantedHash)
|
|
449
|
+
if (store && (await store.has(wantedHash))) return store.get(wantedHash)
|
|
450
|
+
const result = await this.requestData(wantedHash, undefined, { providerIndex: index })
|
|
451
|
+
return result
|
|
452
|
+
},
|
|
453
|
+
decode: async (encoded) => {
|
|
454
|
+
const node = await new FileProto(encoded)
|
|
455
|
+
await node.decode()
|
|
456
|
+
if (node.decoded?.chunked) {
|
|
457
|
+
const providers = Object.values(this.dht.providersFor(hash) || {}) as DHTProvider[]
|
|
458
|
+
for (const link of node.decoded.links || []) {
|
|
459
|
+
for (const provider of providers) this.dht.addProvider(provider, link.hash)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return node.decoded
|
|
463
|
+
},
|
|
464
|
+
concurrency: this.transferConcurrency,
|
|
465
|
+
verify: async (encoded, expectedHash) => {
|
|
466
|
+
const node = await new FileProto(encoded)
|
|
467
|
+
return (await node.hash()) === expectedHash
|
|
468
|
+
},
|
|
469
|
+
pin: options.pin ? (chunkHash, encoded) => dataStore.put(chunkHash, encoded) : undefined
|
|
470
|
+
})
|
|
471
|
+
if (options.autoStart !== false) transfer.start()
|
|
472
|
+
return transfer
|
|
473
|
+
}
|
|
474
|
+
|
|
393
475
|
async handleData(peer, id, proto) {
|
|
394
476
|
let { hash, store } = proto.decoded
|
|
395
477
|
let data
|
|
@@ -400,16 +482,9 @@ export default class Peernet {
|
|
|
400
482
|
if (typeof hash === 'function') {
|
|
401
483
|
resolvedHash = await hash()
|
|
402
484
|
}
|
|
403
|
-
// Decode the stored proto to extract the content or links
|
|
404
|
-
const FileProto = globalThis.peernet.protos['peernet-file']
|
|
405
|
-
const fileProto = await new FileProto(data)
|
|
406
|
-
await fileProto.decode()
|
|
407
|
-
const { content, links } = fileProto.decoded
|
|
408
|
-
console.log(links)
|
|
409
|
-
|
|
410
485
|
data = await new globalThis.peernet.protos['peernet-data-response']({
|
|
411
486
|
hash: resolvedHash,
|
|
412
|
-
data
|
|
487
|
+
data
|
|
413
488
|
})
|
|
414
489
|
|
|
415
490
|
const node = await this.prepareMessage(data)
|
|
@@ -573,18 +648,88 @@ export default class Peernet {
|
|
|
573
648
|
get block() {
|
|
574
649
|
return {
|
|
575
650
|
get: async (hash: string) => {
|
|
576
|
-
|
|
577
|
-
if (data) return blockStore.get(hash)
|
|
578
|
-
return this.requestData(hash, 'block')
|
|
651
|
+
return this.#createBlockTransfer(hash).result
|
|
579
652
|
},
|
|
580
653
|
put: async (hash: string, data: Uint8Array) => {
|
|
581
654
|
if (await blockStore.has(hash)) return
|
|
655
|
+
if (data.length > this.blockChunkThreshold) return this.#putChunkedBlock(hash, data)
|
|
582
656
|
return await blockStore.put(hash, data)
|
|
583
657
|
},
|
|
584
|
-
has: async (hash: string) => await blockStore.has(hash)
|
|
658
|
+
has: async (hash: string) => await blockStore.has(hash),
|
|
659
|
+
download: (hash: string, options: { autoStart?: boolean } = {}) => this.#createBlockTransfer(hash, options)
|
|
585
660
|
}
|
|
586
661
|
}
|
|
587
662
|
|
|
663
|
+
async #putChunkedBlock(hash: string, data: Uint8Array) {
|
|
664
|
+
const links = []
|
|
665
|
+
const FileProto = globalThis.peernet.protos['peernet-file']
|
|
666
|
+
const chunkSize = adaptiveChunkSize(data.length, this.blockChunkSize)
|
|
667
|
+
for (let offset = 0, index = 0; offset < data.length; offset += chunkSize, index++) {
|
|
668
|
+
const content = data.slice(offset, Math.min(offset + chunkSize, data.length))
|
|
669
|
+
const chunk = new FileProto({ path: `block-${hash}.part-${index}`, content })
|
|
670
|
+
const chunkHash = await chunk.hash()
|
|
671
|
+
await blockStore.put(chunkHash, chunk.encoded)
|
|
672
|
+
links.push({ hash: chunkHash, path: String(index).padStart(12, '0'), size: content.length })
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const manifest = new FileProto({
|
|
676
|
+
path: `block-${hash}`,
|
|
677
|
+
links,
|
|
678
|
+
size: data.length,
|
|
679
|
+
chunkSize,
|
|
680
|
+
chunked: true,
|
|
681
|
+
kind: 'block',
|
|
682
|
+
blockHash: hash
|
|
683
|
+
})
|
|
684
|
+
return blockStore.put(hash, manifest.encoded)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
#createBlockTransfer(hash: string, options: { autoStart?: boolean } = {}): FileTransfer {
|
|
688
|
+
const FileProto = globalThis.peernet.protos['peernet-file']
|
|
689
|
+
const fetch = async (wantedHash: string, index?: number) => {
|
|
690
|
+
if (wantedHash !== hash) {
|
|
691
|
+
const providers = Object.values(this.dht.providersFor(hash) || {}) as DHTProvider[]
|
|
692
|
+
for (const provider of providers) this.dht.addProvider(provider, wantedHash)
|
|
693
|
+
}
|
|
694
|
+
if (await blockStore.has(wantedHash)) return blockStore.get(wantedHash)
|
|
695
|
+
const result = await this.requestData(wantedHash, 'block', { providerIndex: index })
|
|
696
|
+
return result
|
|
697
|
+
}
|
|
698
|
+
const transfer = new FileTransfer({
|
|
699
|
+
hash,
|
|
700
|
+
fetch,
|
|
701
|
+
decode: async (encoded) => {
|
|
702
|
+
let node
|
|
703
|
+
try {
|
|
704
|
+
node = new FileProto(encoded)
|
|
705
|
+
await node.decode()
|
|
706
|
+
} catch {
|
|
707
|
+
// A normal block is intentionally not a peernet-file envelope.
|
|
708
|
+
return { content: encoded }
|
|
709
|
+
}
|
|
710
|
+
if (node.decoded?.kind === 'block') {
|
|
711
|
+
if (node.decoded.blockHash !== hash) throw new Error(`Block manifest hash mismatch for ${hash}`)
|
|
712
|
+
const providers = Object.values(this.dht.providersFor(hash) || {}) as DHTProvider[]
|
|
713
|
+
for (const link of node.decoded.links || []) {
|
|
714
|
+
for (const provider of providers) this.dht.addProvider(provider, link.hash)
|
|
715
|
+
}
|
|
716
|
+
return node.decoded
|
|
717
|
+
}
|
|
718
|
+
if (node.decoded?.path?.startsWith(`block-${hash}.part-`)) return node.decoded
|
|
719
|
+
return { content: encoded }
|
|
720
|
+
},
|
|
721
|
+
verify: async (encoded, expectedHash) => {
|
|
722
|
+
const node = new FileProto(encoded)
|
|
723
|
+
return (await node.hash()) === expectedHash
|
|
724
|
+
},
|
|
725
|
+
verifyManifest: false,
|
|
726
|
+
concurrency: this.transferConcurrency,
|
|
727
|
+
pin: (wantedHash, encoded) => blockStore.put(wantedHash, encoded)
|
|
728
|
+
})
|
|
729
|
+
if (options.autoStart !== false) transfer.start()
|
|
730
|
+
return transfer
|
|
731
|
+
}
|
|
732
|
+
|
|
588
733
|
get transaction() {
|
|
589
734
|
return {
|
|
590
735
|
get: async (hash: string) => {
|
|
@@ -600,13 +745,17 @@ export default class Peernet {
|
|
|
600
745
|
}
|
|
601
746
|
}
|
|
602
747
|
|
|
603
|
-
async requestData(hash, store) {
|
|
748
|
+
async requestData(hash, store, options: { providerIndex?: number } = {}) {
|
|
604
749
|
try {
|
|
605
750
|
const providers = await this.providersFor(hash)
|
|
606
751
|
if (!providers || (providers && Object.keys(providers).length === 0)) throw nothingFoundError(hash)
|
|
607
752
|
debug(`found ${Object.keys(providers).length} provider(s) for ${hash}`)
|
|
608
753
|
// get closest peer on earth
|
|
609
|
-
|
|
754
|
+
const providerValues = Object.values(providers) as DHTProvider[]
|
|
755
|
+
let closestPeer: DHTProvider =
|
|
756
|
+
options.providerIndex === undefined
|
|
757
|
+
? await this.dht.closestPeer(providerValues)
|
|
758
|
+
: providerValues[options.providerIndex % providerValues.length]
|
|
610
759
|
// fallback to first provider if no closest peer found
|
|
611
760
|
if (!closestPeer || !closestPeer.id) closestPeer = Object.values(providers)[0]
|
|
612
761
|
|
|
@@ -618,7 +767,7 @@ export default class Peernet {
|
|
|
618
767
|
|
|
619
768
|
if (!peer || !peer?.connected) {
|
|
620
769
|
this.dht.removeProvider(id, hash)
|
|
621
|
-
return this.requestData(hash, store?.name || store)
|
|
770
|
+
return this.requestData(hash, store?.name || store, options)
|
|
622
771
|
}
|
|
623
772
|
|
|
624
773
|
let data = await new globalThis.peernet.protos['peernet-data']({
|
|
@@ -661,7 +810,7 @@ export default class Peernet {
|
|
|
661
810
|
|
|
662
811
|
if (this.#peerAttempts[id] === undefined) this.#peerAttempts[id] = 0
|
|
663
812
|
this.#peerAttempts[id]++
|
|
664
|
-
return this.requestData(hash, store?.name || store)
|
|
813
|
+
return this.requestData(hash, store?.name || store, options)
|
|
665
814
|
}
|
|
666
815
|
|
|
667
816
|
// this.put(hash, proto.decoded.data)
|
|
@@ -843,16 +992,8 @@ export default class Peernet {
|
|
|
843
992
|
}
|
|
844
993
|
|
|
845
994
|
async cat(hash, options) {
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
data = has ? await dataStore.get(hash) : await this.requestData(hash, 'data')
|
|
849
|
-
if (!data) throw nothingFoundError(hash)
|
|
850
|
-
const node = await new globalThis.peernet.protos['peernet-file'](data)
|
|
851
|
-
await node.decode()
|
|
852
|
-
|
|
853
|
-
if (node.decoded?.links.length > 0) throw new Error(`${hash} is a directory`)
|
|
854
|
-
if (options?.pin) await dataStore.put(hash, node.encoded)
|
|
855
|
-
return node.decoded.content
|
|
995
|
+
const transfer = this.download(hash, { pin: options?.pin })
|
|
996
|
+
return transfer.result
|
|
856
997
|
}
|
|
857
998
|
|
|
858
999
|
/**
|
|
@@ -969,3 +1110,4 @@ export default class Peernet {
|
|
|
969
1110
|
}
|
|
970
1111
|
|
|
971
1112
|
globalThis.Peernet = Peernet
|
|
1113
|
+
export { FileTransfer }
|
package/src/proto/file.proto.js
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { FileTransfer } from '../exports/peernet.js'
|
|
4
|
+
|
|
5
|
+
const bytes = (value) => new TextEncoder().encode(JSON.stringify(value))
|
|
6
|
+
const decode = async (value) => JSON.parse(new TextDecoder().decode(value))
|
|
7
|
+
|
|
8
|
+
test('chunk transfer pauses, resumes, reassembles, and reports progress', async () => {
|
|
9
|
+
const objects = new Map([
|
|
10
|
+
['manifest', bytes({ chunked: true, size: 6, links: [{ hash: 'a', size: 2 }, { hash: 'b', size: 2 }, { hash: 'c', size: 2 }] })],
|
|
11
|
+
['a', bytes({ content: [1, 2] })],
|
|
12
|
+
['b', bytes({ content: [3, 4] })],
|
|
13
|
+
['c', bytes({ content: [5, 6] })]
|
|
14
|
+
])
|
|
15
|
+
const fetched = []
|
|
16
|
+
const transfer = new FileTransfer({
|
|
17
|
+
hash: 'manifest',
|
|
18
|
+
fetch: async (hash) => {
|
|
19
|
+
fetched.push(hash)
|
|
20
|
+
return objects.get(hash)
|
|
21
|
+
},
|
|
22
|
+
decode: async (encoded) => {
|
|
23
|
+
const value = await decode(encoded)
|
|
24
|
+
if (value.content) value.content = new Uint8Array(value.content)
|
|
25
|
+
return value
|
|
26
|
+
},
|
|
27
|
+
verify: async (_encoded, hash) => objects.has(hash)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
transfer.pause()
|
|
31
|
+
transfer.start()
|
|
32
|
+
transfer.pause()
|
|
33
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
34
|
+
assert.deepEqual(fetched, ['manifest'])
|
|
35
|
+
assert.equal(transfer.state, 'paused')
|
|
36
|
+
|
|
37
|
+
transfer.resume()
|
|
38
|
+
assert.deepEqual(await transfer.result, new Uint8Array([1, 2, 3, 4, 5, 6]))
|
|
39
|
+
assert.equal(transfer.state, 'completed')
|
|
40
|
+
assert.equal(transfer.progress.completedChunks, 3)
|
|
41
|
+
assert.equal(transfer.progress.transferredBytes, 6)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('chunk transfer rejects failed integrity checks', async () => {
|
|
45
|
+
const transfer = new FileTransfer({
|
|
46
|
+
hash: 'bad',
|
|
47
|
+
fetch: async () => bytes({ content: [1] }),
|
|
48
|
+
decode,
|
|
49
|
+
verify: async () => false
|
|
50
|
+
}).start()
|
|
51
|
+
|
|
52
|
+
await assert.rejects(transfer.result, /Manifest hash mismatch/)
|
|
53
|
+
assert.equal(transfer.state, 'failed')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('chunk transfer pipelines up to the configured concurrency', async () => {
|
|
57
|
+
const links = Array.from({ length: 8 }, (_, index) => ({ hash: `chunk-${index}`, size: 1 }))
|
|
58
|
+
let active = 0
|
|
59
|
+
let maximumActive = 0
|
|
60
|
+
const transfer = new FileTransfer({
|
|
61
|
+
hash: 'manifest',
|
|
62
|
+
concurrency: 4,
|
|
63
|
+
fetch: async (hash) => {
|
|
64
|
+
if (hash === 'manifest') return bytes({ chunked: true, size: 8, links })
|
|
65
|
+
active += 1
|
|
66
|
+
maximumActive = Math.max(maximumActive, active)
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
68
|
+
active -= 1
|
|
69
|
+
return bytes({ content: [Number(hash.split('-')[1])] })
|
|
70
|
+
},
|
|
71
|
+
decode: async (encoded) => {
|
|
72
|
+
const value = await decode(encoded)
|
|
73
|
+
if (value.content) value.content = new Uint8Array(value.content)
|
|
74
|
+
return value
|
|
75
|
+
},
|
|
76
|
+
verify: async () => true
|
|
77
|
+
}).start()
|
|
78
|
+
|
|
79
|
+
assert.deepEqual(await transfer.result, new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]))
|
|
80
|
+
assert.equal(maximumActive, 4)
|
|
81
|
+
})
|
package/test/peernet.test.js
CHANGED
|
@@ -187,6 +187,31 @@ test('block object provides get/put/has', () => {
|
|
|
187
187
|
assert.equal(typeof blockObj.has, 'function')
|
|
188
188
|
})
|
|
189
189
|
|
|
190
|
+
test('large blocks are chunked internally and reconstructed transparently', async () => {
|
|
191
|
+
const previousThreshold = peernet.blockChunkThreshold
|
|
192
|
+
const previousChunkSize = peernet.blockChunkSize
|
|
193
|
+
peernet.blockChunkThreshold = 512
|
|
194
|
+
peernet.blockChunkSize = 256
|
|
195
|
+
try {
|
|
196
|
+
const hash = `chunked-block-${Date.now()}`
|
|
197
|
+
const block = new Uint8Array(1537)
|
|
198
|
+
for (let index = 0; index < block.length; index++) block[index] = index % 251
|
|
199
|
+
|
|
200
|
+
await peernet.block.put(hash, block)
|
|
201
|
+
const stored = await globalThis.blockStore.get(hash)
|
|
202
|
+
const manifest = new peernet.protos['peernet-file'](stored)
|
|
203
|
+
await manifest.decode()
|
|
204
|
+
|
|
205
|
+
assert.equal(manifest.decoded.kind, 'block')
|
|
206
|
+
assert.equal(manifest.decoded.blockHash, hash)
|
|
207
|
+
assert.equal(manifest.decoded.links.length, 7)
|
|
208
|
+
assert.deepEqual(await peernet.block.get(hash), block)
|
|
209
|
+
} finally {
|
|
210
|
+
peernet.blockChunkThreshold = previousThreshold
|
|
211
|
+
peernet.blockChunkSize = previousChunkSize
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
|
|
190
215
|
test('transaction object provides get/put/has', () => {
|
|
191
216
|
const txObj = peernet.transaction
|
|
192
217
|
assert.equal(typeof txObj.get, 'function')
|
|
@@ -329,7 +354,10 @@ test('in-memory broadcast and handleData returns correct data', async () => {
|
|
|
329
354
|
const DataResponseProto = globalThis.peernet.protos['peernet-data-response']
|
|
330
355
|
const decodedProto = await new DataResponseProto(sentNode.data)
|
|
331
356
|
await decodedProto.decode()
|
|
332
|
-
const
|
|
357
|
+
const FileProto = globalThis.peernet.protos['peernet-file']
|
|
358
|
+
const decodedFile = new FileProto(decodedProto.decoded.data)
|
|
359
|
+
await decodedFile.decode()
|
|
360
|
+
const decodedContent = new TextDecoder().decode(decodedFile.decoded.content)
|
|
333
361
|
assert.equal(decodedProto.decoded.hash, hash)
|
|
334
362
|
assert.equal(decodedContent, testString)
|
|
335
363
|
})
|
|
@@ -362,7 +390,8 @@ test('in-memory broadcast and handleData supports large binary data', async () =
|
|
|
362
390
|
const decodedProto = await new DataResponseProto(sentNode.data)
|
|
363
391
|
await decodedProto.decode()
|
|
364
392
|
assert.equal(decodedProto.decoded.hash, hash)
|
|
365
|
-
const
|
|
393
|
+
const transfer = peernet.download(hash)
|
|
394
|
+
const receivedBuffer = Buffer.from(await transfer.result)
|
|
366
395
|
const originalBuffer = Buffer.from(largeBuffer)
|
|
367
396
|
assert.equal(Buffer.compare(receivedBuffer, originalBuffer), 0)
|
|
368
397
|
})
|