diff --git a/lib/ApiClient.ts b/lib/ApiClient.ts index 078f83c..2f33525 100644 --- a/lib/ApiClient.ts +++ b/lib/ApiClient.ts @@ -3,43 +3,66 @@ import { MessageStream, UhstRelayClient, } from './contracts/UhstRelayClient'; -import { HostConfiguration, ClientConfiguration, ApiResponse } from './models'; -import { NetworkClient } from './NetworkClient'; +import { HostConfiguration, ClientConfiguration } from './models'; +import { RelayUrlsProvider } from './RelayUrlsProvider'; import { RelayClient } from './RelayClient'; import { RelayClientProvider } from './RelayClientProvider'; - -const API_URL = 'https://api.uhst.io/v1/get-relay'; +import { InvalidHostId, RelayUnreachable } from './UhstErrors'; export class ApiClient implements UhstRelayClient { - networkClient: NetworkClient; + relayUrlsProvider: RelayUrlsProvider; relayClient: RelayClient; constructor( private relayClientProvider: RelayClientProvider, - networkClient?: NetworkClient + relayUrlsProvider?: RelayUrlsProvider ) { - this.networkClient = networkClient ?? new NetworkClient(); - } - - async getRelayUrl(hostId?: string): Promise { - const apiResponse: ApiResponse = await this.networkClient.post( - API_URL, - hostId ? [`hostId=${hostId}`] : undefined - ); - return apiResponse.url; + this.relayUrlsProvider = relayUrlsProvider ?? new RelayUrlsProvider(); } async initHost(hostId?: string): Promise { - this.relayClient = this.relayClientProvider.createRelayClient( - await this.getRelayUrl(hostId) - ); - return this.relayClient.initHost(hostId); + const relayUrl = await this.relayUrlsProvider.getBestRelayUrl(hostId); + let hostConfiguration: HostConfiguration; + let exception = new RelayUnreachable(); + try { + this.relayClient = this.relayClientProvider.createRelayClient(relayUrl); + hostConfiguration = await this.relayClient.initHost(hostId); + } catch (e) { + // handle network error here + exception = e; + } + return new Promise((resolve, reject) => { + if (hostConfiguration) { + resolve(hostConfiguration); + } else { + reject(exception); + } + }); } async initClient(hostId: string): Promise { - this.relayClient = this.relayClientProvider.createRelayClient( - await this.getRelayUrl(hostId) - ); - return this.relayClient.initClient(hostId); + const relayUrls = await this.relayUrlsProvider.getRelayUrls(hostId); + let clientConfiguration: ClientConfiguration; + let exception = new RelayUnreachable(); + for (const url of relayUrls) { + try { + this.relayClient = this.relayClientProvider.createRelayClient(url); + clientConfiguration = await this.relayClient.initClient(hostId); + break; + } catch (e) { + // handle network error here + exception = e; + if (e instanceof InvalidHostId) { + break; + } + } + } + return new Promise((resolve, reject) => { + if (clientConfiguration) { + resolve(clientConfiguration); + } else { + reject(exception); + } + }); } sendMessage(token: string, message: any, sendUrl?: string): Promise { diff --git a/lib/NetworkClient.ts b/lib/NetworkClient.ts index 4d1a4d2..0953624 100644 --- a/lib/NetworkClient.ts +++ b/lib/NetworkClient.ts @@ -1,16 +1,22 @@ import { NetworkError, NetworkUnreachable } from './UhstErrors'; const REQUEST_OPTIONS = { - method: 'POST' + method: 'POST', }; export class NetworkClient { - async post(url: string, queryParams?: string[]): Promise { + async post( + url: string, + queryParams?: string[], + timeout?: number + ): Promise { try { if (queryParams && queryParams.length > 0) { url = `${url}?${queryParams.join('&')}`; } - const response = await fetch(url, REQUEST_OPTIONS); + const response = timeout + ? await this.fetchWithTimeout(url, { ...REQUEST_OPTIONS, timeout }) + : await fetch(url, REQUEST_OPTIONS); if (response.status == 200) { return response.json(); } else { @@ -21,4 +27,42 @@ export class NetworkClient { throw new NetworkUnreachable(error); } } + + async get( + url: string, + queryParams?: string[], + timeout?: number + ): Promise { + try { + if (queryParams && queryParams.length > 0) { + url = `${url}?${queryParams.join('&')}`; + } + const response = timeout + ? await this.fetchWithTimeout(url, { timeout }) + : await fetch(url); + if (response.status == 200) { + return response.json(); + } else { + throw new NetworkError(response.status, `${response.statusText}`); + } + } catch (error) { + console.log(error); + throw new NetworkUnreachable(error); + } + } + + async fetchWithTimeout(resource, options): Promise { + const { timeout = 8000 } = options; + + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeout); + + const response = await fetch(resource, { + ...options, + signal: controller.signal, + }); + clearTimeout(id); + + return response; + } } diff --git a/lib/RelayUrlsProvider.ts b/lib/RelayUrlsProvider.ts new file mode 100644 index 0000000..65bf42f --- /dev/null +++ b/lib/RelayUrlsProvider.ts @@ -0,0 +1,63 @@ +import { Relay } from './models'; +import { NetworkClient } from './NetworkClient'; +import { RelayUnreachable } from './UhstErrors'; + +const RELAYS_LIST_URL = + 'https://raw.githubusercontent.com/uhst/relays/main/list.json'; + +export class RelayUrlsProvider { + networkClient: NetworkClient; + constructor(networkClient?: NetworkClient) { + this.networkClient = networkClient ?? new NetworkClient(); + } + async getRelayUrls(hostId?: string): Promise { + const relays: Relay[] = await this.networkClient.get(RELAYS_LIST_URL); + if (hostId) { + // if hostId get all URLs for the hostId + const prefix = hostId.split('-')[0]; + for (const relay of relays) { + if (relay.prefix === prefix) { + return relay.urls; + } + } + // there are no relays serving this prefix + return []; + } + // if no hostId, get all URLs + const urls: string[] = []; + relays.forEach((x) => x.urls.forEach((url) => urls.push(url))); + return urls; + } + + async getBestRelayUrl(hostId?: string): Promise { + const relayUrls = await this.getRelayUrls(hostId); + return new Promise((resolve, reject) => { + let resolved = false; + let failed = 0; + if (relayUrls.length === 0) { + reject(new RelayUnreachable()); + } else { + relayUrls + .sort(() => Math.random() - Math.random()) + .slice(0, Math.min(relayUrls.length, 10)) + .forEach(async (url) => { + try { + const response = await this.networkClient.post(url, [ + 'action=ping', + `timestamp=${Date.now()}`, + ]); + if (!resolved) { + resolve(url); + resolved = true; + } + } catch (e) { + failed++; + if (!resolved && failed == relayUrls.length) { + reject(e); + } + } + }); + } + }); + } +} diff --git a/lib/models/ApiResponse.ts b/lib/models/ApiResponse.ts deleted file mode 100644 index 7f1f557..0000000 --- a/lib/models/ApiResponse.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface ApiResponse { - url: string; -} \ No newline at end of file diff --git a/lib/models/Relay.ts b/lib/models/Relay.ts new file mode 100644 index 0000000..78abcd9 --- /dev/null +++ b/lib/models/Relay.ts @@ -0,0 +1,4 @@ +export interface Relay { + urls: string[], + prefix: string +} \ No newline at end of file diff --git a/lib/models/index.ts b/lib/models/index.ts index 4a9ce67..74d141b 100644 --- a/lib/models/index.ts +++ b/lib/models/index.ts @@ -1,4 +1,4 @@ -export * from './ApiResponse'; +export * from './Relay'; export * from './ClientConfiguration'; export * from './HostConfiguration'; export * from './HostMessage'; diff --git a/test/ApiClient.spec.ts b/test/ApiClient.spec.ts index 2f478a6..a4ac9a7 100644 --- a/test/ApiClient.spec.ts +++ b/test/ApiClient.spec.ts @@ -1,12 +1,12 @@ import sinonChai from 'sinon-chai'; import { expect, use } from 'chai'; import { describe } from 'mocha'; -import { RelayClientProvider, UHST } from '../lib'; -import { UhstSocketProvider } from '../lib/contracts/UhstSocketProvider'; +import { stub } from 'sinon'; +import { InvalidHostId, RelayClientProvider, RelayUnreachable } from '../lib'; import { ApiClient } from '../lib/ApiClient'; import { RelayClient } from '../lib/RelayClient'; -import { ApiResponse, ClientConfiguration, HostConfiguration } from '../lib/models'; -import { stub } from 'sinon'; +import { ClientConfiguration, HostConfiguration } from '../lib/models'; +import { RelayUrlsProvider } from '../lib/RelayUrlsProvider'; use(sinonChai); @@ -15,80 +15,104 @@ describe('# ApiClient', () => { const mockRelayClientProvider = {}; expect(new ApiClient(mockRelayClientProvider)).to.not.be.null; }); - it('should initHost without hostId', async () => { + it('should initHost when working relay is available', async () => { const mockRelayClientProvider = {}; const mockRelay = {}; const mockHostConfiguration = {}; - const mockApiResponse = { - url: 'testUrl', - }; - const mockNetworkClient = { - post: stub().returns(mockApiResponse), - }; + const mockRelayUrlsProvider = {}; mockRelayClientProvider.createRelayClient = stub().returns(mockRelay); + mockRelayUrlsProvider.getBestRelayUrl = stub().returns('testUrl'); mockRelay.initHost = stub().returns(mockHostConfiguration); const hostConfiguration = await new ApiClient( mockRelayClientProvider, - mockNetworkClient + mockRelayUrlsProvider ).initHost(); expect(hostConfiguration).to.equal(mockHostConfiguration); - expect(mockNetworkClient.post).to.have.been.calledWith( - 'https://api.uhst.io/v1/get-relay' - ); expect(mockRelayClientProvider.createRelayClient).to.have.been.calledWith( 'testUrl' ); }); - it('should initHost with hostId', async () => { + it('should throw RelayUnreachable when initHost and no relay is available', async () => { + const mockRelayClientProvider = {}; + const mockRelayUrlsProvider = {}; + mockRelayUrlsProvider.getBestRelayUrl = stub().throws(new RelayUnreachable()); + let exception: any; + try { + await new ApiClient( + mockRelayClientProvider, + mockRelayUrlsProvider + ).initHost(); + } catch (e) { + exception = e; + } + expect(exception).to.be.instanceOf(RelayUnreachable); + }); + it('should rethrow InvalidHostId when initHost', async () => { const mockRelayClientProvider = {}; const mockRelay = {}; - const mockHostConfiguration = {}; - const mockApiResponse = { - url: 'testUrlWithHostId', - }; - const mockNetworkClient = { - post: stub().returns(mockApiResponse), - }; + const mockRelayUrlsProvider = {}; mockRelayClientProvider.createRelayClient = stub().returns(mockRelay); - mockRelay.initHost = stub().returns(mockHostConfiguration); - const hostConfiguration = await new ApiClient( - mockRelayClientProvider, - mockNetworkClient - ).initHost('testHostId'); - expect(hostConfiguration).to.equal(mockHostConfiguration); - expect( - mockNetworkClient.post - ).to.have.been.calledWith('https://api.uhst.io/v1/get-relay', [ - 'hostId=testHostId', - ]); - expect(mockRelayClientProvider.createRelayClient).to.have.been.calledWith( - 'testUrlWithHostId' - ); + mockRelayUrlsProvider.getBestRelayUrl = stub().returns('testUrl'); + mockRelay.initHost = stub().throws(new InvalidHostId()); + let exception: any; + try { + await new ApiClient( + mockRelayClientProvider, + mockRelayUrlsProvider + ).initHost(); + } catch (e) { + exception = e; + } + expect(exception).to.be.instanceOf(InvalidHostId); }); - it('should initClient with hostId', async () => { + it('should initClient when working relay is available', async () => { const mockRelayClientProvider = {}; const mockRelay = {}; const mockClientConfiguration = {}; - const mockApiResponse = { - url: 'testUrlWithHostId', - }; - const mockNetworkClient = { - post: stub().returns(mockApiResponse), - }; + const mockRelayUrlsProvider = {}; mockRelayClientProvider.createRelayClient = stub().returns(mockRelay); + mockRelayUrlsProvider.getRelayUrls = stub().returns(['testUrl']); mockRelay.initClient = stub().returns(mockClientConfiguration); const clientConfiguration = await new ApiClient( mockRelayClientProvider, - mockNetworkClient + mockRelayUrlsProvider ).initClient('testHostId'); expect(clientConfiguration).to.equal(mockClientConfiguration); - expect( - mockNetworkClient.post - ).to.have.been.calledWith('https://api.uhst.io/v1/get-relay', [ - 'hostId=testHostId', - ]); expect(mockRelayClientProvider.createRelayClient).to.have.been.calledWith( - 'testUrlWithHostId' + 'testUrl' ); }); + it('should throw RelayUnreachable when initClient and no relay is available', async () => { + const mockRelayClientProvider = {}; + const mockRelayUrlsProvider = {}; + mockRelayUrlsProvider.getRelayUrls = stub().returns([]); + let exception: any; + try { + await new ApiClient( + mockRelayClientProvider, + mockRelayUrlsProvider + ).initClient('testHostId'); + } catch (e) { + exception = e; + } + expect(exception).to.be.instanceOf(RelayUnreachable); + }); + it('should rethrow InvalidHostId when initClient', async () => { + const mockRelayClientProvider = {}; + const mockRelay = {}; + const mockRelayUrlsProvider = {}; + mockRelayClientProvider.createRelayClient = stub().returns(mockRelay); + mockRelayUrlsProvider.getRelayUrls = stub().returns(['testUrl']); + mockRelay.initClient = stub().throws(new InvalidHostId()); + let exception: any; + try { + await new ApiClient( + mockRelayClientProvider, + mockRelayUrlsProvider + ).initClient('testHostId'); + } catch (e) { + exception = e; + } + expect(exception).to.be.instanceOf(InvalidHostId); + }); }); diff --git a/test/RelayUrlsProvider.spec.ts b/test/RelayUrlsProvider.spec.ts new file mode 100644 index 0000000..ff70a04 --- /dev/null +++ b/test/RelayUrlsProvider.spec.ts @@ -0,0 +1,52 @@ +import sinonChai from 'sinon-chai'; +import { expect, use } from 'chai'; +import { describe } from 'mocha'; +import { stub } from 'sinon'; +import { NetworkClient } from '../lib'; +import { RelayUrlsProvider } from '../lib/RelayUrlsProvider'; +import { Relay } from '../lib/models'; + +use(sinonChai); + +describe('# RelayUrlsProvider', () => { + it('should create RelayUrlsProvider', () => { + const mockNetworkClient = {}; + expect(new RelayUrlsProvider(mockNetworkClient)).to.not.be.null; + }); + it('should getRelayUrls when hostId is provided and prefix exists', async () => { + const urls = ['testUrl']; + const relayListResponse = [ + { + prefix: 'test', + urls, + }, + ]; + const mockNetworkClient = {}; + mockNetworkClient.get = stub().returns(relayListResponse); + const relayUrls = await new RelayUrlsProvider( + mockNetworkClient + ).getRelayUrls('test-1234'); + expect(mockNetworkClient.get).to.have.been.calledWith( + 'https://raw.githubusercontent.com/uhst/relays/main/list.json' + ); + expect(relayUrls).to.equal(urls); + }); + it('should return empty list when getRelayUrls and hostId prefix does not exist', async () => { + const urls = ['testUrl']; + const relayListResponse = [ + { + prefix: 'test', + urls, + }, + ]; + const mockNetworkClient = {}; + mockNetworkClient.get = stub().returns(relayListResponse); + const relayUrls = await new RelayUrlsProvider( + mockNetworkClient + ).getRelayUrls('hello'); + expect(mockNetworkClient.get).to.have.been.calledWith( + 'https://raw.githubusercontent.com/uhst/relays/main/list.json' + ); + expect(relayUrls.length).to.equal(0); + }); +});