diff --git a/src/App.tsx b/src/App.tsx index 9e70c30..7aa0bf8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,13 @@ import { DashboardPage } from "./components/dashboard-page"; +import { RpcDiagnostics } from "./components/rpc-diagnostics"; function App() { - return ; + return ( + <> + + + + ); } export default App; diff --git a/src/components/rpc-diagnostics.tsx b/src/components/rpc-diagnostics.tsx new file mode 100644 index 0000000..bd01f8d --- /dev/null +++ b/src/components/rpc-diagnostics.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; +import { isLocalNode, RPC_URL, resolveRpcUrl } from "../constants/config"; +import { rpcHealthCheck, type RpcHealthResult } from "../utils/rpc-health"; + +export function RpcDiagnostics() { + const [health, setHealth] = useState(null); + const [resolvedUrl, setResolvedUrl] = useState(RPC_URL); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + async function check() { + const [healthResult, url] = await Promise.all([ + rpcHealthCheck(RPC_URL, 2000), + resolveRpcUrl(2000), + ]); + if (!cancelled) { + setHealth(healthResult); + setResolvedUrl(url); + setLoading(false); + } + } + + check(); + return () => { + cancelled = true; + }; + }, []); + + if (import.meta.env.PROD) return null; + + return ( +
+
RPC Diagnostics
+ {loading ? ( +
Checking…
+ ) : ( + <> +
Mode: {isLocalNode ? "local-node" : import.meta.env.MODE}
+
URL: {resolvedUrl}
+
+ Status:{" "} + {health?.healthy ? ( + Healthy + ) : ( + Unhealthy + )} +
+ {health?.chainId !== null && health?.chainId !== undefined &&
Chain ID: {health.chainId.toString()}
} + {health?.latencyMs !== undefined &&
Latency: {health.latencyMs}ms
} + {health?.error &&
Error: {health.error}
} + + )} +
+ ); +} diff --git a/src/constants/config.ts b/src/constants/config.ts index 7dc4ad5..02e8661 100644 --- a/src/constants/config.ts +++ b/src/constants/config.ts @@ -1,10 +1,104 @@ +import { rpcHealthCheck, type RpcHealthResult } from "../utils/rpc-health"; + /** * RPC endpoint for blockchain calls. * - In development (including Deno Deploy preview links), it uses https://rpc.ubq.fi * - In production, it uses /rpc for performance. + * - Supports VITE_RPC_URL override with validation and fallback to known-good endpoints. */ export const isLocalNode = import.meta.env.MODE === "local-node"; + export const isDenoDeployHost = (hostname: string) => hostname.endsWith(".deno.dev") || hostname.endsWith(".deno.net"); -const currentLocation = globalThis.location; -export const RPC_URL = - import.meta.env.VITE_RPC_URL || (isDenoDeployHost(currentLocation?.hostname ?? "") ? "https://rpc.ubq.fi" : `${currentLocation?.origin ?? ""}/rpc`); + +const PUBLIC_FALLBACK_RPC = "https://rpc.ubq.fi"; + +export function isValidHttpUrl(url: string): boolean { + try { + const u = new URL(url); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } +} + +function getRawRpcUrl(): string { + const override = import.meta.env.VITE_RPC_URL; + const currentLocation = globalThis.location; + + if (override) { + if (!isValidHttpUrl(override)) { + console.warn( + `[RPC Config] VITE_RPC_URL="${override}" is not a valid HTTP(S) URL. ` + `Falling back to default RPC.` + ); + return PUBLIC_FALLBACK_RPC; + } + return override; + } + + if (isDenoDeployHost(currentLocation?.hostname ?? "")) { + return PUBLIC_FALLBACK_RPC; + } + + return `${currentLocation?.origin ?? ""}/rpc`; +} + +/** + * Synchronous, backward-compatible RPC URL. + * In local-node mode this is the raw URL (no chain suffixing). + * In dev/prod it may be overridden by VITE_RPC_URL. + */ +export const RPC_URL = getRawRpcUrl(); + +/** + * Fallback RPC endpoints for mainnet when the primary fails. + */ +export const DEV_FALLBACK_RPCS: readonly string[] = [PUBLIC_FALLBACK_RPC]; + +/** + * Get a healthy RPC URL from a list, checking sequentially. + * Returns the first that responds within the timeout, or the last checked. + */ +export async function getHealthyRpcUrl( + primaryUrl: string, + fallbackUrls: readonly string[], + timeoutMs = 1500, + checkHealth = rpcHealthCheck +): Promise<{ url: string; health: RpcHealthResult }> { + const primaryHealth = await checkHealth(primaryUrl, timeoutMs); + if (primaryHealth.healthy) { + return { url: primaryUrl, health: primaryHealth }; + } + + if (!isLocalNode) { + console.warn(`[RPC Health] Primary RPC (${primaryUrl}) unhealthy: ${primaryHealth.error}. Trying fallbacks…`); + } + + for (const fallback of fallbackUrls) { + if (fallback === primaryUrl) continue; + const health = await checkHealth(fallback, timeoutMs); + if (health.healthy) { + if (!isLocalNode) { + console.info(`[RPC Health] Fallback RPC (${fallback}) is healthy. Using it.`); + } + return { url: fallback, health }; + } + } + + if (!isLocalNode) { + console.error(`[RPC Health] All RPC endpoints failed. Last error: ${primaryHealth.error}`); + } + return { url: primaryUrl, health: primaryHealth }; +} + +/** + * Resolve the best available RPC URL. + * - In local-node mode, returns the raw RPC_URL without health checks. + * - In dev/prod, probes the primary and falls back to known-good endpoints. + */ +export async function resolveRpcUrl(timeoutMs = 1500, checkHealth = rpcHealthCheck): Promise { + if (isLocalNode) { + return RPC_URL; + } + const { url } = await getHealthyRpcUrl(RPC_URL, DEV_FALLBACK_RPCS, timeoutMs, checkHealth); + return url; +} diff --git a/src/utils/rpc-health.ts b/src/utils/rpc-health.ts new file mode 100644 index 0000000..dadcef9 --- /dev/null +++ b/src/utils/rpc-health.ts @@ -0,0 +1,80 @@ +/** + * Lightweight RPC health check using eth_chainId with a short timeout. + */ +export interface RpcHealthResult { + healthy: boolean; + chainId: bigint | null; + error?: string; + latencyMs?: number; +} + +/** + * Probe an RPC URL with eth_chainId to verify it's reachable. + * + * @param rpcUrl The endpoint to probe. + * @param timeoutMs Abort timeout in milliseconds (default 1500). + * @param fetchImpl Optional fetch implementation for testing. + */ +export async function rpcHealthCheck( + rpcUrl: string, + timeoutMs = 1500, + fetchImpl: typeof fetch = fetch +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const start = Date.now(); + + try { + const response = await fetchImpl(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "eth_chainId", + params: [], + id: 1, + }), + signal: controller.signal, + }); + + clearTimeout(timer); + + if (!response.ok) { + return { + healthy: false, + chainId: null, + error: `HTTP ${response.status}`, + latencyMs: Date.now() - start, + }; + } + + const data = (await response.json()) as { + result?: string; + error?: { message?: string }; + }; + + if (data.error) { + return { + healthy: false, + chainId: null, + error: data.error.message ?? "RPC error", + latencyMs: Date.now() - start, + }; + } + + return { + healthy: true, + chainId: data.result ? BigInt(data.result) : null, + latencyMs: Date.now() - start, + }; + } catch (err) { + clearTimeout(timer); + const message = err instanceof Error ? err.message : String(err); + return { + healthy: false, + chainId: null, + error: message, + latencyMs: Date.now() - start, + }; + } +} diff --git a/tests/config.test.ts b/tests/config.test.ts index 87052d1..19a1066 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { isDenoDeployHost } from "../src/constants/config"; +import { isDenoDeployHost, isValidHttpUrl, getHealthyRpcUrl } from "../src/constants/config"; +import type { RpcHealthResult } from "../src/utils/rpc-health"; describe("isDenoDeployHost", () => { it("matches Deno Deploy preview hostnames", () => { @@ -13,3 +14,87 @@ describe("isDenoDeployHost", () => { expect(isDenoDeployHost("localhost")).toBe(false); }); }); + +describe("isValidHttpUrl", () => { + it("accepts HTTP URLs", () => { + expect(isValidHttpUrl("http://localhost:8545")).toBe(true); + expect(isValidHttpUrl("https://rpc.ubq.fi")).toBe(true); + }); + + it("accepts HTTPS URLs with paths", () => { + expect(isValidHttpUrl("https://rpc.ubq.fi/v1")).toBe(true); + }); + + it("rejects non-HTTP protocols", () => { + expect(isValidHttpUrl("ftp://rpc.ubq.fi")).toBe(false); + expect(isValidHttpUrl("ws://rpc.ubq.fi")).toBe(false); + }); + + it("rejects malformed URLs", () => { + expect(isValidHttpUrl("not-a-url")).toBe(false); + expect(isValidHttpUrl("")).toBe(false); + }); +}); + +describe("getHealthyRpcUrl", () => { + it("returns primary when healthy", async () => { + const checkHealth = async (): Promise => ({ + healthy: true, + chainId: 1n, + latencyMs: 100, + }); + const result = await getHealthyRpcUrl("https://primary.example.com", [], 1500, checkHealth); + expect(result.url).toBe("https://primary.example.com"); + expect(result.health.healthy).toBe(true); + }); + + it("falls back when primary is unhealthy", async () => { + let calls = 0; + const checkHealth = async (url: string): Promise => { + calls++; + if (url === "https://primary.example.com") { + return { healthy: false, chainId: null, error: "DOWN" }; + } + return { healthy: true, chainId: 1n, latencyMs: 100 }; + }; + const result = await getHealthyRpcUrl( + "https://primary.example.com", + ["https://fallback.example.com"], + 1500, + checkHealth + ); + expect(result.url).toBe("https://fallback.example.com"); + expect(calls).toBe(2); + }); + + it("returns primary when all fallbacks fail", async () => { + const checkHealth = async (): Promise => ({ + healthy: false, + chainId: null, + error: "DOWN", + }); + const result = await getHealthyRpcUrl( + "https://primary.example.com", + ["https://fallback.example.com"], + 1500, + checkHealth + ); + expect(result.url).toBe("https://primary.example.com"); + expect(result.health.healthy).toBe(false); + }); + + it("skips duplicate fallback equal to primary", async () => { + let calls = 0; + const checkHealth = async (): Promise => { + calls++; + return { healthy: false, chainId: null, error: "DOWN" }; + }; + await getHealthyRpcUrl( + "https://same.example.com", + ["https://same.example.com"], + 1500, + checkHealth + ); + expect(calls).toBe(1); + }); +}); diff --git a/tests/rpc-health.test.ts b/tests/rpc-health.test.ts new file mode 100644 index 0000000..98598ef --- /dev/null +++ b/tests/rpc-health.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "bun:test"; +import { rpcHealthCheck } from "../src/utils/rpc-health"; + +function mockResponse(body: unknown, ok = true): Response { + return { + ok, + json: () => Promise.resolve(body), + } as Response; +} + +describe("rpcHealthCheck", () => { + it("returns healthy=true for a reachable RPC", async () => { + const fetchImpl = () => Promise.resolve(mockResponse({ jsonrpc: "2.0", result: "0x1" })); + const result = await rpcHealthCheck("https://rpc.example.com", 1500, fetchImpl); + expect(result.healthy).toBe(true); + expect(result.chainId).toBe(1n); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it("returns healthy=false for non-ok HTTP response", async () => { + const fetchImpl = () => Promise.resolve(mockResponse(null, false)); + const result = await rpcHealthCheck("https://rpc.example.com", 1500, fetchImpl); + expect(result.healthy).toBe(false); + expect(result.chainId).toBeNull(); + expect(result.error).toMatch(/HTTP/); + }); + + it("returns healthy=false for RPC error response", async () => { + const fetchImpl = () => Promise.resolve(mockResponse({ jsonrpc: "2.0", error: { message: "Internal error" } })); + const result = await rpcHealthCheck("https://rpc.example.com", 1500, fetchImpl); + expect(result.healthy).toBe(false); + expect(result.error).toBe("Internal error"); + }); + + it("returns healthy=false on fetch throw (network error)", async () => { + const fetchImpl = () => Promise.reject(new Error("ENETUNREACH")); + const result = await rpcHealthCheck("https://rpc.unreachable.example.com", 1500, fetchImpl); + expect(result.healthy).toBe(false); + expect(result.error).toBe("ENETUNREACH"); + }); + + it("returns healthy=false when aborted due to timeout", async () => { + const fetchImpl = (_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + if (init?.signal?.aborted) { + reject(new Error("AbortError")); + return; + } + init?.signal?.addEventListener("abort", () => { + reject(new Error("AbortError")); + }); + }); + const result = await rpcHealthCheck("https://rpc.hanging.example.com", 50, fetchImpl); + expect(result.healthy).toBe(false); + expect(result.error).toMatch(/AbortError/); + }); +});