Skip to content

Commit

Permalink
Implement LocalRegistry and setup ESlint (#13)
Browse files Browse the repository at this point in the history
- Create new LocalRegistry class
- Add new export entry for local-related things
- Refactor unit tests to cover both registry types
- Setup ESLint 9
  • Loading branch information
jmrossy authored Apr 20, 2024
1 parent 551ce32 commit dc688ea
Show file tree
Hide file tree
Showing 18 changed files with 953 additions and 102 deletions.
5 changes: 5 additions & 0 deletions .changeset/quiet-ducks-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperlane-xyz/registry': patch
---

Create LocalRegistry class
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ jobs:
- name: build
run: yarn run build

lint:
runs-on: ubuntu-latest
needs: [install]
steps:
- uses: actions/checkout@v3
- uses: actions/cache@v3
with:
path: |
**/node_modules
.yarn/cache
key: ${{ runner.os }}-yarn-cache-${{ hashFiles('./yarn.lock') }}
- name: lint
run: yarn run lint

prettier:
runs-on: ubuntu-latest
needs: [install]
Expand Down
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"eslint.experimental.useFlatConfig": true
}
38 changes: 38 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import tsparser from '@typescript-eslint/parser';

export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended, {
files: ['src/**/*.ts'],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
project: './tsconfig.json',
},
},
rules: {
'no-console': ['error'],
'no-eval': ['error'],
'no-extra-boolean-cast': ['error'],
'no-ex-assign': ['error'],
'no-constant-condition': ['off'],
'guard-for-in': ['error'],
'@typescript-eslint/ban-ts-comment': ['off'],
'@typescript-eslint/explicit-module-boundary-types': ['off'],
'@typescript-eslint/no-explicit-any': ['off'],
'@typescript-eslint/no-floating-promises': ['error'],
'@typescript-eslint/no-non-null-assertion': ['off'],
'@typescript-eslint/no-require-imports': ['warn'],
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
ignores: ['node_modules', 'dist', 'tmp'],
});
15 changes: 11 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,34 @@
"description": "A collection of configs, artifacts, and schemas for Hyperlane",
"version": "1.0.3",
"dependencies": {
"yaml": "^2"
"yaml": "^2",
"zod": "^3.21.2"
},
"devDependencies": {
"@changesets/cli": "^2.26.2",
"@eslint/js": "^9.1.1",
"@hyperlane-xyz/sdk": "3.10.0",
"@types/mocha": "^10.0.1",
"@types/node": "^16.9.1",
"@typescript-eslint/parser": "^7.7.0",
"chai": "^4.3.6",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"mocha": "^10.2.0",
"prettier": "^2.8.8",
"tsx": "^4.7.1",
"typescript": "5.3.3",
"zod": "^3.21.2",
"typescript-eslint": "^7.7.0",
"zod-to-json-schema": "^3.22.5"
},
"type": "module",
"exports": {
".": "./dist/index.js",
"./chains/schema.json": "./dist/chains/schema.json",
"./chains/*": "./dist/chains/*"
"./chains/*": "./dist/chains/*",
"./local": "./dist/index-local.js"
},
"types": "./dist/index.d.ts",
"typesVersions": {
Expand Down Expand Up @@ -52,7 +58,8 @@
"license": "MIT",
"scripts": {
"clean": "rm -rf ./dist ./tmp",
"build": "tsx ./scripts/build.ts && tsc",
"build": "tsx ./scripts/build.ts && tsc --project tsconfig.publish.json",
"lint": "eslint ./src/",
"prettier": "prettier --write ./chains ./deployments",
"test:unit": "yarn build && mocha --config .mocharc.json './test/unit/*.test.ts' --exit",
"test:health": "yarn build && mocha --config .mocharc.json './test/health/*.test.ts' --exit",
Expand Down
2 changes: 2 additions & 0 deletions src/index-local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Exports for utilities that require fs access and are not suitable for browser use
export { LocalRegistry } from './registry/LocalRegistry.js';
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export { CoreChain, CoreChainName, CoreChains, CoreMainnets, CoreTestnets } from

export { BaseRegistry } from './registry/BaseRegistry.js';
export { GithubRegistry, GithubRegistryOptions } from './registry/GithubRegistry.js';
export { ChainAddresses, ChainFiles, IRegistry, RegistryContent } from './registry/IRegistry.js';
export { ChainFiles, IRegistry, RegistryContent, RegistryType } from './registry/IRegistry.js';
export { ChainAddresses, ChainAddressesSchema } from './types.js';
18 changes: 11 additions & 7 deletions src/registry/BaseRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import type { Logger } from 'pino';

import type { ChainMap, ChainMetadata, ChainName } from '@hyperlane-xyz/sdk';
import type { ChainAddresses, IRegistry, RegistryContent } from './IRegistry.js';
import { ChainAddresses, MaybePromise } from '../types.js';
import type { IRegistry, RegistryContent, RegistryType } from './IRegistry.js';

export const CHAIN_FILE_REGEX = /chains\/([a-z]+)\/([a-z]+)\.yaml/;

export abstract class BaseRegistry implements IRegistry {
abstract type: RegistryType;
protected readonly logger: Logger;

// Caches
Expand All @@ -26,10 +30,10 @@ export abstract class BaseRegistry implements IRegistry {
return 'deployments/warp_routes';
}

abstract listRegistryContent(): Promise<RegistryContent>;
abstract getChains(): Promise<Array<ChainName>>;
abstract getMetadata(): Promise<ChainMap<ChainMetadata>>;
abstract getChainMetadata(chainName: ChainName): Promise<ChainMetadata | null>;
abstract getAddresses(): Promise<ChainMap<ChainAddresses>>;
abstract getChainAddresses(chainName: ChainName): Promise<ChainAddresses | null>;
abstract listRegistryContent(): MaybePromise<RegistryContent>;
abstract getChains(): MaybePromise<Array<ChainName>>;
abstract getMetadata(): MaybePromise<ChainMap<ChainMetadata>>;
abstract getChainMetadata(chainName: ChainName): MaybePromise<ChainMetadata | null>;
abstract getAddresses(): MaybePromise<ChainMap<ChainAddresses>>;
abstract getChainAddresses(chainName: ChainName): MaybePromise<ChainAddresses | null>;
}
36 changes: 23 additions & 13 deletions src/registry/GithubRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import type { Logger } from 'pino';
import { parse } from 'yaml';
import { parse as yamlParse } from 'yaml';

import type { ChainMap, ChainMetadata, ChainName } from '@hyperlane-xyz/sdk';
import { type ChainMap, type ChainMetadata, type ChainName } from '@hyperlane-xyz/sdk';

import { BaseRegistry } from './BaseRegistry.js';
import type { ChainAddresses, ChainFiles, IRegistry, RegistryContent } from './IRegistry.js';
import { ChainAddresses, ChainAddressesSchema } from '../types.js';
import { BaseRegistry, CHAIN_FILE_REGEX } from './BaseRegistry.js';
import {
RegistryType,
type ChainFiles,
type IRegistry,
type RegistryContent,
} from './IRegistry.js';

const DEFAULT_REGISTRY = 'https://github.com/hyperlane-xyz/hyperlane-registry';
const CHAIN_FILE_REGEX = /chains\/([a-z]+)\/([a-z]+)\.yaml/;

export interface GithubRegistryOptions {
url?: string;
uri?: string;
branch?: string;
authToken?: string;
logger?: Logger;
Expand All @@ -25,14 +30,15 @@ interface TreeNode {
}

export class GithubRegistry extends BaseRegistry implements IRegistry {
public readonly type = RegistryType.Github;
public readonly url: URL;
public readonly branch: string;
public readonly repoOwner: string;
public readonly repoName: string;

constructor(options: GithubRegistryOptions = {}) {
super({ logger: options.logger });
this.url = new URL(options.url ?? DEFAULT_REGISTRY);
this.url = new URL(options.uri ?? DEFAULT_REGISTRY);
this.branch = options.branch ?? 'main';
const pathSegments = this.url.pathname.split('/');
if (pathSegments.length < 2) throw new Error('Invalid github url');
Expand Down Expand Up @@ -77,11 +83,12 @@ export class GithubRegistry extends BaseRegistry implements IRegistry {
if (this.metadataCache) return this.metadataCache;
const chainMetadata: ChainMap<ChainMetadata> = {};
const repoContents = await this.listRegistryContent();
// TODO use concurrentMap here when utils package is updated
for (const [chainName, chainFiles] of Object.entries(repoContents.chains)) {
if (!chainFiles.metadata) continue;
const response = await this.fetch(chainFiles.metadata);
const metadata = parse(await response.text()) as ChainMetadata;
chainMetadata[chainName] = metadata;
const data = await response.text();
chainMetadata[chainName] = yamlParse(data);
}
return (this.metadataCache = chainMetadata);
}
Expand All @@ -90,18 +97,20 @@ export class GithubRegistry extends BaseRegistry implements IRegistry {
if (this.metadataCache?.[chainName]) return this.metadataCache[chainName];
const url = this.getRawContentUrl(`${this.getChainsPath()}/${chainName}/metadata.yaml`);
const response = await this.fetch(url);
return parse(await response.text()) as ChainMetadata;
const data = await response.text();
return yamlParse(data);
}

async getAddresses(): Promise<ChainMap<ChainAddresses>> {
if (this.addressCache) return this.addressCache;
const chainAddresses: ChainMap<ChainAddresses> = {};
const repoContents = await this.listRegistryContent();
// TODO use concurrentMap here when utils package is updated
for (const [chainName, chainFiles] of Object.entries(repoContents.chains)) {
if (!chainFiles.addresses) continue;
const response = await this.fetch(chainFiles.addresses);
const addresses = parse(await response.text()) as ChainAddresses;
chainAddresses[chainName] = addresses;
const data = await response.text();
chainAddresses[chainName] = ChainAddressesSchema.parse(yamlParse(data));
}
return (this.addressCache = chainAddresses);
}
Expand All @@ -110,7 +119,8 @@ export class GithubRegistry extends BaseRegistry implements IRegistry {
if (this.addressCache?.[chainName]) return this.addressCache[chainName];
const url = this.getRawContentUrl(`${this.getChainsPath()}/${chainName}/addresses.yaml`);
const response = await this.fetch(url);
return parse(await response.text()) as ChainAddresses;
const data = await response.text();
return ChainAddressesSchema.parse(yamlParse(data));
}

protected getRawContentUrl(path: string): string {
Expand Down
21 changes: 12 additions & 9 deletions src/registry/IRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Address } from '@hyperlane-xyz/utils';

import type { ChainMap, ChainMetadata, ChainName } from '@hyperlane-xyz/sdk';
import { ChainAddresses, MaybePromise } from '../types.js';

export interface ChainFiles {
metadata?: string;
Expand All @@ -14,14 +13,18 @@ export interface RegistryContent {
};
}

export type ChainAddresses = Record<string, Address>;
export enum RegistryType {
Github = 'github',
Local = 'local',
}

export interface IRegistry {
listRegistryContent(): Promise<RegistryContent>;
getChains(): Promise<Array<ChainName>>;
getMetadata(): Promise<ChainMap<ChainMetadata>>;
getChainMetadata(chainName: ChainName): Promise<ChainMetadata | null>;
getAddresses(): Promise<ChainMap<ChainAddresses>>;
getChainAddresses(chainName: ChainName): Promise<ChainAddresses | null>;
type: RegistryType;
listRegistryContent(): MaybePromise<RegistryContent>;
getChains(): MaybePromise<Array<ChainName>>;
getMetadata(): MaybePromise<ChainMap<ChainMetadata>>;
getChainMetadata(chainName: ChainName): MaybePromise<ChainMetadata | null>;
getAddresses(): MaybePromise<ChainMap<ChainAddresses>>;
getChainAddresses(chainName: ChainName): MaybePromise<ChainAddresses | null>;
// TODO: Define write-related methods
}
102 changes: 102 additions & 0 deletions src/registry/LocalRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import fs from 'fs';
import path from 'path';
import type { Logger } from 'pino';
import { parse as yamlParse } from 'yaml';

import { type ChainMap, type ChainMetadata, type ChainName } from '@hyperlane-xyz/sdk';

import { ChainAddresses, ChainAddressesSchema } from '../types.js';
import { BaseRegistry, CHAIN_FILE_REGEX } from './BaseRegistry.js';
import {
RegistryType,
type ChainFiles,
type IRegistry,
type RegistryContent,
} from './IRegistry.js';

export interface LocalRegistryOptions {
uri: string;
logger?: Logger;
}

export class LocalRegistry extends BaseRegistry implements IRegistry {
public readonly type = RegistryType.Local;
public readonly uri: string;

constructor(options: LocalRegistryOptions) {
super({ logger: options.logger });
this.uri = options.uri;
}

listRegistryContent(): RegistryContent {
if (this.listContentCache) return this.listContentCache;

const chainFileList = this.listFiles(path.join(this.uri, this.getChainsPath()));
const chains: ChainMap<ChainFiles> = {};
for (const chainFilePath of chainFileList) {
const matches = chainFilePath.match(CHAIN_FILE_REGEX);
if (!matches) continue;
const [_, chainName, fileName] = matches;
chains[chainName] ??= {};
// @ts-ignore allow dynamic key assignment
chains[chainName][fileName] = chainFilePath;
}

// TODO add handling for deployment artifact files here too

return (this.listContentCache = { chains, deployments: {} });
}

getChains(): Array<ChainName> {
return Object.keys(this.listRegistryContent().chains);
}

getMetadata(): ChainMap<ChainMetadata> {
if (this.metadataCache) return this.metadataCache;
const chainMetadata: ChainMap<ChainMetadata> = {};
const repoContents = this.listRegistryContent();
for (const [chainName, chainFiles] of Object.entries(repoContents.chains)) {
if (!chainFiles.metadata) continue;
const data = fs.readFileSync(chainFiles.metadata, 'utf8');
chainMetadata[chainName] = yamlParse(data);
}
return (this.metadataCache = chainMetadata);
}

getChainMetadata(chainName: ChainName): ChainMetadata {
const metadata = this.getMetadata();
if (!metadata[chainName])
throw new Error(`Metadata not found in registry for chain: ${chainName}`);
return metadata[chainName];
}

getAddresses(): ChainMap<ChainAddresses> {
if (this.addressCache) return this.addressCache;
const chainAddresses: ChainMap<ChainAddresses> = {};
const repoContents = this.listRegistryContent();
for (const [chainName, chainFiles] of Object.entries(repoContents.chains)) {
if (!chainFiles.addresses) continue;
const data = fs.readFileSync(chainFiles.addresses, 'utf8');
chainAddresses[chainName] = ChainAddressesSchema.parse(yamlParse(data));
}
return (this.addressCache = chainAddresses);
}

getChainAddresses(chainName: ChainName): ChainAddresses {
const addresses = this.getAddresses();
if (!addresses[chainName])
throw new Error(`Addresses not found in registry for chain: ${chainName}`);
return addresses[chainName];
}

protected listFiles(dirPath: string): string[] {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });

const filePaths = entries.map((entry) => {
const fullPath = path.join(dirPath, entry.name);
return entry.isDirectory() ? this.listFiles(fullPath) : fullPath;
});

return filePaths.flat();
}
}
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from 'zod';

// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-5.html#the-awaited-type-and-promise-improvements
export type MaybePromise<T> = T | Promise<T> | PromiseLike<T>;

export const ChainAddressesSchema = z.record(z.string());
export type ChainAddresses = z.infer<typeof ChainAddressesSchema>;
Loading

0 comments on commit dc688ea

Please sign in to comment.