UNPKG

@fuel-infrastructure/fuel-hyperlane-registry

Version:

A collection of configs, artifacts, and schemas for Hyperlane

200 lines (199 loc) 8.68 kB
import { parse as yamlParse } from 'yaml'; import { CHAIN_FILE_REGEX, DEFAULT_GITHUB_REGISTRY, GITHUB_FETCH_CONCURRENCY_LIMIT, WARP_ROUTE_CONFIG_FILE_REGEX, WARP_ROUTE_DEPLOY_FILE_REGEX, } from '../consts.js'; import { concurrentMap, parseGitHubPath, stripLeadingSlash } from '../utils.js'; import { BaseRegistry } from './BaseRegistry.js'; import { RegistryType, } from './IRegistry.js'; import { filterWarpRoutesIds, warpRouteConfigPathToId, warpRouteDeployConfigPathToId, } from './warp-utils.js'; export const GITHUB_API_URL = 'https://api.github.com'; export const GITHUB_API_VERSION = '2022-11-28'; /** * 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; authToken; baseApiHeaders = { 'X-GitHub-Api-Version': GITHUB_API_VERSION, }; constructor(options = {}) { super({ uri: options.uri ?? DEFAULT_GITHUB_REGISTRY, logger: options.logger }); this.url = new URL(this.uri); const { repoOwner, repoName, repoBranch } = parseGitHubPath(this.uri); this.repoOwner = repoOwner; this.repoName = repoName; if (options.branch && repoBranch) throw new Error('Branch is set in both options and url.'); this.branch = options.branch ?? repoBranch ?? 'main'; this.proxyUrl = options.proxyUrl; this.authToken = options.authToken; } 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 = {}; const warpDeployConfig = {}; 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); } if (WARP_ROUTE_DEPLOY_FILE_REGEX.test(node.path)) { const routeId = warpRouteDeployConfigPathToId(node.path); warpDeployConfig[routeId] = this.getRawContentUrl(node.path); } } return (this.listContentCache = { chains, deployments: { warpRoutes, warpDeployConfig } }); } 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 getWarpDeployConfig(routeId) { const repoContents = await this.listRegistryContent(); const routeConfigUrl = repoContents.deployments.warpDeployConfig[routeId]; if (!routeConfigUrl) return null; return this.fetchYamlFile(routeConfigUrl); } async getWarpRoutes(filter) { const { warpRoutes } = (await this.listRegistryContent()).deployments; const { ids: routeIds, values: routeConfigUrls } = filterWarpRoutesIds(warpRoutes, filter); return this.readConfigs(routeIds, routeConfigUrls); } async getWarpDeployConfigs(filter) { const { warpDeployConfig } = (await this.listRegistryContent()).deployments; const { ids: routeIds, values: routeConfigUrls } = filterWarpRoutesIds(warpDeployConfig, filter); return this.readConfigs(routeIds, routeConfigUrls); } async readConfigs(routeIds, routeConfigUrls) { 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 this.fetch(`${GITHUB_API_URL}/rate_limit`); 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 isProxiedRequest = this.proxyUrl && url.startsWith(this.proxyUrl); const headers = !isProxiedRequest && !!this.authToken ? { ...this.baseApiHeaders, Authorization: `Bearer ${this.authToken}` } : this.baseApiHeaders; const response = await fetch(url, { headers, }); if (!response.ok) throw new Error(`Failed to fetch from github: ${response.status} ${response.statusText}`); return response; } }