Files
xo-stack/packages/stack/src/bcmr/bcmr-http.ts
T
2025-10-16 06:23:59 +00:00

133 lines
3.7 KiB
TypeScript

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<CachedMetadataRegistry>;
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<CachedMetadataRegistry>;
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<MetadataRegistry> {
// 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}`);
}
}
}