133 lines
3.7 KiB
TypeScript
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}`);
|
|
}
|
|
}
|
|
}
|