@bsv/sdk 1.4.1 → 1.4.2

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 (28) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/storage/StorageDownloader.js +82 -0
  3. package/dist/cjs/src/storage/StorageDownloader.js.map +1 -0
  4. package/dist/cjs/src/storage/__test/StorageDownloader.test.js +144 -0
  5. package/dist/cjs/src/storage/__test/StorageDownloader.test.js.map +1 -0
  6. package/dist/cjs/src/storage/index.js +3 -1
  7. package/dist/cjs/src/storage/index.js.map +1 -1
  8. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  9. package/dist/esm/src/storage/StorageDownloader.js +75 -0
  10. package/dist/esm/src/storage/StorageDownloader.js.map +1 -0
  11. package/dist/esm/src/storage/__test/StorageDownloader.test.js +139 -0
  12. package/dist/esm/src/storage/__test/StorageDownloader.test.js.map +1 -0
  13. package/dist/esm/src/storage/index.js +1 -0
  14. package/dist/esm/src/storage/index.js.map +1 -1
  15. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  16. package/dist/types/src/storage/StorageDownloader.d.ts +25 -0
  17. package/dist/types/src/storage/StorageDownloader.d.ts.map +1 -0
  18. package/dist/types/src/storage/__test/StorageDownloader.test.d.ts +2 -0
  19. package/dist/types/src/storage/__test/StorageDownloader.test.d.ts.map +1 -0
  20. package/dist/types/src/storage/index.d.ts +1 -0
  21. package/dist/types/src/storage/index.d.ts.map +1 -1
  22. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  23. package/dist/umd/bundle.js +1 -1
  24. package/docs/storage.md +51 -0
  25. package/package.json +1 -1
  26. package/src/storage/StorageDownloader.ts +91 -0
  27. package/src/storage/__test/StorageDownloader.test.ts +170 -0
  28. package/src/storage/index.ts +3 -0
package/docs/storage.md CHANGED
@@ -6,6 +6,8 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
6
6
 
7
7
  | |
8
8
  | --- |
9
+ | [DownloadResult](#interface-downloadresult) |
10
+ | [DownloaderConfig](#interface-downloaderconfig) |
9
11
  | [UploadFileResult](#interface-uploadfileresult) |
10
12
  | [UploadableFile](#interface-uploadablefile) |
11
13
  | [UploaderConfig](#interface-uploaderconfig) |
@@ -14,6 +16,29 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
14
16
 
15
17
  ---
16
18
 
19
+ ### Interface: DownloadResult
20
+
21
+ ```ts
22
+ export interface DownloadResult {
23
+ data: Buffer;
24
+ mimeType: string | null;
25
+ }
26
+ ```
27
+
28
+ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
29
+
30
+ ---
31
+ ### Interface: DownloaderConfig
32
+
33
+ ```ts
34
+ export interface DownloaderConfig {
35
+ networkPreset: string;
36
+ }
37
+ ```
38
+
39
+ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
40
+
41
+ ---
17
42
  ### Interface: UploadFileResult
18
43
 
19
44
  ```ts
@@ -54,6 +79,32 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
54
79
  ---
55
80
  ## Classes
56
81
 
82
+ | |
83
+ | --- |
84
+ | [StorageDownloader](#class-storagedownloader) |
85
+ | [StorageUploader](#class-storageuploader) |
86
+
87
+ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
88
+
89
+ ---
90
+
91
+ ### Class: StorageDownloader
92
+
93
+ Locates HTTP URLs where content can be downloaded. It uses the passed or the default one.
94
+
95
+ ```ts
96
+ export class StorageDownloader {
97
+ constructor(config?: DownloaderConfig)
98
+ public async resolve(uhrpUrl: string): Promise<string[]>
99
+ public async download(uhrpUrl: string)
100
+ }
101
+ ```
102
+
103
+ See also: [DownloaderConfig](./storage.md#interface-downloaderconfig)
104
+
105
+ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
106
+
107
+ ---
57
108
  ### Class: StorageUploader
58
109
 
59
110
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsv/sdk",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "type": "module",
5
5
  "description": "BSV Blockchain Software Development Kit",
6
6
  "main": "dist/cjs/mod.js",
@@ -0,0 +1,91 @@
1
+ import { LookupResolver } from '../overlay-tools/index.js'
2
+ import { StorageUtils } from './index.js'
3
+ import PushDrop from '../script/templates/PushDrop.js'
4
+ import Transaction from '../transaction/Transaction.js'
5
+ import { Hash, Utils } from '../primitives/index.js'
6
+
7
+ export interface DownloaderConfig {
8
+ networkPreset: 'mainnet' | 'testnet' | 'local'
9
+ }
10
+
11
+ export interface DownloadResult {
12
+ data: number[]
13
+ mimeType: string | null
14
+ }
15
+
16
+ /**
17
+ * Locates HTTP URLs where content can be downloaded. It uses the passed or the default one.
18
+ *
19
+ * @param {Object} obj All parameters are passed in an object.
20
+ * @param {String} obj.uhrpUrl The UHRP url to resolve.
21
+ * @param {string} obj.confederacyHost HTTPS URL for for the with default setting.
22
+ *
23
+ * @return {Array<String>} An array of HTTP URLs where content can be downloaded.
24
+ * @throws {Error} If UHRP url parameter invalid or is not an array
25
+ * or there is an error retrieving url(s) stored in the UHRP token.
26
+ */
27
+ export class StorageDownloader {
28
+ private readonly networkPreset?: 'mainnet' | 'testnet' | 'local' = 'mainnet'
29
+
30
+ constructor (config?: DownloaderConfig) {
31
+ this.networkPreset = config?.networkPreset
32
+ }
33
+
34
+ public async resolve (uhrpUrl: string): Promise<string[]> {
35
+ // Use UHRP lookup service
36
+ const lookupResolver = new LookupResolver({ networkPreset: this.networkPreset })
37
+ const response = await lookupResolver.query({ service: 'ls_uhrp', query: { uhrpUrl } })
38
+ if (response.type !== 'output-list') {
39
+ throw new Error('Lookup answer must be an output list')
40
+ }
41
+ const decodedResults: string[] = []
42
+ for (let i = 0; i < response.outputs.length; i++) {
43
+ const tx = Transaction.fromBEEF(response.outputs[i].beef)
44
+ const { fields } = PushDrop.decode(tx.outputs[response.outputs[i].outputIndex].lockingScript)
45
+ decodedResults.push(Utils.toUTF8(fields[2]))
46
+ }
47
+ return decodedResults
48
+ }
49
+
50
+ public async download (uhrpUrl: string): Promise<DownloadResult> {
51
+ if (!StorageUtils.isValidURL(uhrpUrl)) {
52
+ throw new Error('Invalid parameter UHRP url')
53
+ }
54
+ const hash = StorageUtils.getHashFromURL(uhrpUrl)
55
+ const downloadURLs = await this.resolve(uhrpUrl)
56
+
57
+ if (!Array.isArray(downloadURLs) || downloadURLs.length === 0) {
58
+ throw new Error('No one currently hosts this file!')
59
+ }
60
+
61
+ for (let i = 0; i < downloadURLs.length; i++) {
62
+ try {
63
+ // The url is fetched
64
+ const result = await fetch(downloadURLs[i], { method: 'GET' })
65
+
66
+ // If the request fails, continue to the next url
67
+ if (!result.ok || result.status >= 400) {
68
+ continue
69
+ }
70
+ const body = await result.arrayBuffer()
71
+
72
+ // The body is loaded into a number array
73
+ const content: number[] = [...new Uint8Array(body)]
74
+ const contentHash = Hash.sha256(content)
75
+ for (let i = 0; i < contentHash.length; ++i) {
76
+ if (contentHash[i] !== hash[i]) {
77
+ throw new Error('Value of content does not match hash of the url given')
78
+ }
79
+ }
80
+
81
+ return {
82
+ data: content,
83
+ mimeType: result.headers.get('Content-Type')
84
+ }
85
+ } catch (error) {
86
+ continue
87
+ }
88
+ }
89
+ throw new Error(`Unable to download content from ${uhrpUrl}`)
90
+ }
91
+ }
@@ -0,0 +1,170 @@
1
+ import { StorageDownloader } from '../StorageDownloader.js'
2
+ import { StorageUtils } from '../index.js'
3
+ import { LookupResolver } from '../../overlay-tools/index.js'
4
+ import Transaction from '../../transaction/Transaction.js'
5
+ import PushDrop from '../../script/templates/PushDrop.js'
6
+ import { Hash, PublicKey } from '../../primitives/index.js'
7
+ import { Utils } from '../../primitives/index.js'
8
+
9
+ beforeEach(() => {
10
+ jest.restoreAllMocks()
11
+ })
12
+
13
+ describe('StorageDownloader', () => {
14
+ let downloader: StorageDownloader
15
+
16
+ beforeEach(() => {
17
+ // Create a fresh instance
18
+ downloader = new StorageDownloader()
19
+ })
20
+
21
+ describe('resolve()', () => {
22
+ it('throws if the lookup response is not "output-list"', async () => {
23
+ // Mock the LookupResolver to return something invalid
24
+ jest.spyOn(LookupResolver.prototype, 'query').mockResolvedValue({
25
+ type: 'something-else',
26
+ outputs: []
27
+ } as any)
28
+
29
+ await expect(downloader.resolve('fakeUhrpUrl'))
30
+ .rejects
31
+ .toThrow('Lookup answer must be an output list')
32
+ })
33
+
34
+ it('decodes each output with Transaction.fromBEEF and PushDrop.decode', async () => {
35
+ // 1) Mock lookup response
36
+ jest.spyOn(LookupResolver.prototype, 'query').mockResolvedValue({
37
+ type: 'output-list',
38
+ outputs: [
39
+ { beef: 'fake-beef-a', outputIndex: 0 },
40
+ { beef: 'fake-beef-b', outputIndex: 1 }
41
+ ]
42
+ } as any)
43
+
44
+ // 2) Mock Transaction.fromBEEF -> returns a dummy transaction
45
+ jest.spyOn(Transaction, 'fromBEEF').mockImplementation(() => {
46
+ // Each transaction might have multiple outputs; we only care about `outputIndex`
47
+ return {
48
+ outputs: [
49
+ { lockingScript: {} }, // index 0
50
+ { lockingScript: {} } // index 1
51
+ ]
52
+ } as any
53
+ })
54
+
55
+ // 3) Mock PushDrop.decode -> returns { fields: number[][] }
56
+ jest.spyOn(PushDrop, 'decode').mockImplementation(() => {
57
+ // The decode function returns an object with `fields`,
58
+ return {
59
+ lockingPublicKey: {} as PublicKey,
60
+ fields: [
61
+ [11],
62
+ [22],
63
+ [104, 116, 116, 112, 58, 47, 47, 97, 46, 99, 111, 109]
64
+ ]
65
+ }
66
+ })
67
+
68
+ // 4) Mock Utils.toUTF8 to convert that number[] to a string
69
+ jest.spyOn(Utils, 'toUTF8').mockReturnValue('http://a.com')
70
+
71
+ const resolved = await downloader.resolve('fakeUhrpUrl')
72
+ expect(resolved).toEqual(['http://a.com', 'http://a.com'])
73
+ })
74
+ })
75
+
76
+ describe('download()', () => {
77
+ it('throws if UHRP URL is invalid', async () => {
78
+ jest.spyOn(StorageUtils, 'isValidURL').mockReturnValue(false)
79
+
80
+ await expect(downloader.download('invalidUrl'))
81
+ .rejects
82
+ .toThrow('Invalid parameter UHRP url')
83
+ })
84
+
85
+ it('throws if no hosts are found', async () => {
86
+ // Valid UHRP URL
87
+ jest.spyOn(StorageUtils, 'isValidURL').mockReturnValue(true)
88
+ // Return some random 32-byte hash so we can pass the check
89
+ jest.spyOn(StorageUtils, 'getHashFromURL').mockReturnValue(new Array(32).fill(0))
90
+
91
+ // Force resolve() to return an empty array
92
+ jest.spyOn(downloader, 'resolve').mockResolvedValue([])
93
+
94
+ await expect(downloader.download('validButUnhostedUrl'))
95
+ .rejects
96
+ .toThrow('No one currently hosts this file!')
97
+ })
98
+
99
+ it('downloads successfully from the first working host', async () => {
100
+ jest.spyOn(StorageUtils, 'isValidURL').mockReturnValue(true)
101
+ const knownHash = [
102
+ 102, 104, 122, 173, 248, 98, 189, 119, 108, 143,
103
+ 193, 139, 142, 159, 142, 32, 8, 151, 20, 133,
104
+ 110, 226, 51, 179, 144, 42, 89, 29, 13, 95,
105
+ 41, 37
106
+ ]
107
+ jest.spyOn(StorageUtils, 'getHashFromURL').mockReturnValue(knownHash)
108
+
109
+ // Suppose two possible download URLs
110
+ jest.spyOn(downloader, 'resolve').mockResolvedValue([
111
+ 'http://host1/404',
112
+ 'http://host2/ok'
113
+ ])
114
+
115
+ // The first fetch -> 404, second fetch -> success
116
+ const fetchSpy = jest.spyOn(global, 'fetch')
117
+ .mockResolvedValueOnce(new Response(null, { status: 404 }))
118
+ .mockResolvedValueOnce(new Response(new Uint8Array(32).fill(0), {
119
+ status: 200,
120
+ headers: { 'Content-Type': 'application/test' }
121
+ }))
122
+
123
+ const result = await downloader.download('validUrl')
124
+ expect(fetchSpy).toHaveBeenCalledTimes(2)
125
+ expect(result).toEqual({
126
+ data: new Array(32).fill(0),
127
+ mimeType: 'application/test'
128
+ })
129
+ })
130
+
131
+ it('throws if content hash mismatches the UHRP hash', async () => {
132
+ jest.spyOn(StorageUtils, 'isValidURL').mockReturnValue(true)
133
+ // The expected hash is all zeros
134
+ jest.spyOn(StorageUtils, 'getHashFromURL').mockReturnValue(new Array(32).fill(0))
135
+
136
+ // One potential host
137
+ jest.spyOn(downloader, 'resolve').mockResolvedValue([
138
+ 'http://bad-content.test'
139
+ ])
140
+
141
+ // The fetch returns 32 bytes of all 1's => hash mismatch
142
+ jest.spyOn(global, 'fetch').mockResolvedValue(
143
+ new Response(new Uint8Array(32).fill(1), { status: 200 })
144
+ )
145
+
146
+ await expect(downloader.download('validButBadHashUrl'))
147
+ .rejects
148
+ .toThrow()
149
+ })
150
+
151
+ it('throws if all hosts fail or mismatch', async () => {
152
+ jest.spyOn(StorageUtils, 'isValidURL').mockReturnValue(true)
153
+ jest.spyOn(StorageUtils, 'getHashFromURL').mockReturnValue(new Array(32).fill(0))
154
+
155
+ jest.spyOn(downloader, 'resolve').mockResolvedValue([
156
+ 'http://host1.test',
157
+ 'http://host2.test'
158
+ ])
159
+
160
+ // Both fetches fail with 500 or something >=400
161
+ jest.spyOn(global, 'fetch').mockResolvedValue(
162
+ new Response(null, { status: 500 })
163
+ )
164
+
165
+ await expect(downloader.download('validButNoGoodHostUrl'))
166
+ .rejects
167
+ .toThrow('Unable to download content from validButNoGoodHostUrl')
168
+ })
169
+ })
170
+ })
@@ -1,2 +1,5 @@
1
1
  export * as StorageUtils from './StorageUtils.js'
2
+
2
3
  export { StorageUploader } from './StorageUploader.js'
4
+
5
+ export { StorageDownloader } from './StorageDownloader.js'