UNPKG

@artela-network/registry

Version:

A collection of configs, artifacts, and schemas for Hyperlane

172 lines (171 loc) 7.18 kB
import { parse as yamlParse } from 'yaml'; import { CHAIN_FILE_REGEX, DEFAULT_GITHUB_REGISTRY, GITHUB_FETCH_CONCURRENCY_LIMIT, WARP_ROUTE_CONFIG_FILE_REGEX, } from '../consts.js'; import { concurrentMap, stripLeadingSlash } from '../utils.js'; import { BaseRegistry } from './BaseRegistry.js'; import { RegistryType, } from './IRegistry.js'; import { filterWarpRoutesIds, warpRouteConfigPathToId } from './warp-utils.js'; export const GITHUB_API_URL = 'https://api.github.com'; /** * A registry that uses a github repository as its data source. * Reads are performed via the github API and github's raw content URLs. * Writes are not yet supported (TODO) */ export class GithubRegistry extends BaseRegistry { type = RegistryType.Github; url; branch; repoOwner; repoName; proxyUrl; constructor(options = {}) { super({ uri: options.uri ?? DEFAULT_GITHUB_REGISTRY, logger: options.logger }); this.url = new URL(this.uri); this.branch = options.branch ?? 'main'; const pathSegments = this.url.pathname.split('/'); if (pathSegments.length < 2) throw new Error('Invalid github url'); this.repoOwner = pathSegments.at(-2); this.repoName = pathSegments.at(-1); this.proxyUrl = options.proxyUrl; } getUri(itemPath) { if (!itemPath) return super.getUri(); return this.getRawContentUrl(itemPath); } async listRegistryContent() { if (this.listContentCache) return this.listContentCache; // This uses the tree API instead of the simpler directory list API because it // allows us to get a full view of all files in one request. const apiUrl = await this.getApiUrl(); const response = await this.fetch(apiUrl); const result = await response.json(); const tree = result.tree; const chainPath = this.getChainsPath(); const chains = {}; const warpRoutes = {}; for (const node of tree) { if (CHAIN_FILE_REGEX.test(node.path)) { const [_, chainName, fileName, extension] = node.path.match(CHAIN_FILE_REGEX); chains[chainName] ??= {}; // @ts-ignore allow dynamic key assignment chains[chainName][fileName] = this.getRawContentUrl(`${chainPath}/${chainName}/${fileName}.${extension}`); } if (WARP_ROUTE_CONFIG_FILE_REGEX.test(node.path)) { const routeId = warpRouteConfigPathToId(node.path); warpRoutes[routeId] = this.getRawContentUrl(node.path); } } return (this.listContentCache = { chains, deployments: { warpRoutes } }); } async getChains() { const repoContents = await this.listRegistryContent(); return Object.keys(repoContents.chains); } async getMetadata() { if (this.metadataCache && this.isMetadataCacheFull) return this.metadataCache; const combinedDataUrl = this.getRawContentUrl(`${this.getChainsPath()}/metadata.yaml`); const chainMetadata = await this.fetchYamlFile(combinedDataUrl); this.isMetadataCacheFull = true; return (this.metadataCache = chainMetadata); } async getChainMetadata(chainName) { if (this.metadataCache?.[chainName]) return this.metadataCache[chainName]; const data = await this.fetchChainFile('metadata', chainName); if (!data) return null; this.metadataCache = { ...this.metadataCache, [chainName]: data }; return data; } async getAddresses() { if (this.addressCache && this.isAddressCacheFull) return this.addressCache; const combinedDataUrl = this.getRawContentUrl(`${this.getChainsPath()}/addresses.yaml`); const chainAddresses = await this.fetchYamlFile(combinedDataUrl); this.isAddressCacheFull = true; return (this.addressCache = chainAddresses); } async getChainAddresses(chainName) { if (this.addressCache?.[chainName]) return this.addressCache[chainName]; const data = await this.fetchChainFile('addresses', chainName); if (!data) return null; this.addressCache = { ...this.addressCache, [chainName]: data }; return data; } async addChain(_chains) { throw new Error('TODO: Implement'); } async updateChain(_chains) { throw new Error('TODO: Implement'); } async removeChain(_chains) { throw new Error('TODO: Implement'); } async getWarpRoute(routeId) { const repoContents = await this.listRegistryContent(); const routeConfigUrl = repoContents.deployments.warpRoutes[routeId]; if (!routeConfigUrl) return null; return this.fetchYamlFile(routeConfigUrl); } async getWarpRoutes(filter) { const warpRoutes = (await this.listRegistryContent()).deployments.warpRoutes; const { ids: routeIds, values: routeConfigUrls } = filterWarpRoutesIds(warpRoutes, filter); const configs = await this.fetchYamlFiles(routeConfigUrls); const idsWithConfigs = routeIds.map((id, i) => [id, configs[i]]); return Object.fromEntries(idsWithConfigs); } async addWarpRoute(_config) { throw new Error('TODO: Implement'); } async getApiUrl() { const { remaining, reset } = await this.getApiRateLimit(); let apiHost = GITHUB_API_URL; if (remaining === 0) { if (!this.proxyUrl) throw new Error(`Github API rate remaining: ${remaining}, limit reset at ${reset}.`); apiHost = this.proxyUrl; } return `${apiHost}/repos/${this.repoOwner}/${this.repoName}/git/trees/${this.branch}?recursive=true`; } async getApiRateLimit() { const response = await fetch(`${GITHUB_API_URL}/rate_limit`, { headers: { 'X-GitHub-Api-Version': '2022-11-28' } }); const { resources } = await response.json(); return resources.core; } getRawContentUrl(path) { path = stripLeadingSlash(path); return `https://raw.githubusercontent.com/${this.repoOwner}/${this.repoName}/${this.branch}/${path}`; } async fetchChainFile(fileName, chainName) { const repoContents = await this.listRegistryContent(); const fileUrl = repoContents.chains[chainName]?.[fileName]; if (!fileUrl) return null; return this.fetchYamlFile(fileUrl); } fetchYamlFiles(urls) { return concurrentMap(GITHUB_FETCH_CONCURRENCY_LIMIT, urls, (url) => this.fetchYamlFile(url)); } async fetchYamlFile(url) { const response = await this.fetch(url); const data = await response.text(); return yamlParse(data); } async fetch(url) { this.logger.debug(`Fetching from github: ${url}`); const response = await fetch(url); if (!response.ok) throw new Error(`Failed to fetch from github: ${response.status} ${response.statusText}`); return response; } }