@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.
Files changed (45) hide show
  1. package/README.md +44 -1
  2. package/exports/browser/client-86Tnax66.js +2224 -0
  3. package/exports/browser/client-BFxJts6j.js +2224 -0
  4. package/exports/browser/client-BzvWzzLv.js +2224 -0
  5. package/exports/browser/client-C9GBz2Tf.js +2224 -0
  6. package/exports/browser/client-CQzuu6At.js +2224 -0
  7. package/exports/browser/client-CVkFejdx.js +2224 -0
  8. package/exports/browser/client-Cj_UUO8W.js +2224 -0
  9. package/exports/browser/client-CrGaeBjj.js +2224 -0
  10. package/exports/browser/client-ERocgKET.js +2224 -0
  11. package/exports/browser/client-a8rws3cu.js +2224 -0
  12. package/exports/browser/messages-BFCmg6Vb.js +212 -0
  13. package/exports/browser/messages-BHIC2mQu.js +212 -0
  14. package/exports/browser/messages-CimzJ4wr.js +210 -0
  15. package/exports/browser/messages-Cjoa_LGO.js +210 -0
  16. package/exports/browser/messages-D7JxkkDW.js +212 -0
  17. package/exports/browser/messages-DVHcNvs_.js +212 -0
  18. package/exports/browser/messages-DqL_g2qw.js +210 -0
  19. package/exports/browser/messages-XgZZUMma.js +212 -0
  20. package/exports/browser/messages-kfFyctPL.js +210 -0
  21. package/exports/browser/messages-psROnfRi.js +212 -0
  22. package/exports/browser/peernet-B9VL4Xwe.js +9522 -0
  23. package/exports/browser/peernet-C-HNdrcd.js +9528 -0
  24. package/exports/browser/peernet-C4rOvyyU.js +9661 -0
  25. package/exports/browser/peernet-CbI0-jFn.js +9651 -0
  26. package/exports/browser/peernet-CtHScDyh.js +9524 -0
  27. package/exports/browser/peernet-DfuxUK3u.js +9525 -0
  28. package/exports/browser/peernet-DiEcNDLD.js +9655 -0
  29. package/exports/browser/peernet-DtuRXOzP.js +9653 -0
  30. package/exports/browser/peernet-rcD5XL1y.js +9590 -0
  31. package/exports/browser/peernet-rw8HJGOz.js +9597 -0
  32. package/exports/browser/peernet.js +1 -1
  33. package/exports/messages-0uXHmQH7.js +209 -0
  34. package/exports/messages-DaUtFfBh.js +207 -0
  35. package/exports/peernet.js +358 -33
  36. package/exports/types/file-transfer.d.ts +34 -0
  37. package/exports/types/peernet.d.ts +20 -4
  38. package/exports/types/proto/file.proto.d.ts +5 -0
  39. package/package.json +2 -2
  40. package/rollup.config.js +2 -6
  41. package/src/file-transfer.ts +194 -0
  42. package/src/peernet.ts +170 -28
  43. package/src/proto/file.proto.js +6 -1
  44. package/test/file-transfer.test.js +81 -0
  45. 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(path: string, { content, links }: { content?: Uint8Array; links?: any[] }): Promise<string> {
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) protoInput = { path, 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: links || content
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
- const data = await blockStore.has(hash)
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
- let closestPeer: DHTProvider = await this.dht.closestPeer(Object.values(providers))
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
- let data
847
- const has = await dataStore.has(hash)
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 }
@@ -1,5 +1,10 @@
1
1
  export default {
2
2
  path: String(),
3
3
  'content?': new Uint8Array(),
4
- 'links?': []
4
+ 'links?': [],
5
+ 'size?': Number(),
6
+ 'chunkSize?': Number(),
7
+ 'chunked?': Boolean(),
8
+ 'kind?': String(),
9
+ 'blockHash?': String()
5
10
  }
@@ -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
+ })
@@ -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 decodedContent = new TextDecoder().decode(decodedProto.decoded.data)
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 receivedBuffer = Buffer.from(decodedProto.decoded.data)
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
  })