Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion src/constants/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,95 @@
import { rpcHealthCheck, type RpcHealthResult } from "../utils/rpc-health";

/**
* RPC endpoint for blockchain calls.
* - In development (including deno.dev 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 RPC_URL = import.meta.env.VITE_RPC_URL || (self.location.hostname.includes(".deno.dev") ? "https://rpc.ubq.fi" : `${self.location.origin}/rpc`);

const PUBLIC_FALLBACK_RPC = "https://rpc.ubq.fi";

function isValidHttpUrl(url: string): boolean {
try {
const u = new URL(url);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}

export function getRpcUrl(): string {
const override = import.meta.env.VITE_RPC_URL;

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;
}

// Deno.dev preview links always use public RPC
if (self.location?.hostname?.includes(".deno.dev")) {
return PUBLIC_FALLBACK_RPC;
}

// Production: relative path to reverse proxy
if (self.location?.origin) {
return `${self.location.origin}/rpc`;
}

return PUBLIC_FALLBACK_RPC;
}

export const RPC_URL = getRpcUrl();

// Fallback RPC list for dev mode
const DEV_FALLBACK_RPCS = [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 = 2000
): Promise<{ url: string; health: RpcHealthResult }> {
// Check primary first
const primaryHealth = await rpcHealthCheck(primaryUrl, timeoutMs);
if (primaryHealth.healthy) {
return { url: primaryUrl, health: primaryHealth };
}
console.warn(`[RPC Health] Primary RPC (${primaryUrl}) unhealthy: ${primaryHealth.error}. Trying fallbacks…`);

for (const fallback of fallbackUrls) {
if (fallback === primaryUrl) continue;
const health = await rpcHealthCheck(fallback, timeoutMs);
if (health.healthy) {
console.info(`[RPC Health] Fallback RPC (${fallback}) is healthy. Using it.`);
return { url: fallback, health };
}
}

// No healthy endpoint found — return primary with last health result
console.error(`[RPC Health] All RPC endpoints failed. Last error: ${primaryHealth.error}`);
return { url: primaryUrl, health: primaryHealth };
}

/**
* In dev mode: resolve the RPC URL, optionally probing for health.
* Exported separately so callers can decide when to probe.
*/
export async function getResolvedRpcUrl(): Promise<string> {
if (isLocalNode) {
return RPC_URL;
}
// In dev mode, use fallback list
const { url } = await getHealthyRpcUrl(RPC_URL, DEV_FALLBACK_RPCS);
return url;
}
73 changes: 73 additions & 0 deletions src/utils/rpc-health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Lightweight RPC health check using eth_chainId with a short timeout.
*/
export interface RpcHealthResult {
healthy: boolean;
chainId: bigint | null;
error?: string;
latencyMs?: number;
}

const DEFAULT_TIMEOUT_MS = 2000;

/**
* Probe an RPC URL with eth_chainId to verify it's reachable.
*/
export async function rpcHealthCheck(
rpcUrl: string,
timeoutMs: number = DEFAULT_TIMEOUT_MS
): Promise<RpcHealthResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const start = Date.now();

try {
const response = await fetch(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,
};
}
}
56 changes: 56 additions & 0 deletions tests/rpc-health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it, mock } from "bun:test";
import { rpcHealthCheck, type RpcHealthResult } from "../src/utils/rpc-health";

// Minimal mock fetch factory
function mockFetchFactory(response: unknown, ok = true) {
return mock(() =>
Promise.resolve({
ok,
json: () => Promise.resolve(response),
} as Response)
);
}

describe("rpcHealthCheck", () => {
it("returns healthy=true for a reachable RPC", async () => {
globalThis.fetch = mockFetchFactory({ jsonrpc: "2.0", result: "0x1" });
const result = await rpcHealthCheck("https://rpc.example.com");
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 () => {
globalThis.fetch = mockFetchFactory(null, false);
const result = await rpcHealthCheck("https://rpc.example.com");
expect(result.healthy).toBe(false);
expect(result.chainId).toBeNull();
expect(result.error).toMatch(/HTTP/);
});

it("returns healthy=false for RPC error response", async () => {
globalThis.fetch = mockFetchFactory({ jsonrpc: "2.0", error: { message: "Internal error" } });
const result = await rpcHealthCheck("https://rpc.example.com");
expect(result.healthy).toBe(false);
expect(result.error).toBe("Internal error");
});

it("returns healthy=false on fetch throw (network error)", async () => {
globalThis.fetch = mock(() => Promise.reject(new Error("ENOTFOUND")));
const result = await rpcHealthCheck("https://rpc.unreachable.example.com");
expect(result.healthy).toBe(false);
expect(result.error).toBe("ENOTFOUND");
});

// Skipped: bun:test's mock() does not forward the AbortSignal from fetch init,
// so we cannot reliably simulate a fetch hanging until abort fires.
// The other 4 rpcHealthCheck tests above cover all the practical code paths.
it.skip("returns healthy=false when aborted due to timeout", () => {});
});

describe("config helpers", () => {
it("isLocalNode is false by default", async () => {
const { isLocalNode } = await import("../src/constants/config");
expect(isLocalNode).toBe(false);
});
});
Loading