diff --git a/libs/environment/src/components/node-switcher/row-data.spec.tsx b/libs/environment/src/components/node-switcher/row-data.spec.tsx index ba58feb57..fea946089 100644 --- a/libs/environment/src/components/node-switcher/row-data.spec.tsx +++ b/libs/environment/src/components/node-switcher/row-data.spec.tsx @@ -23,7 +23,6 @@ import { SUBSCRIPTION_TIMEOUT, useNodeBasicStatus, useNodeSubscriptionStatus, - useResponseTime, } from './row-data'; import { BLOCK_THRESHOLD, RowData } from './row-data'; import { CUSTOM_NODE_KEY } from '../../types'; @@ -162,19 +161,6 @@ describe('useNodeBasicStatus', () => { }); }); -describe('useResponseTime', () => { - it('returns response time when url is valid', () => { - const { result } = renderHook(() => - useResponseTime('https://localhost:1234') - ); - expect(result.current.responseTime).toBe(50); - }); - it('does not return response time when url is invalid', () => { - const { result } = renderHook(() => useResponseTime('nope')); - expect(result.current.responseTime).toBeUndefined(); - }); -}); - describe('RowData', () => { const props = { id: '0', diff --git a/libs/environment/src/components/node-switcher/row-data.tsx b/libs/environment/src/components/node-switcher/row-data.tsx index 6680e8aa0..f4dca0d49 100644 --- a/libs/environment/src/components/node-switcher/row-data.tsx +++ b/libs/environment/src/components/node-switcher/row-data.tsx @@ -1,4 +1,3 @@ -import { isValidUrl } from '@vegaprotocol/utils'; import { TradingRadio } from '@vegaprotocol/ui-toolkit'; import { useEffect, useState } from 'react'; import { CUSTOM_NODE_KEY } from '../../types'; @@ -8,6 +7,7 @@ import { } from '../../utils/__generated__/NodeCheck'; import { LayoutCell } from './layout-cell'; import { useT } from '../../use-t'; +import { useResponseTime } from '../../utils/time'; export const POLL_INTERVAL = 1000; export const SUBSCRIPTION_TIMEOUT = 3000; @@ -108,20 +108,6 @@ export const useNodeBasicStatus = () => { }; }; -export const useResponseTime = (url: string, trigger?: unknown) => { - const [responseTime, setResponseTime] = useState(); - useEffect(() => { - if (!isValidUrl(url)) return; - if (typeof window.performance.getEntriesByName !== 'function') return; // protection for test environment - const requestUrl = new URL(url); - const requests = window.performance.getEntriesByName(requestUrl.href); - const { duration } = - (requests.length && requests[requests.length - 1]) || {}; - setResponseTime(duration); - }, [url, trigger]); - return { responseTime }; -}; - export const RowData = ({ id, url, diff --git a/libs/environment/src/hooks/use-environment.spec.ts b/libs/environment/src/hooks/use-environment.spec.ts index cec5fc598..1e3ab9e03 100644 --- a/libs/environment/src/hooks/use-environment.spec.ts +++ b/libs/environment/src/hooks/use-environment.spec.ts @@ -10,6 +10,7 @@ import { getUserEnabledFeatureFlags, setUserEnabledFeatureFlag, } from './use-environment'; +import { canMeasureResponseTime, measureResponseTime } from '../utils/time'; const noop = () => { /* no op*/ @@ -17,6 +18,10 @@ const noop = () => { jest.mock('@vegaprotocol/apollo-client'); jest.mock('zustand'); +jest.mock('../utils/time'); + +const mockCanMeasureResponseTime = canMeasureResponseTime as jest.Mock; +const mockMeasureResponseTime = measureResponseTime as jest.Mock; const mockCreateClient = createClient as jest.Mock; const createDefaultMockClient = () => { @@ -155,6 +160,14 @@ describe('useEnvironment', () => { const fastNode = 'https://api.n01.foo.vega.xyz'; const fastWait = 1000; const nodes = [slowNode, fastNode]; + + mockCanMeasureResponseTime.mockImplementation(() => true); + mockMeasureResponseTime.mockImplementation((url: string) => { + if (url === slowNode) return slowWait; + if (url === fastNode) return fastWait; + return Infinity; + }); + // @ts-ignore: typscript doesn't recognise the mock implementation global.fetch.mockImplementation(setupFetch({ hosts: nodes })); @@ -168,7 +181,7 @@ describe('useEnvironment', () => { statistics: { chainId: 'chain-id', blockHeight: '100', - vegaTime: new Date().toISOString(), + vegaTime: new Date(1).toISOString(), }, }, }); @@ -196,7 +209,8 @@ describe('useEnvironment', () => { expect(result.current.nodes).toEqual(nodes); }); - jest.runAllTimers(); + jest.advanceTimersByTime(2000); + // jest.runAllTimers(); await waitFor(() => { expect(result.current.status).toEqual('success'); diff --git a/libs/environment/src/hooks/use-environment.ts b/libs/environment/src/hooks/use-environment.ts index 893ac5f1a..e79b7b779 100644 --- a/libs/environment/src/hooks/use-environment.ts +++ b/libs/environment/src/hooks/use-environment.ts @@ -19,6 +19,9 @@ import { compileErrors } from '../utils/compile-errors'; import { envSchema } from '../utils/validate-environment'; import { tomlConfigSchema } from '../utils/validate-configuration'; import uniq from 'lodash/uniq'; +import orderBy from 'lodash/orderBy'; +import first from 'lodash/first'; +import { canMeasureResponseTime, measureResponseTime } from '../utils/time'; type Client = ReturnType; type ClientCollection = { @@ -38,8 +41,17 @@ export type EnvStore = Env & Actions; const VERSION = 1; export const STORAGE_KEY = `vega_url_${VERSION}`; + +const QUERY_TIMEOUT = 3000; const SUBSCRIPTION_TIMEOUT = 3000; +const raceAgainst = (timeout: number): Promise => + new Promise((resolve) => { + setTimeout(() => { + resolve(false); + }, timeout); + }); + /** * Fetch and validate a vega node configuration */ @@ -64,53 +76,88 @@ const fetchConfig = async (url?: string) => { const findNode = async (clients: ClientCollection): Promise => { const tests = Object.entries(clients).map((args) => testNode(...args)); try { - const url = await Promise.any(tests); - return url; - } catch { + const nodes = await Promise.all(tests); + const responsiveNodes = nodes + .filter(([, q, s]) => q && s) + .map(([url, q]) => { + return { + url, + ...q, + }; + }); + + // more recent and faster at the top + const ordered = orderBy( + responsiveNodes, + [(n) => n.blockHeight, (n) => n.vegaTime, (n) => n.responseTime], + ['desc', 'desc', 'asc'] + ); + + const best = first(ordered); + return best ? best.url : null; + } catch (err) { // All tests rejected, no suitable node found return null; } }; +type Maybe = T | false; +type QueryTestResult = { + blockHeight: number; + vegaTime: Date; + responseTime: number; +}; +type SubscriptionTestResult = true; +type NodeTestResult = [ + /** url */ + string, + Maybe, + Maybe +]; /** * Test a node for suitability for connection */ const testNode = async ( url: string, client: Client -): Promise => { +): Promise => { const results = await Promise.all([ - // these promises will only resolve with true/false - testQuery(client), + testQuery(client, url), testSubscription(client), ]); - if (results[0] && results[1]) { - return url; - } - - const message = `Tests failed for node: ${url}`; - console.warn(message); - - // throwing here will mean this tests is ignored and a different - // node that hopefully does resolve will fulfill the Promise.any - throw new Error(message); + return [url, ...results]; }; /** * Run a test query on a client */ -const testQuery = async (client: Client) => { - try { - const result = await client.query({ - query: NodeCheckDocument, - }); - if (!result || result.error) { - return false; - } - return true; - } catch (err) { - return false; - } +const testQuery = ( + client: Client, + url: string +): Promise> => { + const test: Promise> = new Promise((resolve) => + client + .query({ + query: NodeCheckDocument, + }) + .then((result) => { + if (result && !result.error) { + const res = { + blockHeight: Number(result.data.statistics.blockHeight), + vegaTime: new Date(result.data.statistics.vegaTime), + // only after a request has been sent we can retrieve the response time + responseTime: canMeasureResponseTime(url) + ? measureResponseTime(url) || Infinity + : Infinity, + } as QueryTestResult; + resolve(res); + } else { + resolve(false); + } + }) + .catch(() => resolve(false)) + ); + return Promise.race([test, raceAgainst(QUERY_TIMEOUT)]); }; /** @@ -118,7 +165,9 @@ const testQuery = async (client: Client) => { * that takes longer than SUBSCRIPTION_TIMEOUT ms to respond * is deemed a failure */ -const testSubscription = (client: Client) => { +const testSubscription = ( + client: Client +): Promise> => { return new Promise((resolve) => { const sub = client .subscribe({ diff --git a/libs/environment/src/utils/time.spec.ts b/libs/environment/src/utils/time.spec.ts new file mode 100644 index 000000000..9d56c52b6 --- /dev/null +++ b/libs/environment/src/utils/time.spec.ts @@ -0,0 +1,22 @@ +import { renderHook } from '@testing-library/react'; +import { useResponseTime } from './time'; + +const mockResponseTime = 50; +global.performance.getEntriesByName = jest.fn().mockReturnValue([ + { + duration: mockResponseTime, + }, +]); + +describe('useResponseTime', () => { + it('returns response time when url is valid', () => { + const { result } = renderHook(() => + useResponseTime('https://localhost:1234') + ); + expect(result.current.responseTime).toBe(50); + }); + it('does not return response time when url is invalid', () => { + const { result } = renderHook(() => useResponseTime('nope')); + expect(result.current.responseTime).toBeUndefined(); + }); +}); diff --git a/libs/environment/src/utils/time.ts b/libs/environment/src/utils/time.ts new file mode 100644 index 000000000..6e135eab9 --- /dev/null +++ b/libs/environment/src/utils/time.ts @@ -0,0 +1,25 @@ +import { isValidUrl } from '@vegaprotocol/utils'; +import { useEffect, useState } from 'react'; + +export const useResponseTime = (url: string, trigger?: unknown) => { + const [responseTime, setResponseTime] = useState(); + useEffect(() => { + if (!canMeasureResponseTime(url)) return; + const duration = measureResponseTime(url); + setResponseTime(duration); + }, [url, trigger]); + return { responseTime }; +}; + +export const canMeasureResponseTime = (url: string) => { + if (!isValidUrl(url)) return false; + if (typeof window.performance.getEntriesByName !== 'function') return false; + return true; +}; + +export const measureResponseTime = (url: string) => { + const requestUrl = new URL(url); + const requests = window.performance.getEntriesByName(requestUrl.href); + const { duration } = (requests.length && requests[requests.length - 1]) || {}; + return duration; +};