diff --git a/app/layout.tsx b/app/layout.tsx index 2703895..62e872a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer"; import { WalletProvider } from "@/lib/contexts/WalletContext"; import "./globals.css"; import { RealtimeProvider } from "@/lib/realtime/RealtimeProvider"; +import { HealthProvider } from "@/lib/health/HealthProvider"; import { InboxProvider } from "@/lib/notifications/InboxProvider"; import { PwaRegistrar } from "@/components/pwa/pwa-registrar"; import { DebugMount } from "@/components/debug/debug-mount"; diff --git a/app/markets/page.tsx b/app/markets/page.tsx index d83c138..22c19fa 100644 --- a/app/markets/page.tsx +++ b/app/markets/page.tsx @@ -148,7 +148,7 @@ const MarketCard = ({ href, category, onChainMarketId, reasoningTraces, expiry,
- YES {yesProb}% + YES {yesProb}% NO {noProb}%
@@ -166,7 +166,7 @@ const MarketCard = ({ href, category, onChainMarketId, reasoningTraces, expiry,
-

Liquidity

+

Liquidity

{liquidity}

diff --git a/app/markets/quick-view/page.tsx b/app/markets/quick-view/page.tsx index 5570eb1..4353c48 100644 --- a/app/markets/quick-view/page.tsx +++ b/app/markets/quick-view/page.tsx @@ -239,6 +239,14 @@ function MarketQuickViewContent() { setBuyStatus("This market closes too soon to safely place a trade."); return; } + if (health.rpc.status === "down") { + setBuyStatus("Stellar RPC isn't responding; the trade can't be simulated."); + return; + } + if (now !== null && view && isStale(chain.dataUpdatedAt, now, MARKET_STALE_AFTER_SEC)) { + setBuyStatus("Refreshing the price before you trade…"); + await chain.refetch(); + } setBuyStatus(null); try { const result = await buy.mutateAsync({ @@ -374,7 +382,7 @@ function MarketQuickViewContent() {
{statusLabel} - {chainStatus && } + {chainStatus && } {countdown && {countdown}} {categoryLabel} @@ -403,7 +411,7 @@ function MarketQuickViewContent() {
{yesProb}% - +

YES price

{view?.ledger != null && ( @@ -571,7 +579,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 - } /> + } />

)} @@ -658,7 +666,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/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); + }, + }; +}