import { BaseBCMR } from './base-bcmr.js'; import { MetadataRegistry, MetadataRegistrySchema } from './bcmr-helpers.js'; import { type BaseStore, StoreInMemory } from '../storage/index.js'; export type BCMRHttpOpts = { store?: BaseStore; indexerUri: string; expireCacheAfterSeconds?: number; endpointEndsWithDotJson?: boolean; }; type CachedMetadataRegistry = { timestampInSeconds: number; metadataRegistry: MetadataRegistry; }; // The proposal is to expire cache after 7 days. // https://github.com/bitjson/chip-bcmr?tab=readme-ov-file#well-known-uri const DEFAULT_EXPIRE_CACHE_AFTER_SECONDS = 60 * 60 * 24 * 7; // Define the options for our POST request. const fetchOptions = { method: 'GET', cache: 'no-cache' as const, headers: { 'Content-Type': 'application/json' }, }; export class BCMRHttp extends BaseBCMR { // Options private store: BaseStore; private cacheExpirationInSeconds: number; private indexerUri: string; private endpointEndsWithDotJson: boolean; constructor(opts: BCMRHttpOpts) { // Check that given indexer url is valid. BCMRHttp.isUrl(opts.indexerUri); super(); // Set the store, or use the memory store by default. this.store = opts.store ?? new StoreInMemory(); this.cacheExpirationInSeconds = opts.expireCacheAfterSeconds ?? DEFAULT_EXPIRE_CACHE_AFTER_SECONDS; const endChar = opts.indexerUri.slice(-1) === '/' ? '' : '/'; this.indexerUri = `${opts.indexerUri}${endChar}`; this.endpointEndsWithDotJson = Boolean(opts.endpointEndsWithDotJson); } async getTokenData(categoryId: string) { // Define the cache key. const cacheKey = `tokenMetadata.${categoryId}`; const cachedTokenMetadata = await this.store.get(cacheKey); if (cachedTokenMetadata) { // Check if cache is not expired if ( Date.now() < (cachedTokenMetadata.timestampInSeconds + this.cacheExpirationInSeconds) * 1000 ) return cachedTokenMetadata.metadataRegistry; // Otherwise if expired... await this.store.delete(cacheKey); } try { const metadataRegistry = await this.fetchMetadata(categoryId); const cachedMetadataRegistry: CachedMetadataRegistry = { timestampInSeconds: Date.now() / 1000, metadataRegistry, }; // Cache token metadata. await this.store.set(cacheKey, cachedMetadataRegistry); return metadataRegistry; } catch (error: unknown) { throw new Error( `Error trying to fetch token metadata. Detailed error: ${error}`, ); } } /** * Try to fetch the metadata of a given token 'categoryId' using the given indexer. * * @throws {Error} If the fetch fails. * @throws {Error} If the response fails to be parsed. */ private async fetchMetadata(categoryId: string): Promise { // Prepare the uri to fetch the token metadata. const uri = `${this.indexerUri}${categoryId}${this.endpointEndsWithDotJson ? '.json' : ''}`; const response = await fetch(uri, fetchOptions); // If the request was not successful... if (!response.ok) throw new Error( `Error trying to fetch token (${categoryId}) metadata at ${uri}`, ); // Validate the payload body. try { const metadata = MetadataRegistrySchema.parse(await response.json()); return metadata; } catch (error: unknown) { throw new Error( `Unable to parse the metadata registry format. Detailed error: ${error}`, ); } } static isUrl(value: string): boolean { // Validate as URL using try-catch try { return Boolean(new URL(value)); } catch (error: unknown) { throw new Error(`Invalid URL. Detailed error: ${error}`); } } }