@bsv/sdk
Version:
BSV Blockchain Software Development Kit
115 lines (98 loc) • 3.83 kB
text/typescript
import { LookupResolver } from '../overlay-tools/index.js'
import { StorageUtils } from './index.js'
import PushDrop from '../script/templates/PushDrop.js'
import Transaction from '../transaction/Transaction.js'
import { Hash, Utils } from '../primitives/index.js'
export interface DownloaderConfig {
networkPreset: 'mainnet' | 'testnet' | 'local'
}
export interface DownloadResult {
data: Uint8Array
mimeType: string | null
}
export class StorageDownloader {
private readonly networkPreset?: 'mainnet' | 'testnet' | 'local' = 'mainnet'
private readonly lookupResolver: LookupResolver
constructor (config?: DownloaderConfig) {
this.networkPreset = config?.networkPreset ?? 'mainnet'
this.lookupResolver = new LookupResolver({ networkPreset: this.networkPreset })
}
/**
* Resolves the UHRP URL to a list of HTTP URLs where content can be downloaded.
* @param uhrpUrl The UHRP URL to resolve.
* @returns A promise that resolves to an array of HTTP URLs.
*/
public async resolve (uhrpUrl: string): Promise<string[]> {
// Use UHRP lookup service
const response = await this.lookupResolver.query({ service: 'ls_uhrp', query: { uhrpUrl } })
if (response.type !== 'output-list') {
throw new Error('Lookup answer must be an output list')
}
const decodedResults: string[] = []
const currentTime = Math.floor(Date.now() / 1000)
for (let i = 0; i < response.outputs.length; i++) {
const tx = Transaction.fromBEEF(response.outputs[i].beef)
const { fields } = PushDrop.decode(tx.outputs[response.outputs[i].outputIndex].lockingScript)
const expiryTime = new Utils.Reader(fields[3]).readVarIntNum()
if (expiryTime < currentTime) {
continue
}
decodedResults.push(Utils.toUTF8(fields[2]))
}
return decodedResults
}
/**
* Downloads the content from the UHRP URL after validating the hash for integrity.
* @param uhrpUrl The UHRP URL to download.
* @returns A promise that resolves to the downloaded content.
*/
public async download (uhrpUrl: string): Promise<DownloadResult> {
if (!StorageUtils.isValidURL(uhrpUrl)) {
throw new Error('Invalid parameter UHRP url')
}
const hash = StorageUtils.getHashFromURL(uhrpUrl)
const expected = Utils.toHex(hash)
const downloadURLs = await this.resolve(uhrpUrl)
if (!Array.isArray(downloadURLs) || downloadURLs.length === 0) {
throw new Error('No one currently hosts this file!')
}
for (let i = 0; i < downloadURLs.length; i++) {
try {
// The url is fetched
const result = await fetch(downloadURLs[i], { method: 'GET' })
// If the request fails, continue to the next url
if (!result.ok || result.status >= 400 || result.body == null) {
continue
}
const reader = result.body.getReader()
const hashStream = new Hash.SHA256()
const chunks: Uint8Array[] = []
let totalLength = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
hashStream.update(Array.from(value))
chunks.push(value)
totalLength += value.length
}
const digest = Utils.toHex(hashStream.digest())
if (digest !== expected) {
throw new Error('Data integrity error: value of content does not match hash of the url given')
}
const data = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
data.set(chunk, offset)
offset += chunk.length
}
return {
data,
mimeType: result.headers.get('Content-Type')
}
} catch (error) {
continue
}
}
throw new Error(`Unable to download content from ${uhrpUrl}`)
}
}