@@ -519,7 +524,7 @@ function MarketQuickViewContent() {
{position.data && (position.data.yes > 0n || position.data.no > 0n || positionState.data?.provisional || positionState.data?.awaitingRpc) && (
Your position: {formatUsdc(position.data.yes, { maxDecimals: 2 })} YES · {formatUsdc(position.data.no, { maxDecimals: 2 })} NO
- } />
+ } />
)}
@@ -570,7 +575,7 @@ function MarketQuickViewContent() {
{/* Footer Action */}
- {liquidityText}
+ {liquidityText}
Collateral locked
diff --git a/components/layout/HealthIndicator.tsx b/components/layout/HealthIndicator.tsx
new file mode 100644
index 0000000..5e6a283
--- /dev/null
+++ b/components/layout/HealthIndicator.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { useHealth, type OverallHealth } from "@/lib/health/HealthProvider";
+import type { SocketStatus } from "@/lib/realtime/RealtimeProvider";
+
+const OVERALL_LABEL: Record
= {
+ live: "Live",
+ degraded: "Degraded",
+ offline: "Offline",
+ checking: "Checking…",
+};
+
+const OVERALL_DOT: Record = {
+ live: "bg-secondary",
+ degraded: "bg-tertiary",
+ offline: "bg-error",
+ checking: "bg-outline",
+};
+
+const SOCKET_LABEL: Record = {
+ connected: "connected",
+ reconnecting: "reconnecting",
+ offline: "offline",
+ unknown: "checking…",
+};
+
+function ago(seconds: number | null): string {
+ if (seconds === null) return "—";
+ if (seconds < 60) return `${seconds}s ago`;
+ return `${Math.floor(seconds / 60)}m ago`;
+}
+
+/**
+ * Honest status for the three data paths. Replaces the decorative pulsing dot:
+ * the label is text, not colour-only, and the popover lists each probe with a
+ * manual retry. Polling itself lives in the health monitor.
+ */
+export function HealthIndicator() {
+ const health = useHealth();
+ const [open, setOpen] = useState(false);
+ const rootRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setOpen(false);
+ };
+ const onClick = (event: MouseEvent) => {
+ if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false);
+ };
+ document.addEventListener("keydown", onKey);
+ document.addEventListener("mousedown", onClick);
+ return () => {
+ document.removeEventListener("keydown", onKey);
+ document.removeEventListener("mousedown", onClick);
+ };
+ }, [open]);
+
+ const rows = [
+ { name: "Socket", value: SOCKET_LABEL[health.socket] },
+ {
+ name: "Stellar RPC",
+ value:
+ health.rpc.status === "ok"
+ ? `ok · ledger ${health.rpc.latestLedger ?? "—"} (${ago(health.rpc.ledgerAgeSec)})`
+ : `${health.rpc.status}${health.rpc.latestLedger !== null ? ` · ledger ${health.rpc.latestLedger}` : ""}`,
+ },
+ { name: "Backend API", value: health.backend.status },
+ ];
+
+ return (
+
+
+ {open && (
+
+
Data sources
+
+ {rows.map((row) => (
+ -
+ {row.name}
+ {row.value}
+
+ ))}
+
+
+ Health polling pauses while this tab is hidden and backs off when a probe fails.
+
+
+
+ )}
+
+ );
+}
diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx
index 095f52f..cc29ab6 100644
--- a/components/layout/Navbar.tsx
+++ b/components/layout/Navbar.tsx
@@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useInbox } from "@/lib/notifications/InboxProvider";
import WalletDropdown from "./WalletDropdown";
+import { HealthIndicator } from "./HealthIndicator";
const Navbar = () => {
const pathname = usePathname();
@@ -96,6 +97,7 @@ const Navbar = () => {
+
0;
+ const ageSec = hasRead ? Math.max(0, Math.round((now - (readAt as number)) / 1_000)) : null;
+ const stale = hasRead && isStale(readAt, now, staleAfterSec);
+ const chain = source === "chain";
+
+ const detail = [
+ ledger !== null ? `at ledger ${ledger}` : null,
+ ageSec !== null ? ageLabel(ageSec) : null,
+ ]
+ .filter(Boolean)
+ .join(", ");
+ const title = chain
+ ? `Read live from the market contract${detail ? `, ${detail}` : ""}`
+ : "OracleDesk backend estimate; may lag the chain";
+
+ const palette = chain
+ ? stale
+ ? "bg-error-container text-on-error-container"
+ : "bg-secondary-container text-on-secondary-container"
+ : "bg-surface-container-highest text-on-surface-variant";
+
+ return (
+
+ {stale ? `${source} · stale` : source}
+ {detail && {`, ${detail}`}}
+
+ );
+}
diff --git a/lib/health/HealthProvider.tsx b/lib/health/HealthProvider.tsx
new file mode 100644
index 0000000..2b51c4f
--- /dev/null
+++ b/lib/health/HealthProvider.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
+import { createHealthMonitor, type BackendHealth, type HealthSample, type RpcHealth } from "@/lib/stellar/health";
+import { useRealtimeStatus, type SocketStatus } from "@/lib/realtime/RealtimeProvider";
+
+/** One word for the navbar, derived from the three probes. */
+export type OverallHealth = "live" | "degraded" | "offline" | "checking";
+
+export interface HealthState {
+ socket: SocketStatus;
+ rpc: RpcHealth;
+ backend: BackendHealth;
+ overall: OverallHealth;
+ /** Force an immediate re-probe of both paths. */
+ retry: () => void;
+}
+
+const initialSample: HealthSample = {
+ rpc: { status: "down", latestLedger: null, ledgerAgeSec: null, checkedAt: 0 },
+ backend: { status: "down", checkedAt: 0 },
+};
+
+const HealthContext = createContext(null);
+
+/**
+ * Mounts the health monitor once and combines it with the realtime socket
+ * state. Initial render is deterministic ("checking") on both server and
+ * client, so statically prerendered pages hydrate without a warning.
+ */
+export function HealthProvider({ children }: { children: ReactNode }) {
+ const [sample, setSample] = useState(initialSample);
+ const [monitor] = useState(() => createHealthMonitor());
+ const socket = useRealtimeStatus();
+
+ useEffect(() => {
+ const unsubscribe = monitor.subscribe(setSample);
+ monitor.start();
+ return () => {
+ unsubscribe();
+ monitor.stop();
+ };
+ }, [monitor]);
+
+ const checked = sample.rpc.checkedAt > 0 || sample.backend.checkedAt > 0;
+ const overall = useMemo(() => {
+ if (!checked) return "checking";
+ if (sample.rpc.status === "down" && sample.backend.status === "down" && socket === "offline") return "offline";
+ if (
+ sample.rpc.status !== "ok" ||
+ sample.backend.status !== "ok" ||
+ socket === "offline" ||
+ socket === "reconnecting"
+ ) {
+ return "degraded";
+ }
+ return "live";
+ }, [sample, socket, checked]);
+
+ const value = useMemo(
+ () => ({ socket, rpc: sample.rpc, backend: sample.backend, overall, retry: () => monitor.refresh() }),
+ [socket, sample, overall, monitor],
+ );
+
+ return {children};
+}
+
+export function useHealth(): HealthState {
+ const ctx = useContext(HealthContext);
+ if (!ctx) throw new Error("HealthProvider is missing");
+ return ctx;
+}
diff --git a/lib/health/useTicker.ts b/lib/health/useTicker.ts
new file mode 100644
index 0000000..c96d8bc
--- /dev/null
+++ b/lib/health/useTicker.ts
@@ -0,0 +1,40 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+
+/**
+ * One shared 5-second interval for every mounted freshness tag, so relative
+ * times update without giving each tag its own timer. Returns 0 during server
+ * render and hydration (deterministic), then the client clock.
+ */
+const TICK_MS = 5_000;
+
+const subscribers = new Set<() => void>();
+let timer: ReturnType | null = null;
+let current = Date.now();
+
+function subscribe(callback: () => void) {
+ subscribers.add(callback);
+ if (timer === null) {
+ current = Date.now();
+ timer = setInterval(() => {
+ current = Date.now();
+ for (const notify of subscribers) notify();
+ }, TICK_MS);
+ }
+ return () => {
+ subscribers.delete(callback);
+ if (subscribers.size === 0 && timer !== null) {
+ clearInterval(timer);
+ timer = null;
+ }
+ };
+}
+
+const getSnapshot = () => current;
+const getServerSnapshot = () => 0;
+
+/** Current time in ms, ticking at most once per 5 s. 0 while prerendering. */
+export function useNow(): number {
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
diff --git a/lib/hooks/useStellar.ts b/lib/hooks/useStellar.ts
index 2eb974e..a0d99d0 100644
--- a/lib/hooks/useStellar.ts
+++ b/lib/hooks/useStellar.ts
@@ -158,11 +158,13 @@ export interface OnChainMarketView {
market: OnChainMarket;
/** YES price in bps, from the contract's own FPMM math. */
yesBps: number;
+ /** Ledger the simulated read was taken at, so the UI can show its age. */
+ ledger: number;
}
export async function fetchOnChainMarket(id: bigint | string): Promise {
- const market = read(await marketCore().get_market({ market_id: toId(id) }));
- return { id: String(id), market, yesBps: Number(priceYesBps(market.reserve_yes, market.reserve_no)) };
+ const { value: market, ledger } = simulatedWithLedger(await marketCore().get_market({ market_id: toId(id) }));
+ return { id: String(id), market, yesBps: Number(priceYesBps(market.reserve_yes, market.reserve_no)), ledger };
}
export function useOnChainMarket(id: bigint | string | null | undefined) {
diff --git a/lib/realtime/RealtimeProvider.tsx b/lib/realtime/RealtimeProvider.tsx
index c684b96..eba9ee5 100644
--- a/lib/realtime/RealtimeProvider.tsx
+++ b/lib/realtime/RealtimeProvider.tsx
@@ -10,8 +10,13 @@ import { isTradeEvent, isTraceEvent, type RealtimeEvent } from "./types";
type Listener = (batch: Batch) => void;
const Context = createContext | null>(null);
+/** Honest socket state, surfaced so the navbar can stop guessing. */
+export type SocketStatus = "connected" | "reconnecting" | "offline" | "unknown";
+const StatusContext = createContext("unknown");
+
export function RealtimeProvider({ children }: { children: ReactNode }) {
const [listeners] = useState(() => new Set());
+ const [status, setStatus] = useState("unknown");
useEffect(() => {
// One scheduler for both event kinds: mixed bursts also commit once per frame.
const batcher = createBatcher();
@@ -20,11 +25,23 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
visibility();
document.addEventListener("visibilitychange", visibility);
const socket = io(API_URL.replace(/\/api\/v1\/?$/, ""));
+ const onConnect = () => setStatus("connected");
+ const onDisconnect = () => setStatus("reconnecting");
+ const onConnectError = () => setStatus("offline");
+ const onReconnectAttempt = () => setStatus("reconnecting");
+ socket.on("connect", onConnect);
+ socket.on("disconnect", onDisconnect);
+ socket.on("connect_error", onConnectError);
+ socket.io.on("reconnect_attempt", onReconnectAttempt);
socket.on("TRADE_EXECUTED", (payload: unknown) => { if (isTradeEvent(payload)) batcher.push({ type: "TRADE_EXECUTED", payload, receivedAt: Date.now() }); });
socket.on("REASONING_PUBLISHED", (payload: unknown) => { if (isTraceEvent(payload)) batcher.push({ type: "REASONING_PUBLISHED", payload, receivedAt: Date.now() }); });
return () => { socket.disconnect(); stop(); batcher.dispose(); document.removeEventListener("visibilitychange", visibility); };
}, [listeners]);
- return {children};
+ return {children};
+}
+
+export function useRealtimeStatus(): SocketStatus {
+ return useContext(StatusContext);
}
export function useRealtimeBatch(handler: Listener) {
diff --git a/lib/stellar/__tests__/health.test.ts b/lib/stellar/__tests__/health.test.ts
new file mode 100644
index 0000000..70dd976
--- /dev/null
+++ b/lib/stellar/__tests__/health.test.ts
@@ -0,0 +1,166 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { rpc } from "@stellar/stellar-sdk";
+import {
+ backendHealthUrl,
+ createHealthMonitor,
+ isStale,
+ ledgerAge,
+ nextBackoff,
+ probeBackend,
+ probeRpc,
+ type RpcHealth,
+} from "../health";
+
+const down: RpcHealth = { status: "down", latestLedger: null, ledgerAgeSec: null, checkedAt: 0 };
+
+describe("ledgerAge", () => {
+ it("is zero when the ledger advanced", () => {
+ expect(ledgerAge(105, { latestLedger: 100, checkedAt: 1_000 }, 6_000)).toBe(0);
+ });
+ it("accrues wall time while the ledger is stuck", () => {
+ expect(ledgerAge(100, { latestLedger: 100, checkedAt: 1_000 }, 31_000)).toBe(30);
+ });
+ it("is null without a ledger", () => {
+ expect(ledgerAge(null, null, 1_000)).toBeNull();
+ });
+});
+
+describe("isStale", () => {
+ it("is false before the threshold and true after", () => {
+ expect(isStale(0, 59_000, 60)).toBe(false);
+ expect(isStale(0, 61_000, 60)).toBe(true);
+ });
+ it("is false when readAt is missing", () => {
+ expect(isStale(undefined, 10_000)).toBe(false);
+ expect(isStale(null, 10_000)).toBe(false);
+ });
+});
+
+describe("nextBackoff", () => {
+ it("doubles per failure and caps", () => {
+ expect(nextBackoff(0, 15_000, 120_000)).toBe(15_000);
+ expect(nextBackoff(1, 15_000, 120_000)).toBe(30_000);
+ expect(nextBackoff(2, 15_000, 120_000)).toBe(60_000);
+ expect(nextBackoff(3, 15_000, 120_000)).toBe(120_000);
+ expect(nextBackoff(10, 15_000, 120_000)).toBe(120_000);
+ });
+});
+
+describe("backendHealthUrl", () => {
+ it("strips the /api/v1 suffix", () => {
+ expect(backendHealthUrl("https://api.example.com/api/v1")).toBe("https://api.example.com/health");
+ });
+ it("handles a trailing slash and a custom base", () => {
+ expect(backendHealthUrl("https://api.example.com/api/v1/")).toBe("https://api.example.com/health");
+ expect(backendHealthUrl("https://api.example.com")).toBe("https://api.example.com/health");
+ });
+});
+
+describe("probeRpc", () => {
+ const server = (over: {
+ health?: () => Promise;
+ latest?: () => Promise;
+ }) =>
+ ({
+ getHealth: over.health ?? (async () => ({})),
+ getLatestLedger: over.latest ?? (async () => ({ sequence: 5 })),
+ }) as unknown as rpc.Server;
+
+ it("reports ok when the ledger advances", async () => {
+ const result = await probeRpc(server({ latest: async () => ({ sequence: 101 }) }), { latestLedger: 100, checkedAt: 0 }, 5_000);
+ expect(result).toMatchObject({ status: "ok", latestLedger: 101, ledgerAgeSec: 0 });
+ });
+ it("reports degraded when the ledger is stuck past the threshold", async () => {
+ const result = await probeRpc(server({ latest: async () => ({ sequence: 100 }) }), { latestLedger: 100, checkedAt: 0 }, 31_000);
+ expect(result.status).toBe("degraded");
+ expect(result.ledgerAgeSec).toBe(31);
+ });
+ it("reports down when getHealth fails", async () => {
+ const result = await probeRpc(server({ health: async () => { throw new Error("boom"); } }));
+ expect(result.status).toBe("down");
+ });
+ it("reports degraded when only the ledger read fails", async () => {
+ const result = await probeRpc(server({ latest: async () => { throw new Error("boom"); } }));
+ expect(result.status).toBe("degraded");
+ });
+});
+
+describe("probeBackend", () => {
+ const fetchReturning = (value: unknown): typeof fetch =>
+ (async () => value) as unknown as typeof fetch;
+
+ it("parses the /health envelope", async () => {
+ const fetchImpl = fetchReturning({ ok: true, json: async () => ({ ok: true, data: { status: "healthy" } }) });
+ expect((await probeBackend(fetchImpl, 1, "http://x/health")).status).toBe("ok");
+ });
+ it("is down on a non-ok response", async () => {
+ const fetchImpl = fetchReturning({ ok: false, json: async () => ({}) });
+ expect((await probeBackend(fetchImpl, 1, "http://x/health")).status).toBe("down");
+ });
+ it("is down on an unhealthy envelope", async () => {
+ const fetchImpl = fetchReturning({ ok: true, json: async () => ({ ok: true, data: { status: "degraded" } }) });
+ expect((await probeBackend(fetchImpl, 1, "http://x/health")).status).toBe("down");
+ });
+ it("is down when the fetch throws", async () => {
+ const fetchImpl = (async () => { throw new Error("network"); }) as unknown as typeof fetch;
+ expect((await probeBackend(fetchImpl, 1, "http://x/health")).status).toBe("down");
+ });
+});
+
+describe("createHealthMonitor", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("backs off exponentially after failures", async () => {
+ vi.useFakeTimers();
+ let calls = 0;
+ const monitor = createHealthMonitor({
+ baseIntervalMs: 1_000,
+ maxIntervalMs: 4_000,
+ isHidden: () => false,
+ probeRpc: async () => {
+ calls += 1;
+ return down;
+ },
+ probeBackend: async () => ({ status: "ok", checkedAt: 0 }),
+ });
+ monitor.start();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(calls).toBe(1);
+ // First failure → next delay = base × 2 = 2000, not the base interval.
+ await vi.advanceTimersByTimeAsync(1_999);
+ expect(calls).toBe(1);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(calls).toBe(2);
+ // Second failure → next delay = base × 4 = 4000.
+ await vi.advanceTimersByTimeAsync(3_999);
+ expect(calls).toBe(2);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(calls).toBe(3);
+ monitor.stop();
+ });
+
+ it("does not probe while the tab is hidden, and resumes on refresh", async () => {
+ vi.useFakeTimers();
+ let hidden = true;
+ let rpcCalls = 0;
+ const monitor = createHealthMonitor({
+ baseIntervalMs: 1_000,
+ isHidden: () => hidden,
+ probeRpc: async () => {
+ rpcCalls += 1;
+ return down;
+ },
+ probeBackend: async () => ({ status: "ok", checkedAt: Date.now() }),
+ });
+ monitor.start();
+ await vi.advanceTimersByTimeAsync(5_000);
+ expect(rpcCalls).toBe(0);
+ hidden = false;
+ monitor.refresh();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(rpcCalls).toBe(1);
+ monitor.stop();
+ });
+});
diff --git a/lib/stellar/health.ts b/lib/stellar/health.ts
new file mode 100644
index 0000000..2005a92
--- /dev/null
+++ b/lib/stellar/health.ts
@@ -0,0 +1,216 @@
+import { rpc } from "@stellar/stellar-sdk";
+import { RPC_URL } from "./config";
+import { API_URL } from "@/lib/api/client";
+
+/**
+ * Honest liveness probes for the three data paths the app depends on: the
+ * backend REST API, the Stellar RPC, and (reported by the realtime provider)
+ * the socket. These never assume data is fresh; they only report what a probe
+ * just observed so the UI can stop pretending a decorative dot means "live".
+ */
+
+export type RpcStatus = "ok" | "degraded" | "down";
+export type BackendStatus = "ok" | "down";
+
+export interface RpcHealth {
+ status: RpcStatus;
+ latestLedger: number | null;
+ /** Seconds since the ledger last advanced, per consecutive probes. Null if unknown. */
+ ledgerAgeSec: number | null;
+ checkedAt: number;
+}
+
+export interface BackendHealth {
+ status: BackendStatus;
+ checkedAt: number;
+}
+
+/** A ledger that has not advanced for this long is reported as degraded. */
+export const RPC_LEDGER_STALL_SEC = 30;
+/** `/health` is served outside `/api/v1`; give a cold Render instance time. */
+export const BACKEND_TIMEOUT_MS = 8_000;
+
+/** Derives the backend health URL from the API base, which ends in `/api/v1`. */
+export function backendHealthUrl(apiUrl: string = API_URL): string {
+ return `${apiUrl.replace(/\/+$/, "").replace(/\/api\/v1$/, "")}/health`;
+}
+
+/**
+ * Age of the current ledger, in seconds, from two consecutive probes. A
+ * ledger that advanced resets the age to zero; a stuck ledger accrues wall
+ * time since the previous probe. Wall-clock only, never ledger timestamps.
+ */
+export function ledgerAge(
+ ledger: number | null,
+ previous: { latestLedger: number | null; checkedAt: number } | null,
+ now: number,
+): number | null {
+ if (ledger === null) return null;
+ if (!previous || previous.latestLedger === null) return 0;
+ if (ledger > previous.latestLedger) return 0;
+ return Math.max(0, Math.round((now - previous.checkedAt) / 1000));
+}
+
+/** True when a value read at `readAt` (ms) is older than `staleAfterSec`. */
+export function isStale(readAt: number | null | undefined, now: number, staleAfterSec = 60): boolean {
+ if (readAt === null || readAt === undefined) return false;
+ return now - readAt > staleAfterSec * 1000;
+}
+
+/** Exponential backoff, capped: base, 2×base, 4×base, … Max after `max`. */
+export function nextBackoff(failures: number, baseMs: number, maxMs: number): number {
+ if (failures <= 0) return baseMs;
+ const raw = baseMs * 2 ** Math.min(failures, 16);
+ return Math.min(maxMs, raw);
+}
+
+export async function probeRpc(
+ server: rpc.Server = new rpc.Server(RPC_URL, { allowHttp: RPC_URL.startsWith("http://") }),
+ previous: { latestLedger: number | null; checkedAt: number } | null = null,
+ now: number = Date.now(),
+): Promise {
+ try {
+ await server.getHealth();
+ } catch {
+ return { status: "down", latestLedger: null, ledgerAgeSec: null, checkedAt: now };
+ }
+ try {
+ const latest = await server.getLatestLedger();
+ const latestLedger = typeof latest.sequence === "number" ? latest.sequence : null;
+ const age = ledgerAge(latestLedger, previous, now);
+ const status: RpcStatus = age !== null && age > RPC_LEDGER_STALL_SEC ? "degraded" : "ok";
+ return { status, latestLedger, ledgerAgeSec: age, checkedAt: now };
+ } catch {
+ // getHealth answered but the ledger read failed: reachable but unusable.
+ return { status: "degraded", latestLedger: null, ledgerAgeSec: null, checkedAt: now };
+ }
+}
+
+export async function probeBackend(
+ fetchImpl: typeof fetch = fetch,
+ now: number = Date.now(),
+ url: string = backendHealthUrl(),
+): Promise {
+ try {
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(BACKEND_TIMEOUT_MS) });
+ if (!res.ok) return { status: "down", checkedAt: now };
+ const envelope = (await res.json().catch(() => null)) as
+ | { ok?: boolean; data?: { status?: string } }
+ | null;
+ const healthy = envelope?.ok === true && envelope.data?.status === "healthy";
+ return { status: healthy ? "ok" : "down", checkedAt: now };
+ } catch {
+ return { status: "down", checkedAt: now };
+ }
+}
+
+export interface HealthSample {
+ rpc: RpcHealth;
+ backend: BackendHealth;
+}
+
+export interface HealthMonitorOptions {
+ baseIntervalMs?: number;
+ maxIntervalMs?: number;
+ probeRpc?: (previous: RpcHealth | null) => Promise;
+ probeBackend?: () => Promise;
+ isHidden?: () => boolean;
+}
+
+/**
+ * Runs the two probes on their own schedules with per-probe exponential
+ * backoff on failure and an immediate probe when the tab becomes visible or
+ * comes back online. Pauses entirely while the document is hidden so an idle
+ * tab costs nothing.
+ */
+export function createHealthMonitor(options: HealthMonitorOptions = {}) {
+ const baseIntervalMs = options.baseIntervalMs ?? 15_000;
+ const maxIntervalMs = options.maxIntervalMs ?? 120_000;
+ const runRpc = options.probeRpc ?? ((previous: RpcHealth | null) => probeRpc(undefined, previous));
+ const runBackend = options.probeBackend ?? (() => probeBackend());
+ const hidden = options.isHidden ?? (() => typeof document !== "undefined" && document.hidden);
+
+ const listeners = new Set<(sample: HealthSample) => void>();
+ let sample: HealthSample = {
+ rpc: { status: "down", latestLedger: null, ledgerAgeSec: null, checkedAt: 0 },
+ backend: { status: "down", checkedAt: 0 },
+ };
+ let rpcTimer: ReturnType | null = null;
+ let backendTimer: ReturnType | null = null;
+ let rpcFailures = 0;
+ let backendFailures = 0;
+ let running = false;
+
+ const emit = () => {
+ for (const listener of listeners) listener(sample);
+ };
+
+ const scheduleRpc = (delay: number) => {
+ if (!running) return;
+ clearTimeout(rpcTimer ?? undefined);
+ rpcTimer = setTimeout(tickRpc, delay);
+ };
+ const scheduleBackend = (delay: number) => {
+ if (!running) return;
+ clearTimeout(backendTimer ?? undefined);
+ backendTimer = setTimeout(tickBackend, delay);
+ };
+
+ const tickRpc = async () => {
+ if (!running) return;
+ if (hidden()) return scheduleRpc(baseIntervalMs);
+ const next = await runRpc(sample.rpc.latestLedger === null && sample.rpc.checkedAt === 0 ? null : sample.rpc);
+ sample = { ...sample, rpc: next };
+ rpcFailures = next.status === "down" ? rpcFailures + 1 : 0;
+ emit();
+ scheduleRpc(nextBackoff(rpcFailures, baseIntervalMs, maxIntervalMs));
+ };
+
+ const tickBackend = async () => {
+ if (!running) return;
+ if (hidden()) return scheduleBackend(baseIntervalMs);
+ const next = await runBackend();
+ sample = { ...sample, backend: next };
+ backendFailures = next.status === "down" ? backendFailures + 1 : 0;
+ emit();
+ scheduleBackend(nextBackoff(backendFailures, baseIntervalMs, maxIntervalMs));
+ };
+
+ const onVisible = () => {
+ if (!hidden()) {
+ scheduleRpc(0);
+ scheduleBackend(0);
+ }
+ };
+
+ return {
+ subscribe(listener: (sample: HealthSample) => void) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ getSnapshot() {
+ return sample;
+ },
+ start() {
+ if (running) return;
+ running = true;
+ scheduleRpc(0);
+ scheduleBackend(0);
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
+ if (typeof window !== "undefined") window.addEventListener("online", onVisible);
+ },
+ stop() {
+ running = false;
+ clearTimeout(rpcTimer ?? undefined);
+ clearTimeout(backendTimer ?? undefined);
+ rpcTimer = null;
+ backendTimer = null;
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
+ if (typeof window !== "undefined") window.removeEventListener("online", onVisible);
+ },
+ refresh() {
+ scheduleRpc(0);
+ scheduleBackend(0);
+ },
+ };
+}