UNPKG

@helia/verified-fetch

Version:

A fetch-like API for obtaining verified & trustless IPFS content on the web

255 lines (211 loc) 7.82 kB
import { DoesNotExistError } from '@helia/unixfs/errors' import * as dagCbor from '@ipld/dag-cbor' import * as dagJson from '@ipld/dag-json' import * as dagPb from '@ipld/dag-pb' import { peerIdFromString } from '@libp2p/peer-id' import { InvalidParametersError, walkPath } from 'ipfs-unixfs-exporter' import toBuffer from 'it-to-buffer' import { CID } from 'multiformats/cid' import * as json from 'multiformats/codecs/json' import * as raw from 'multiformats/codecs/raw' import QuickLRU from 'quick-lru' import { SESSION_CACHE_MAX_SIZE, SESSION_CACHE_TTL_MS, CODEC_CBOR, CODEC_IDENTITY } from './constants.ts' import { resourceToSessionCacheKey } from './utils/resource-to-cache-key.ts' import { ServerTiming } from './utils/server-timing.ts' import type { ResolveURLResult, URLResolver as URLResolverInterface } from './index.ts' import type { DNSLink } from '@helia/dnslink' import type { IPNSResolver } from '@helia/ipns' import type { AbortOptions } from '@libp2p/interface' import type { Helia, SessionBlockstore } from 'helia' import type { Blockstore } from 'interface-blockstore' import type { UnixFSEntry } from 'ipfs-unixfs-exporter' // 1 year in seconds for ipfs content const IPFS_CONTENT_TTL = 29030400 const ENTITY_CODECS = [ CODEC_CBOR, json.code, raw.code ] /** * These are supported by the UnixFS exporter */ const EXPORTABLE_CODECS = [ dagPb.code, dagCbor.code, dagJson.code, raw.code ] interface GetBlockstoreOptions extends AbortOptions { session?: boolean } export interface WalkPathResult { ipfsRoots: CID[] terminalElement: UnixFSEntry blockstore: Blockstore } function basicEntry (type: 'raw' | 'object', cid: CID, bytes: Uint8Array): UnixFSEntry { return { name: cid.toString(), path: cid.toString(), depth: 0, type, node: bytes, cid, size: BigInt(bytes.byteLength), content: async function * () { yield bytes } } } export interface URLResolverComponents { helia: Helia ipnsResolver: IPNSResolver dnsLink: DNSLink } export interface URLResolverInit { sessionCacheSize?: number sessionTTLms?: number } export interface ResolveURLOptions extends AbortOptions { session?: boolean isRawBlockRequest?: boolean onlyIfCached?: boolean } export class URLResolver implements URLResolverInterface { private readonly components: URLResolverComponents private readonly blockstoreSessions: QuickLRU<string, SessionBlockstore> constructor (components: URLResolverComponents, init: URLResolverInit = {}) { this.components = components this.blockstoreSessions = new QuickLRU({ maxSize: init.sessionCacheSize ?? SESSION_CACHE_MAX_SIZE, maxAge: init.sessionTTLms ?? SESSION_CACHE_TTL_MS, onEviction: (key, store) => { store.close() } }) } async resolve (url: URL, serverTiming: ServerTiming = new ServerTiming(), options: ResolveURLOptions = {}): Promise<ResolveURLResult> { if (url.protocol === 'ipfs:') { return this.resolveIPFSPath(url, serverTiming, options) } if (url.protocol === 'ipns:') { return this.resolveIPNSName(url, serverTiming, options) } if (url.protocol === 'dnslink:') { return this.resolveDNSLink(url, serverTiming, options) } throw new InvalidParametersError(`Invalid resource. Unsupported protocol in URL, must be ipfs:, ipns:, or dnslink: ${url}`) } private getBlockstore (root: CID, options: GetBlockstoreOptions = {}): Blockstore { if (options.session === false) { return this.components.helia.blockstore } const key = resourceToSessionCacheKey(root) let session = this.blockstoreSessions.get(key) if (session == null) { session = this.components.helia.blockstore.createSession(root, options) this.blockstoreSessions.set(key, session) } return session } private async resolveDNSLink (url: URL, serverTiming: ServerTiming, options?: ResolveURLOptions): Promise<ResolveURLResult> { const results = await serverTiming.time('dnsLink.resolve', `Resolve DNSLink ${url.hostname}`, this.components.dnsLink.resolve(url.hostname, options)) const result = results?.[0] if (result == null) { throw new TypeError(`Invalid resource. Cannot resolve DNSLink from domain: ${url.hostname}`) } // dnslink resolved to IPNS name if (result.namespace === 'ipns') { return this.resolveIPNSName(url, serverTiming, options) } // dnslink resolved to CID if (result.namespace !== 'ipfs') { // @ts-expect-error result namespace should only be ipns or ipfs throw new TypeError(`Invalid resource. Unexpected DNSLink namespace ${result.namespace} from domain: ${domain}`) } if (result.path != null && (url.pathname !== '' && url.pathname !== '/')) { // path conflict? } const ipfsUrl = new URL(`ipfs://${result.cid}/${url.pathname}`) const ipfsResult = await this.resolveIPFSPath(ipfsUrl, serverTiming, options) return { ...ipfsResult, url, ttl: result.answer.TTL } } private async resolveIPNSName (url: URL, serverTiming: ServerTiming, options?: ResolveURLOptions): Promise<ResolveURLResult> { const peerId = peerIdFromString(url.hostname) const result = await serverTiming.time('ipns.resolve', `Resolve IPNS name ${peerId}`, this.components.ipnsResolver.resolve(peerId, options)) if (result.path != null && (url.pathname !== '' && url.pathname !== '/')) { // path conflict? } const ipfsUrl = new URL(`ipfs://${result.cid}/${url.pathname}`) const ipfsResult = await this.resolveIPFSPath(ipfsUrl, serverTiming, options) return { ...ipfsResult, url, // IPNS ttl is in nanoseconds, convert to seconds ttl: Number((result.record.ttl ?? 0n) / BigInt(1e9)) } } private async resolveIPFSPath (url: URL, serverTiming: ServerTiming, options?: ResolveURLOptions): Promise<ResolveURLResult> { const walkPathResult = await serverTiming.time('ipfs.resolve', '', this.walkPath(url, options)) return { ...walkPathResult, url, ttl: IPFS_CONTENT_TTL, blockstore: walkPathResult.blockstore } } private async walkPath (url: URL, options: ResolveURLOptions = {}): Promise<WalkPathResult> { const cid = CID.parse(url.hostname) const blockstore = this.getBlockstore(cid, options) if (EXPORTABLE_CODECS.includes(cid.code)) { const ipfsRoots: CID[] = [] let terminalElement: UnixFSEntry | undefined const ipfsPath = toIPFSPath(url) // @ts-expect-error offline is a helia option for await (const entry of walkPath(ipfsPath, blockstore, { ...options, offline: options.onlyIfCached === true, extended: options.isRawBlockRequest !== true })) { ipfsRoots.push(entry.cid) terminalElement = entry } if (terminalElement == null) { throw new DoesNotExistError('No terminal element found') } return { ipfsRoots, terminalElement, blockstore } } let bytes: Uint8Array if (cid.multihash.code === CODEC_IDENTITY) { bytes = cid.multihash.digest } else { bytes = await toBuffer(blockstore.get(cid, options)) } // entity codecs contain all the bytes for an entity in one block and no // path walking outside of that block is possible if (ENTITY_CODECS.includes(cid.code)) { return { ipfsRoots: [cid], terminalElement: basicEntry('object', cid, bytes), blockstore } } // may be an unknown codec return { ipfsRoots: [cid], terminalElement: basicEntry('raw', cid, bytes), blockstore } } } function toIPFSPath (url: URL): string { return `/ipfs/${url.hostname}${decodeURI(url.pathname)}` }