From ba07a9a45db88ac82d07349f9a9a66275e120338 Mon Sep 17 00:00:00 2001 From: iam-mercy Date: Wed, 21 Oct 2026 14:36:32 +0000 Subject: [PATCH] feat: add realtime account state and resilience --- .eslintrc.json | 2 +- .github/workflows/ci.yml | 26 ++++ docs/realtime-performance.md | 9 ++ lighthouserc.js | 2 + package.json | 9 +- reports/realtime-benchmark-baseline.json | 10 ++ scripts/realtime-benchmark.mjs | 73 ++++++++++ src/app/cover/page.tsx | 48 ++++--- src/app/dashboard/page.tsx | 8 +- src/app/page.tsx | 17 ++- src/app/provide/page.tsx | 11 +- src/components/NotificationCenter.tsx | 3 +- src/components/layout/Navbar.test.tsx | 32 +++++ src/components/layout/Navbar.tsx | 25 ++++ .../realtime/RealtimeBoundary.test.ts | 22 +++ src/components/realtime/RealtimeBoundary.ts | 32 +++++ src/hooks/useAccountBalance.test.ts | 46 ++++++ src/hooks/useAccountBalance.ts | 58 ++++++++ src/hooks/useClaims.ts | 24 ++++ src/hooks/useCoverageTypes.ts | 22 +++ src/hooks/useNotifications.ts | 12 +- src/hooks/useOracleStatus.ts | 35 +++++ src/hooks/usePoolStats.ts | 27 ++++ src/hooks/useRealtimeTopic.ts | 69 +++++++++ src/hooks/useRecentClaims.ts | 16 +++ src/lib/realtime/client.ts | 36 ++++- src/lib/realtime/devProfiler.test.ts | 10 ++ src/lib/realtime/devProfiler.ts | 23 +++ src/lib/realtime/snapshotCache.test.ts | 18 +++ src/lib/realtime/snapshotCache.ts | 131 ++++++++++++++++++ vitest.config.mts | 9 +- 31 files changed, 823 insertions(+), 42 deletions(-) create mode 100644 docs/realtime-performance.md create mode 100644 reports/realtime-benchmark-baseline.json create mode 100644 scripts/realtime-benchmark.mjs create mode 100644 src/components/layout/Navbar.test.tsx create mode 100644 src/components/realtime/RealtimeBoundary.test.ts create mode 100644 src/components/realtime/RealtimeBoundary.ts create mode 100644 src/hooks/useAccountBalance.test.ts create mode 100644 src/hooks/useAccountBalance.ts create mode 100644 src/hooks/useRealtimeTopic.ts create mode 100644 src/lib/realtime/devProfiler.test.ts create mode 100644 src/lib/realtime/devProfiler.ts create mode 100644 src/lib/realtime/snapshotCache.test.ts create mode 100644 src/lib/realtime/snapshotCache.ts diff --git a/.eslintrc.json b/.eslintrc.json index 3cbf99e..9f8fe95 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -12,7 +12,7 @@ "message": "Hard-coded rgb()/rgba() colours are not allowed in JSX. Use design tokens from src/lib/coverage/metadata.ts or CSS custom properties (see CONTRIBUTING.md)." }, { - "selector": "Literal[value=1e7]", + "selector": "Literal[value=10000000]", "message": "Use USDC_BASE_UNIT / fromStroops from src/lib/format.ts instead of the literal 1e7." }, { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f273c23..8f0a12a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,6 +167,32 @@ jobs: - name: Run ESLint run: npm run lint + realtime-benchmark: + name: Realtime render benchmark + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run dev & + env: + NEXT_PUBLIC_REALTIME_PROFILER: "1" + - run: | + for attempt in $(seq 1 60); do + if curl --fail --silent http://localhost:3000 >/dev/null; then exit 0; fi + sleep 1 + done + echo "Next.js dev server did not become ready" >&2 + exit 1 + - run: npm run realtime:benchmark + env: + REALTIME_BENCHMARK_URL: http://127.0.0.1:3000 + typecheck: name: Typecheck runs-on: ubuntu-latest diff --git a/docs/realtime-performance.md b/docs/realtime-performance.md new file mode 100644 index 0000000..d7e1617 --- /dev/null +++ b/docs/realtime-performance.md @@ -0,0 +1,9 @@ +# Realtime resilience and performance + +Realtime data uses the existing REST endpoints during cold start and after subscription failure. The client caches topic snapshots by network, wallet and topic; entries carry a version and capture time and render as stale until a REST response or realtime push supersedes them. IndexedDB is optional, and storage failures never block the page. + +`RealtimeBoundary` isolates a route's live region. A render or subscription handler failure marks the topic degraded and renders its fallback. Retry clears that topic's degraded state. Diagnostics include topic, route, transport and error type only; payloads and wallet addresses are omitted. + +The development-only React Profiler records commits and actual durations by topic. Run `NEXT_PUBLIC_REALTIME_PROFILER=1 npm run dev`, then `npm run realtime:benchmark`. The scripted benchmark measures five runs with the socket disconnected and five runs with 100 oracle pushes at 25 Hz on landing, `/cover` and `/provide`; it reports both scenarios' median commit time, p95 commit time, long tasks, and p98 event duration to `reports/realtime-benchmark.json`. + +Budgets are checked in at `reports/realtime-benchmark-baseline.json`: median commit duration below 16 ms, p95 commit duration below 50 ms, no long tasks during the scripted stream, and p98 interaction duration below 200 ms. These thresholds allow CI variance while flagging work that exceeds a frame or the Core Web Vitals INP “good” threshold. Lighthouse CI separately warns on INP above 200 ms and total blocking time above 200 ms. Absolute measurements vary by runner; compare like-for-like CI runs and use the median of five benchmark runs. diff --git a/lighthouserc.js b/lighthouserc.js index d5bb13e..b58a420 100644 --- a/lighthouserc.js +++ b/lighthouserc.js @@ -37,6 +37,8 @@ module.exports = { 'categories:accessibility': ['error', { minScore: 0.8 }], 'categories:best-practices': ['error', { minScore: 0.8 }], 'categories:seo': ['error', { minScore: 0.8 }], + 'interaction-to-next-paint': ['warn', { maxNumericValue: 200 }], + 'total-blocking-time': ['warn', { maxNumericValue: 200 }], }, }, upload: { diff --git a/package.json b/package.json index 51f59c9..f65f63b 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", + "test": "vitest run --config vitest.config.mts", + "test:watch": "vitest --config vitest.config.mts", + "test:coverage": "vitest run --config vitest.config.mts --coverage", "test:visual": "playwright test --project=visual", "test:visual:update": "playwright test --project=visual --update-snapshots", "audit": "audit-ci --config audit-ci.json", @@ -19,7 +19,8 @@ "analyze": "ANALYZE=true next build", "size": "size-limit", "size:why": "size-limit --why", - "tokens:check": "node scripts/check-tokens.mjs" + "tokens:check": "node scripts/check-tokens.mjs", + "realtime:benchmark": "node scripts/realtime-benchmark.mjs" }, "lint-staged": { "*.{ts,tsx}": [ diff --git a/reports/realtime-benchmark-baseline.json b/reports/realtime-benchmark-baseline.json new file mode 100644 index 0000000..f70dfe2 --- /dev/null +++ b/reports/realtime-benchmark-baseline.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "scenario": "100 oracle pushes at 25Hz across landing, /cover, and /provide; median of five browser runs", + "budgets": { + "medianCommitDurationMs": 16, + "p95CommitDurationMs": 50, + "longTaskCount": 0, + "p98InteractionDurationMs": 200 + } +} diff --git a/scripts/realtime-benchmark.mjs b/scripts/realtime-benchmark.mjs new file mode 100644 index 0000000..45d4675 --- /dev/null +++ b/scripts/realtime-benchmark.mjs @@ -0,0 +1,73 @@ +import { chromium } from "@playwright/test"; +import { readFile, writeFile } from "node:fs/promises"; + +const origin = process.env.REALTIME_BENCHMARK_URL ?? "http://127.0.0.1:3000"; +const baseline = JSON.parse(await readFile(new URL("../reports/realtime-benchmark-baseline.json", import.meta.url), "utf8")); +const routes = ["/", "/cover", "/provide"]; +const runs = Number(process.env.REALTIME_BENCHMARK_RUNS ?? 5); +const browser = await chromium.launch({ headless: true }); +const samples = { disconnected: [], streaming: [] }; +for (let index = 0; index < runs; index += 1) { + for (const scenario of ["disconnected", "streaming"]) { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.addInitScript(() => { + window.__realtimeVitals = { longTasks: [], interactions: [] }; + try { + new PerformanceObserver((list) => window.__realtimeVitals.longTasks.push(...list.getEntries().map((entry) => entry.duration))) + .observe({ type: "longtask", buffered: true }); + new PerformanceObserver((list) => window.__realtimeVitals.interactions.push(...list.getEntries().map((entry) => entry.duration))) + .observe({ type: "event", buffered: true, durationThreshold: 16 }); + } catch { /* event timing is not supported by every browser build */ } + }); + await page.routeWebSocket("**/ws", (socket) => { + if (scenario === "disconnected") { socket.close(); return; } + let push = 0; + const timer = setInterval(() => { + push += 1; + socket.send(JSON.stringify({ topic: "oracle_update", payload: [ + { coverageType: "StablecoinDepeg", type: "oracle_update", value: 1 + push / 100000, threshold: 0.95, severity: "low", message: "benchmark" }, + ] })); + if (push >= 100) clearInterval(timer); + }, 40); + }); + for (const route of routes) { + await page.goto(`${origin}${route}`, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(250); + await page.locator("body").click({ position: { x: 10, y: 10 } }).catch(() => undefined); + } + await page.waitForTimeout(4200); + const result = await page.evaluate(() => ({ + profile: window.__REFRACT_REALTIME_PROFILE__ ?? {}, + longTasks: window.__realtimeVitals.longTasks, + interactions: window.__realtimeVitals.interactions, + })); + samples[scenario].push(result); + await context.close(); + } +} +await browser.close(); +const median = (values) => values.sort((a, b) => a - b)[Math.floor(values.length / 2)] ?? 0; +const measure = (scenarioSamples) => { + const commitDurations = scenarioSamples.flatMap((sample) => Object.values(sample.profile).flatMap((item) => item.durations ?? [])); + const longTasks = scenarioSamples.flatMap((sample) => sample.longTasks); + const interactions = scenarioSamples.flatMap((sample) => sample.interactions); + return { + medianCommitDurationMs: median(commitDurations), + p95CommitDurationMs: commitDurations.sort((a, b) => a - b)[Math.floor(commitDurations.length * 0.95)] ?? 0, + medianCommitsPerTopic: Object.fromEntries(["oracle_update", "claim_settlement", "pool_stats"].map((topic) => [topic, median(scenarioSamples.map((sample) => sample.profile[topic]?.commits ?? 0))])), + longTaskCount: longTasks.length, + medianLongTaskDurationMs: median(longTasks), + p98InteractionDurationMs: interactions.sort((a, b) => a - b)[Math.floor(interactions.length * 0.98)] ?? 0, + }; +}; +const disconnected = measure(samples.disconnected); +const streaming = measure(samples.streaming); +const report = { + generatedAt: new Date().toISOString(), runs, routes, + scenarios: { disconnected, streaming }, + budget: baseline.budgets, +}; +await writeFile("reports/realtime-benchmark.json", `${JSON.stringify(report, null, 2)}\n`); +console.log(JSON.stringify(report, null, 2)); +if (streaming.medianCommitDurationMs > report.budget.medianCommitDurationMs || streaming.p95CommitDurationMs > report.budget.p95CommitDurationMs || streaming.longTaskCount > report.budget.longTaskCount || streaming.p98InteractionDurationMs > report.budget.p98InteractionDurationMs) process.exitCode = 1; diff --git a/src/app/cover/page.tsx b/src/app/cover/page.tsx index a7a0cfd..80693b9 100644 --- a/src/app/cover/page.tsx +++ b/src/app/cover/page.tsx @@ -33,6 +33,9 @@ import { parseCoverQueryParams } from "@/lib/coverQueryParams"; import { PolicyCertificateModal } from "@/components/PolicyCertificate"; import { validateBuyCoverage } from "@/lib/validation/coverage"; import { searchFlights, POPULAR_FLIGHTS, type FlightInfo } from "@/lib/flights"; +import { RealtimeBoundary } from "@/components/realtime/RealtimeBoundary"; +import { RealtimeProfiler } from "@/lib/realtime/devProfiler"; +import { publishAccountBalanceDelta } from "@/hooks/useAccountBalance"; const RISK_TAG_COLORS: Record = { low: "#10b981", @@ -281,6 +284,7 @@ export default function CoverPage() { return; } const txHash = await signAndSubmit(result.txXdr, wallet.address, wallet.networkPassphrase, { hardware: wallet.hardwareWallet }); + publishAccountBalanceDelta(wallet.address, "USDC", -fromStroops(result.policy.premium)); setSubmission({ status: "success", result, demo: false, txHash }); updateTransaction(txId, { status: "confirmed", txHash }); } catch (err) { @@ -394,7 +398,8 @@ export default function CoverPage() {
{/* STEP 1: Select Type */} {currentStep === 1 && ( -
+
Coverage types are temporarily unavailable. Try again later or open the Dashboard.
}> +
Step 1 of 4: Select Coverage Type
@@ -405,30 +410,13 @@ export default function CoverPage() { )} - {typesLoading && ( -
- {Array.from({ length: 5 }).map((_, i -
- - {typesError && ( - -

Couldn't load coverage types: {typesError}

-
- )} - - {typesLoading && ( + {typesLoading && !coverageTypes && (
{Array.from({ length: 5 }).map((_, i) => ( ))}
- } - errorRender={(err) => ( - -

Couldn't load coverage types: {err}

-
)} - > {coverageTypes && (
)} - -
-
-
+
+ )} {/* STEP 2: Trigger Parameters */} @@ -690,6 +676,22 @@ export default function CoverPage() {
setDurationDays(Number(event.target.value))} + className="w-full accent-violet-500" + /> + +
+ + +
+ + )} + {/* Right: Quote panel */}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 6bcaa5d..a847ab9 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -17,6 +17,8 @@ import { import type { Policy } from "@/lib/api/policies"; import type { ClaimRecord } from "@/lib/api/claims"; import { normalizePolicies, normalizeClaims, selectPolicyStatus, type PolicyStatus } from "@/lib/entities/policies"; +import { RealtimeBoundary } from "@/components/realtime/RealtimeBoundary"; +import { RealtimeProfiler } from "@/lib/realtime/devProfiler"; const COVERAGE_ICONS = ["🪙", "📉", "🛡️", "🔐", "✈️"]; const COVERAGE_COLORS = ["#8b5cf6", "#f59e0b", "#10b981", "#ef4444", "#06b6d4"]; @@ -74,7 +76,8 @@ export default function DashboardPage() {
} /> -
+

Live claim updates are temporarily unavailable. Reconnect to retry; cached policy data remains available.

}> +
@@ -230,7 +233,8 @@ export default function DashboardPage() { )} -
+
+
diff --git a/src/app/page.tsx b/src/app/page.tsx index 38753ab..1e87f69 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -14,6 +14,8 @@ import { formatUsd, formatRelativeTime, fromStroops } from "@/lib/format"; import { truncateAddress } from "@/lib/wallet/WalletProvider"; import { ACTIVE_NETWORK } from "@/lib/network"; import { HowItWorks } from "@/components/HowItWorks"; +import { RealtimeBoundary } from "@/components/realtime/RealtimeBoundary"; +import { RealtimeProfiler } from "@/lib/realtime/devProfiler"; function Counter({ to, prefix = "", suffix = "", decimals = 0 }: { to: number; prefix?: string; suffix?: string; decimals?: number }) { const [val, setVal] = useState(0); @@ -176,7 +178,8 @@ export default function Home() { {/* Hero right: Live coverage card */}
- +

Live oracle readings are unavailable. Other Refract sections remain available.

}> +
Live Oracle Status @@ -246,10 +249,11 @@ export default function Home() { )}
- {oracleStatus.isFixture ? "Example data — Refract API unreachable" : "Oracles auto-refreshed every 60 seconds"} + {oracleStatus.isStale ? "Saved readings — refreshing live data…" : oracleStatus.isFixture ? "Example data — Refract API unreachable" : "Oracles auto-refreshed every 60 seconds"}
-
+
+ diff --git a/src/components/NotificationCenter.tsx b/src/components/NotificationCenter.tsx index f5a5658..d4af326 100644 --- a/src/components/NotificationCenter.tsx +++ b/src/components/NotificationCenter.tsx @@ -21,7 +21,7 @@ const SEVERITY_COLORS: Record(null); const buttonRef = useRef(null); @@ -88,6 +88,7 @@ export function NotificationCenter() {
Notifications + {isStale && Saved} {unreadCount > 0 && ( {unreadCount} new diff --git a/src/components/layout/Navbar.test.tsx b/src/components/layout/Navbar.test.tsx new file mode 100644 index 0000000..84a60f5 --- /dev/null +++ b/src/components/layout/Navbar.test.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ACTIVE_NETWORK } from "@/lib/network"; +import { realtimeClient } from "@/lib/realtime/client"; +import { Navbar } from "./Navbar"; + +vi.mock("@/lib/wallet/WalletProvider", () => ({ + useWallet: () => ({ status: "connected", address: "GTESTACCOUNT" }), + truncateAddress: (address: string) => address, +})); + +afterEach(() => vi.unstubAllGlobals()); + +describe("Navbar account balance", () => { + it("reflects a pushed balance without a page reload", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + sequence: "12", + balances: [{ asset_type: "credit_alphanum4", asset_code: "USDC", asset_issuer: "GISSUER", balance: "12.5000000" }], + }), + })); + render(React.createElement(Navbar)); + await waitFor(() => expect(screen.getByLabelText("Live wallet balance")).toHaveTextContent("12.50 USDC")); + realtimeClient.dispatch(`account:${ACTIVE_NETWORK.id}:GTESTACCOUNT`, { + balances: [{ asset: "USDC:GISSUER", balance: "11.2500000", assetType: "credit_alphanum4", decimals: 7 }], + sequence: "13", updatedAt: Date.now(), + }); + await waitFor(() => expect(screen.getByLabelText("Live wallet balance")).toHaveTextContent("11.25 USDC")); + }); +}); diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index e69de29..f1dd1c1 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -0,0 +1,25 @@ +"use client"; +import Link from "next/link"; +import { useWallet } from "@/lib/wallet/WalletProvider"; +import { useAccountBalance } from "@/hooks/useAccountBalance"; +import { truncateAddress } from "@/lib/wallet/WalletProvider"; +import { ACTIVE_NETWORK } from "@/lib/network"; + +export function Navbar({ right }: { right?: React.ReactNode }) { + const wallet = useWallet(); + const account = useAccountBalance(wallet.status === "connected" ? wallet.address : null); + const usdc = account.balances.find((balance) => balance.asset === "USDC" || balance.asset.startsWith("USDC:")); + return ( +
+ +
+ ); +} diff --git a/src/components/realtime/RealtimeBoundary.test.ts b/src/components/realtime/RealtimeBoundary.test.ts new file mode 100644 index 0000000..cde23b0 --- /dev/null +++ b/src/components/realtime/RealtimeBoundary.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { RealtimeBoundary } from "./RealtimeBoundary"; +import { realtimeClient } from "@/lib/realtime/client"; + +function Broken() { throw new Error("bad decoded value"); } + +describe("RealtimeBoundary", () => { + it("falls back and degrades only the failed topic", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + render(React.createElement(React.Fragment, null, + React.createElement(RealtimeBoundary, { topic: "oracle_update", route: "/", fallback: () => React.createElement("p", null, "REST fallback") }, React.createElement(Broken)), + React.createElement(RealtimeBoundary, { topic: "pool_stats", route: "/provide", fallback: () => React.createElement("p", null, "Other fallback") }, React.createElement("p", null, "Pool remains live")), + )); + expect(screen.getByText("REST fallback")).toBeTruthy(); + expect(screen.getByText("Pool remains live")).toBeTruthy(); + expect(realtimeClient.isDegraded("oracle_update")).toBe(true); + expect(realtimeClient.isDegraded("pool_stats")).toBe(false); + error.mockRestore(); + }); +}); +import React from "react"; diff --git a/src/components/realtime/RealtimeBoundary.ts b/src/components/realtime/RealtimeBoundary.ts new file mode 100644 index 0000000..6a8968a --- /dev/null +++ b/src/components/realtime/RealtimeBoundary.ts @@ -0,0 +1,32 @@ +"use client"; + +import React from "react"; +import { realtimeClient } from "@/lib/realtime/client"; + +interface Props { + topic: string; + route: string; + fallback: (retry: () => void) => React.ReactNode; + children: React.ReactNode; + onRetry?: () => void; +} +interface State { failed: boolean } + +export class RealtimeBoundary extends React.Component { + state: State = { failed: false }; + static getDerivedStateFromError() { return { failed: true }; } + componentDidMount() { this.listen = realtimeClient.onError(this.props.topic, (error) => { + realtimeClient.markDegraded(this.props.topic); + this.setState({ failed: true }); + console.error("Realtime boundary fallback", { topic: this.props.topic, route: this.props.route, transport: "websocket", error: error.name }); + }); } + componentWillUnmount() { this.listen?.(); } + private listen?: () => void; + componentDidCatch(error: Error) { realtimeClient.markDegraded(this.props.topic, error, this.props.route); } + retry = () => { + realtimeClient.retry(this.props.topic); + this.setState({ failed: false }); + this.props.onRetry?.(); + }; + render() { return this.state.failed ? this.props.fallback(this.retry) : this.props.children; } +} diff --git a/src/hooks/useAccountBalance.test.ts b/src/hooks/useAccountBalance.test.ts new file mode 100644 index 0000000..1ebe05a --- /dev/null +++ b/src/hooks/useAccountBalance.test.ts @@ -0,0 +1,46 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ACTIVE_NETWORK } from "@/lib/network"; +import { realtimeClient } from "@/lib/realtime/client"; +import { useAccountBalance } from "./useAccountBalance"; + +const ADDRESS = "GTESTACCOUNT"; +const accountResponse = (balance: string, sequence = "12") => ({ + balances: [{ asset_type: "credit_alphanum4", asset_code: "USDC", asset_issuer: "GISSUER", balance }], + sequence, +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe("useAccountBalance", () => { + it("starts from Horizon and applies authoritative account pushes", async () => { + const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => accountResponse("12.5000000") }); + vi.stubGlobal("fetch", fetch); + const { result } = renderHook(() => useAccountBalance(ADDRESS)); + await waitFor(() => expect(result.current.balances[0]?.balance).toBe("12.5000000")); + + act(() => realtimeClient.dispatch(`account:${ACTIVE_NETWORK.id}:${ADDRESS}`, { + balances: [{ asset: "USDC:GISSUER", balance: "11.2500000", assetType: "credit_alphanum4", decimals: 7 }], + sequence: "13", updatedAt: Date.now(), + })); + await waitFor(() => expect(result.current.sequence).toBe("13")); + expect(result.current.balances[0].balance).toBe("11.2500000"); + expect(result.current.isStale).toBe(false); + }); + + it("treats an unfunded account as an empty account without an error", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ status: 404, ok: false })); + const { result } = renderHook(() => useAccountBalance(ADDRESS)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.balances).toEqual([]); + expect(result.current.sequence).toBe("0"); + }); + + it("keeps a network failure in loading fallback state without throwing", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + const { result } = renderHook(() => useAccountBalance(ADDRESS)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.balances).toEqual([]); + expect(result.current.isStale).toBe(true); + }); +}); diff --git a/src/hooks/useAccountBalance.ts b/src/hooks/useAccountBalance.ts new file mode 100644 index 0000000..1215624 --- /dev/null +++ b/src/hooks/useAccountBalance.ts @@ -0,0 +1,58 @@ +"use client"; +import { useCallback, useEffect, useState } from "react"; +import { ACTIVE_NETWORK } from "@/lib/network"; +import { useRealtimeTopic } from "./useRealtimeTopic"; + +export interface AccountBalance { asset: string; balance: string; assetType: string; decimals: number } +export interface AccountState { balances: AccountBalance[]; sequence: string; updatedAt: number } +const OPTIMISTIC_EVENT = "refract:account-balance-delta"; +export function publishAccountBalanceDelta(address: string, asset: string, delta: number) { + if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(OPTIMISTIC_EVENT, { detail: { address, asset, delta } })); +} + +async function fetchAccount(address: string, signal: AbortSignal): Promise { + const response = await fetch(`${ACTIVE_NETWORK.horizonUrl}/accounts/${address}`, { signal }); + if (response.status === 404) return { balances: [], sequence: "0", updatedAt: Date.now() }; + if (!response.ok) throw new Error(`Horizon account request failed (${response.status})`); + const account = await response.json(); + return { balances: (account.balances ?? []).map((item: { asset_type: string; asset_code?: string; asset_issuer?: string; balance: string }) => ({ + asset: item.asset_type === "native" ? "XLM" : `${item.asset_code ?? "UNKNOWN"}:${item.asset_issuer ?? ""}`, + balance: item.balance, assetType: item.asset_type, decimals: 7, + })), sequence: String(account.sequence), updatedAt: Date.now() }; +} + +export function useAccountBalance(address: string | null) { + const topic = address ? `account:${ACTIVE_NETWORK.id}:${address}` : "account:disconnected"; + const fetcher = useCallback((signal: AbortSignal) => address + ? fetchAccount(address, signal) + : Promise.resolve({ balances: [], sequence: "0", updatedAt: Date.now() }), [address]); + const { data, isStale, isLoading, refresh } = useRealtimeTopic( + topic, fetcher, address ?? undefined, Boolean(address), + ); + const [optimistic, setOptimistic] = useState<{ address: string; balances: AccountBalance[] } | null>(null); + useEffect(() => setOptimistic(null), [address]); + useEffect(() => { + if (!address) return; + const listener = (event: Event) => { + const detail = (event as CustomEvent<{ address: string; asset: string; delta: number }>).detail; + if (detail.address !== address) return; + setOptimistic((previous) => { + const balances = previous?.address === address ? previous.balances : data?.balances ?? []; + return { address, balances: balances.map((entry) => + entry.asset === detail.asset || entry.asset.startsWith(`${detail.asset}:`) + ? { ...entry, balance: String(Math.max(0, Number(entry.balance) + detail.delta)) } : entry) }; + }); + setTimeout(() => { void refresh().catch(() => undefined); }, 2_500); + }; + window.addEventListener(OPTIMISTIC_EVENT, listener); + return () => window.removeEventListener(OPTIMISTIC_EVENT, listener); + }, [address, data, refresh]); + const deduct = useCallback((asset: string, amount: number) => { + if (!address) return; + setOptimistic({ address, balances: (data?.balances ?? []).map((entry) => entry.asset === asset + ? { ...entry, balance: String(Math.max(0, Number(entry.balance) - amount)) } : entry) }); + }, [data]); + useEffect(() => { if (data && !isStale) setOptimistic(null); }, [data, isStale]); + const visibleOptimistic = optimistic?.address === address ? optimistic.balances : null; + return { balances: visibleOptimistic ?? data?.balances ?? [], sequence: data?.sequence ?? "0", updatedAt: data?.updatedAt ?? null, isStale, isLoading, deduct }; +} diff --git a/src/hooks/useClaims.ts b/src/hooks/useClaims.ts index e69de29..81b896f 100644 --- a/src/hooks/useClaims.ts +++ b/src/hooks/useClaims.ts @@ -0,0 +1,24 @@ +"use client"; +import { useCallback, useMemo } from "react"; +import { fetchHolderClaims, fetchRecentClaims, type ClaimRecord } from "@/lib/api/claims"; +import type { Policy } from "@/lib/api/policies"; +import { ACTIVE_NETWORK } from "@/lib/network"; +import { useWallet } from "@/lib/wallet/WalletProvider"; +import { useRealtimeTopic } from "./useRealtimeTopic"; + +export type ClaimsResult = ClaimRecord[] & { data: ClaimRecord[] | null; loading: boolean; isStale: boolean }; + +export function useClaims(addressArg?: string | null, _policies?: Policy[] | null): ClaimsResult { + const wallet = useWallet(); + const address = addressArg === undefined + ? (wallet.status === "connected" ? wallet.address : null) + : addressArg; + const load = useCallback(async (signal: AbortSignal) => address + ? (await fetchHolderClaims(address, {}, signal)).items + : (await fetchRecentClaims({ limit: 50 }, signal)).items, [address]); + const topic = address ? `claims:${ACTIVE_NETWORK.id}:${address}` : "claim_settlement"; + const { data, isLoading, isStale } = useRealtimeTopic(topic, load, address ?? undefined); + return useMemo(() => Object.assign([...(data ?? [])], { + data: data ?? (address ? null : []), loading: isLoading, isStale, + }) as ClaimsResult, [data, address, isLoading, isStale]); +} diff --git a/src/hooks/useCoverageTypes.ts b/src/hooks/useCoverageTypes.ts index e69de29..2e7e7ff 100644 --- a/src/hooks/useCoverageTypes.ts +++ b/src/hooks/useCoverageTypes.ts @@ -0,0 +1,22 @@ +"use client"; +import { useCallback, useState } from "react"; +import { fetchCoverageTypes, type CoverageTypeInfo } from "@/lib/api/policies"; +import { FIXTURE_COVERAGE_TYPES } from "@/lib/fixtures/coverageTypes"; +import { useRealtimeTopic } from "./useRealtimeTopic"; + +export function useCoverageTypes() { + const [error, setError] = useState(null); + const [isFixture, setFixture] = useState(false); + const load = useCallback(async (signal: AbortSignal): Promise => { + try { + const response = await fetchCoverageTypes(signal); + setError(null); setFixture(false); return response.coverageTypes; + } catch (reason) { + setFixture(true); + setError(reason instanceof Error ? reason.message : "Coverage data unavailable"); + return FIXTURE_COVERAGE_TYPES; + } + }, []); + const { data, isLoading, isStale } = useRealtimeTopic("coverage_types", load); + return { data, loading: isLoading, error, isFixture, isStale }; +} diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index e8fe606..ab17c0f 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -1,11 +1,13 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ACTIVE_NETWORK } from "@/lib/network"; import { useWallet } from "@/lib/wallet/WalletProvider"; import { useHolderPolicies } from "./useHolderPolicies"; import { useClaims } from "./useClaims"; import { useLockupStatus } from "./useLockupStatus"; import { useOracleStatus } from "./useOracleStatus"; +import { useRealtimeTopic } from "./useRealtimeTopic"; import { deriveNotifications, type RefractNotification, @@ -44,7 +46,7 @@ export function useNotifications() { } }; - const notifications = useMemo(() => { + const derivedNotifications = useMemo(() => { return deriveNotifications({ policies, claims, @@ -54,6 +56,11 @@ export function useNotifications() { }); }, [policies, claims, lockupExpiresAt, oracleReadings, readIds]); + const loadNotifications = useCallback(async (_signal: AbortSignal) => derivedNotifications, [derivedNotifications]); + const notificationTopic = `notifications:${ACTIVE_NETWORK.id}:${address ?? "public"}`; + const { data: cachedOrPushed, isStale } = useRealtimeTopic(notificationTopic, loadNotifications, address ?? undefined); + const notifications = cachedOrPushed ?? derivedNotifications; + const unreadCount = useMemo(() => { return notifications.filter((n) => !n.read).length; }, [notifications]); @@ -77,5 +84,6 @@ export function useNotifications() { unreadCount, markAsRead, markAllAsRead, + isStale, }; } diff --git a/src/hooks/useOracleStatus.ts b/src/hooks/useOracleStatus.ts index e69de29..8282bb8 100644 --- a/src/hooks/useOracleStatus.ts +++ b/src/hooks/useOracleStatus.ts @@ -0,0 +1,35 @@ +"use client"; +import { useCallback, useEffect, useState } from "react"; +import { fetchOracleStatus, type OracleReading } from "@/lib/api/oracle"; +import { FIXTURE_ORACLE_READINGS } from "@/lib/fixtures/oracle"; +import { useRealtimeTopic } from "./useRealtimeTopic"; + +export function useOracleStatus() { + const [lastUpdated, setLastUpdated] = useState(null); + const load = useCallback(async (signal: AbortSignal) => { + try { + const response = await fetchOracleStatus(signal); + setLastUpdated(new Date()); setFixture(false); + return response.readings; + } catch { + setFixture(true); + return FIXTURE_ORACLE_READINGS; + } + }, []); + const { data, isStale, isLoading, updatedAt, refresh: refreshTopic } = useRealtimeTopic("oracle_update", load); + const [isFixture, setFixture] = useState(false); + const [isRefreshing, setRefreshing] = useState(false); + useEffect(() => { if (updatedAt) setLastUpdated(updatedAt); }, [updatedAt]); + useEffect(() => { if (data && data !== FIXTURE_ORACLE_READINGS) setFixture(false); }, [data]); + useEffect(() => { + const timer = setInterval(() => { void refreshTopic().catch(() => setFixture(true)); }, 60_000); + return () => clearInterval(timer); + }, [refreshTopic]); + const refresh = useCallback(async () => { + setRefreshing(true); + try { const value = await refreshTopic(); setLastUpdated(new Date()); return value; } + catch { setFixture(true); return FIXTURE_ORACLE_READINGS; } + finally { setRefreshing(false); } + }, [refreshTopic]); + return { data: data ?? FIXTURE_ORACLE_READINGS, loading: isLoading, isFixture, isStale, lastUpdated, isRefreshing, refresh }; +} diff --git a/src/hooks/usePoolStats.ts b/src/hooks/usePoolStats.ts index e69de29..4568da2 100644 --- a/src/hooks/usePoolStats.ts +++ b/src/hooks/usePoolStats.ts @@ -0,0 +1,27 @@ +"use client"; +import { useCallback, useState } from "react"; +import { fetchPoolStats, type PoolStats } from "@/lib/api/pool"; +import { ApiUnreachableError } from "@/lib/api/client"; +import { readPoolStats } from "@/lib/stellar/rpc"; +import { FIXTURE_POOL_STATS } from "@/lib/fixtures/poolStats"; +import { useRealtimeTopic } from "./useRealtimeTopic"; + +export function usePoolStats() { + const [dataSource, setDataSource] = useState<"api" | "chain" | "fixture" | null>(null); + const [error, setError] = useState(null); + const load = useCallback(async (signal: AbortSignal): Promise => { + try { + const result = await fetchPoolStats(signal); + setDataSource("api"); setError(null); return result; + } catch (apiError) { + if (!(apiError instanceof ApiUnreachableError)) { + setDataSource(null); setError(apiError instanceof Error ? apiError.message : "Failed to load pool stats"); + throw apiError; + } + try { const result = await readPoolStats(); setDataSource("chain"); setError(null); return result; } + catch { setDataSource("fixture"); setError(null); return FIXTURE_POOL_STATS; } + } + }, []); + const { data, isLoading, isStale } = useRealtimeTopic("pool_stats", load); + return { data, loading: isLoading, error, dataSource, isFixture: dataSource === "fixture", isStale }; +} diff --git a/src/hooks/useRealtimeTopic.ts b/src/hooks/useRealtimeTopic.ts new file mode 100644 index 0000000..4917d05 --- /dev/null +++ b/src/hooks/useRealtimeTopic.ts @@ -0,0 +1,69 @@ +"use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ACTIVE_NETWORK } from "@/lib/network"; +import { realtimeClient } from "@/lib/realtime/client"; +import { readSnapshot, readSnapshotSync, writeSnapshot } from "@/lib/realtime/snapshotCache"; + +export function useRealtimeTopic(topic: string, fetchFallback: (signal: AbortSignal) => Promise, wallet?: string, enabled = true) { + const [initialSnapshot] = useState(() => readSnapshotSync(topic, ACTIVE_NETWORK.id, wallet)); + const [stored, setStored] = useState<{ topic: string; value: T | null }>(() => ({ topic, value: initialSnapshot?.value ?? null })); + const [isStale, setStale] = useState(true); + const [isLoading, setLoading] = useState(true); + const [updatedAt, setUpdatedAt] = useState(() => initialSnapshot ? new Date(initialSnapshot.capturedAt) : null); + const revisionRef = useRef(0); + const generationRef = useRef(0); + const refresh = useCallback(async () => { + const controller = new AbortController(); + const revision = revisionRef.current; + const generation = generationRef.current; + setLoading(true); + try { + const value = await fetchFallback(controller.signal); + if (generationRef.current !== generation || revisionRef.current !== revision) return value; + revisionRef.current += 1; + setStored({ topic, value }); setStale(false); setLoading(false); writeSnapshot(topic, ACTIVE_NETWORK.id, value, wallet); + setUpdatedAt(new Date()); + return value; + } catch (error) { if (generationRef.current === generation) setLoading(false); throw error; } + }, [fetchFallback, topic, wallet]); + useEffect(() => { + let current = true; + const generation = ++generationRef.current; + if (!enabled) { setStored({ topic, value: null }); setLoading(false); return () => { current = false; }; } + setStale(true); setLoading(true); + revisionRef.current = 0; + const controller = new AbortController(); + void readSnapshot(topic, ACTIVE_NETWORK.id, wallet).then((snapshot) => { + if (current && generationRef.current === generation && snapshot && revisionRef.current === 0) { setStored({ topic, value: snapshot.value }); setUpdatedAt(new Date(snapshot.capturedAt)); } + }); + const off = realtimeClient.subscribe(topic, (payload) => { + if (!current || realtimeClient.isDegraded(topic)) return; + revisionRef.current += 1; + setStored({ topic, value: payload as T }); setStale(false); setLoading(false); + setUpdatedAt(new Date()); + writeSnapshot(topic, ACTIVE_NETWORK.id, payload as T, wallet); + }); + const offError = realtimeClient.onError(topic, () => { + if (!current || controller.signal.aborted) return; + const revision = revisionRef.current; + fetchFallback(controller.signal).then((value) => { + if (!current || generationRef.current !== generation || revisionRef.current !== revision) return; + revisionRef.current += 1; + setStored({ topic, value }); setStale(false); setLoading(false); writeSnapshot(topic, ACTIVE_NETWORK.id, value, wallet); + setUpdatedAt(new Date()); + }).catch(() => { if (current) setLoading(false); }); + }); + const startingRevision = revisionRef.current; + fetchFallback(controller.signal).then((value) => { + if (!current || generationRef.current !== generation || revisionRef.current !== startingRevision) return; + revisionRef.current += 1; + setStored({ topic, value }); setStale(false); setLoading(false); writeSnapshot(topic, ACTIVE_NETWORK.id, value, wallet); + setUpdatedAt(new Date()); + }).catch(() => { if (current) setLoading(false); }); + return () => { current = false; controller.abort(); off(); offError(); }; + }, [topic, wallet, fetchFallback, enabled]); + const data = stored.topic === topic + ? stored.value + : readSnapshotSync(topic, ACTIVE_NETWORK.id, wallet)?.value ?? null; + return { data, isStale, isLoading, updatedAt, isDegraded: realtimeClient.isDegraded(topic), refresh }; +} diff --git a/src/hooks/useRecentClaims.ts b/src/hooks/useRecentClaims.ts index e69de29..f00465f 100644 --- a/src/hooks/useRecentClaims.ts +++ b/src/hooks/useRecentClaims.ts @@ -0,0 +1,16 @@ +"use client"; +import { useCallback, useEffect, useState } from "react"; +import { fetchRecentClaims, type ClaimRecord } from "@/lib/api/claims"; +import { FIXTURE_RECENT_CLAIMS } from "@/lib/fixtures/recentClaims"; +import { useRealtimeTopic } from "./useRealtimeTopic"; + +export function useRecentClaims() { + const load = useCallback(async (signal: AbortSignal) => { + try { const result = await fetchRecentClaims({ limit: 10 }, signal); setFixture(false); return result.items; } + catch { setFixture(true); return FIXTURE_RECENT_CLAIMS; } + }, []); + const { data, isStale, isLoading } = useRealtimeTopic("claim_settlement", load); + const [isFixture, setFixture] = useState(false); + useEffect(() => { if (data && data !== FIXTURE_RECENT_CLAIMS) setFixture(false); }, [data]); + return { data: data ?? FIXTURE_RECENT_CLAIMS, loading: isLoading, isFixture, isStale }; +} diff --git a/src/lib/realtime/client.ts b/src/lib/realtime/client.ts index 8be8d3c..04d24d6 100644 --- a/src/lib/realtime/client.ts +++ b/src/lib/realtime/client.ts @@ -1,6 +1,7 @@ import { API_BASE_URL } from "@/lib/api/client"; type Handler = (payload: unknown) => void; +export type RealtimeErrorHandler = (error: Error) => void; const MAX_BACKOFF_MS = 30_000; const BASE_BACKOFF_MS = 1_000; @@ -20,6 +21,8 @@ class RealtimeClient { private attempt = 0; private closedByUser = false; private reconnectTimer: ReturnType | null = null; + private errors = new Map>(); + private degraded = new Set(); connect(): void { if (typeof WebSocket === "undefined" || this.socket) return; @@ -42,7 +45,12 @@ class RealtimeClient { this.socket.addEventListener("message", (event) => { try { const { topic, payload } = JSON.parse(event.data as string) as { topic: string; payload: unknown }; - this.handlers.get(topic)?.forEach((handler) => handler(payload)); + if (typeof topic !== "string") return; + this.handlers.get(topic)?.forEach((handler) => { + try { handler(payload); } catch (error) { + this.report(topic, error instanceof Error ? error : new Error("Realtime handler failed")); + } + }); } catch { // Malformed message — ignore, don't crash the connection. } @@ -76,6 +84,32 @@ class RealtimeClient { }; } + onError(topic: string, handler: RealtimeErrorHandler): () => void { + if (!this.errors.has(topic)) this.errors.set(topic, new Set()); + this.errors.get(topic)!.add(handler); + return () => this.errors.get(topic)?.delete(handler); + } + + markDegraded(topic: string, error?: Error, route = "unknown"): void { + if (this.degraded.has(topic)) return; + this.degraded.add(topic); + if (error) console.error("Realtime topic degraded", { topic, route, transport: "websocket", error: error.name }); + this.errors.get(topic)?.forEach((handler) => handler(error ?? new Error("Realtime topic degraded"))); + } + + retry(topic: string): void { this.degraded.delete(topic); } + isDegraded(topic: string): boolean { return this.degraded.has(topic); } + + private report(topic: string, error: Error, route = "unknown"): void { + // Context only: never include a payload or address in diagnostic output. + this.markDegraded(topic, error, route); + } + + /** Deterministic entry point used by local benchmark and unit harnesses. */ + dispatch(topic: string, payload: unknown): void { + this.handlers.get(topic)?.forEach((handler) => handler(payload)); + } + disconnect(): void { this.closedByUser = true; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); diff --git a/src/lib/realtime/devProfiler.test.ts b/src/lib/realtime/devProfiler.test.ts new file mode 100644 index 0000000..eafcdf5 --- /dev/null +++ b/src/lib/realtime/devProfiler.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { shouldProfileRealtime } from "./devProfiler"; + +describe("realtime profiler production guard", () => { + it("disables measurement for production bundles", async () => { + expect(shouldProfileRealtime("production")).toBe(false); + expect(shouldProfileRealtime("development", "1")).toBe(true); + expect(shouldProfileRealtime("development", undefined)).toBe(false); + }); +}); diff --git a/src/lib/realtime/devProfiler.ts b/src/lib/realtime/devProfiler.ts new file mode 100644 index 0000000..d0d5b02 --- /dev/null +++ b/src/lib/realtime/devProfiler.ts @@ -0,0 +1,23 @@ +"use client"; +import React, { Profiler, type ProfilerOnRenderCallback } from "react"; + +export const shouldProfileRealtime = ( + environment = process.env.NODE_ENV, + enabledFlag = process.env.NEXT_PUBLIC_REALTIME_PROFILER, +) => environment !== "production" && enabledFlag === "1"; +const enabled = shouldProfileRealtime(); +const totals = new Map(); +const onRender: ProfilerOnRenderCallback = (topic, _phase, actualDuration) => { + const item = totals.get(topic) ?? { commits: 0, durations: [] }; + item.commits += 1; item.durations.push(actualDuration); totals.set(topic, item); + if (enabled && typeof window !== "undefined") { + (window as Window & { __REFRACT_REALTIME_PROFILE__?: unknown }).__REFRACT_REALTIME_PROFILE__ = getRealtimeProfile(); + } +}; +export function RealtimeProfiler({ topic, children }: { topic: string; children: React.ReactNode }) { + return enabled ? React.createElement(Profiler, { id: topic, onRender }, children) : React.createElement(React.Fragment, null, children); +} +export function getRealtimeProfile() { + return Object.fromEntries([...totals].map(([topic, result]) => [topic, { commits: result.commits, durations: [...result.durations] }])); +} +export function resetRealtimeProfile() { totals.clear(); } diff --git a/src/lib/realtime/snapshotCache.test.ts b/src/lib/realtime/snapshotCache.test.ts new file mode 100644 index 0000000..9becd55 --- /dev/null +++ b/src/lib/realtime/snapshotCache.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { readSnapshot, writeSnapshot } from "./snapshotCache"; + +describe("realtime snapshot cache", () => { + it("keeps wallet and network snapshots isolated and marks cached values stale", async () => { + writeSnapshot("oracle_update", "testnet", { value: 1 }, "GONE"); + writeSnapshot("oracle_update", "testnet", { value: 2 }, "GTWO"); + writeSnapshot("oracle_update", "mainnet", { value: 3 }, "GONE"); + expect(await readSnapshot("oracle_update", "testnet", "GONE")).toMatchObject({ value: { value: 1 }, stale: true }); + expect(await readSnapshot("oracle_update", "testnet", "GTWO")).toMatchObject({ value: { value: 2 } }); + expect(await readSnapshot("oracle_update", "mainnet", "GONE")).toMatchObject({ value: { value: 3 } }); + }); + + it("discards a value written with an older topic schema", async () => { + writeSnapshot("versioned_topic", "testnet", { old: true }, "GVERSION", 1); + expect(await readSnapshot("versioned_topic", "testnet", "GVERSION", 2)).toBeNull(); + }); +}); diff --git a/src/lib/realtime/snapshotCache.ts b/src/lib/realtime/snapshotCache.ts new file mode 100644 index 0000000..0596202 --- /dev/null +++ b/src/lib/realtime/snapshotCache.ts @@ -0,0 +1,131 @@ +"use client"; + +export interface Snapshot { value: T; capturedAt: number; version: number; stale: true } +const DB_NAME = "refract-realtime-snapshots"; +const STORE = "snapshots"; +const VERSION = 1; +const pending = new Map; timer: ReturnType }>(); +const memory = new Map>(); + +function scopedKey(topic: string, network: string, wallet = "public") { + return `${network}:${wallet.toLowerCase()}:${topic}`; +} +const mirrorKey = (key: string) => `refract:realtime:${key}`; + +/** A tiny synchronous seed mirror lets client renders paint before IndexedDB opens. */ +export function readSnapshotSync(topic: string, network: string, wallet?: string, version = VERSION): Snapshot | null { + if (typeof window === "undefined") return null; + const key = scopedKey(topic, network, wallet); + const warmed = memory.get(key) as Snapshot | undefined; + if (warmed) return warmed.version === version ? { ...warmed, stale: true } : null; + try { + const raw = window.localStorage.getItem(mirrorKey(key)); + if (!raw) return null; + const entry = JSON.parse(raw) as Snapshot; + if (entry.version !== version) { + window.localStorage.removeItem(mirrorKey(key)); + return null; + } + memory.set(key, entry); + return { ...entry, stale: true }; + } catch { return null; } +} +function openDb(): Promise { + if (typeof window === "undefined" || !("indexedDB" in window)) return Promise.resolve(null); + return new Promise((resolve) => { + try { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(STORE)) request.result.createObjectStore(STORE, { keyPath: "key" }); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = request.onblocked = () => resolve(null); + } catch { resolve(null); } + }); +} + +export async function readSnapshot(topic: string, network: string, wallet?: string, version = VERSION): Promise | null> { + if (typeof window === "undefined") return null; + const key = scopedKey(topic, network, wallet); + let value: Snapshot | null = (memory.get(key) as Snapshot | undefined) ?? null; + const db = await openDb(); + if (db) { + const persisted = await new Promise | null>((resolve) => { + try { + const req = db.transaction(STORE).objectStore(STORE).get(key); + req.onsuccess = () => { + const stored = req.result; + const entry = stored?.entry as Snapshot | undefined; + if (entry && entry.version !== version) { + try { db.transaction(STORE, "readwrite").objectStore(STORE).delete(key); } catch { /* optional cache */ } + memory.delete(key); + resolve(null); + } else resolve(entry ?? null); + }; + req.onerror = () => resolve(null); + } catch { resolve(null); } + }); + if (!value || (persisted && persisted.capturedAt > value.capturedAt)) value = persisted; + } + if (value && value.version !== version) { await deleteSnapshot(topic, network, wallet); return null; } + return value ? { ...value, stale: true } : null; +} + +export function writeSnapshot(topic: string, network: string, value: T, wallet?: string, version = VERSION): void { + if (typeof window === "undefined") return; + const key = scopedKey(topic, network, wallet); + const entry: Snapshot = { value, capturedAt: Date.now(), version, stale: true }; + memory.set(key, entry); + try { window.localStorage.setItem(mirrorKey(key), JSON.stringify(entry)); } catch { /* optional sync seed */ } + const previous = pending.get(key); + if (previous) { clearTimeout(previous.timer); previous.entry = entry; previous.timer = setTimeout(() => void flush(key), 300); } + else pending.set(key, { entry, timer: setTimeout(() => void flush(key), 300) }); +} + +async function flush(key: string) { + const item = pending.get(key); if (!item) return; + pending.delete(key); + const db = await openDb(); if (!db) return; + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).put({ key, entry: item.entry }); + tx.oncomplete = () => resolve(); + tx.onerror = tx.onabort = () => reject(tx.error ?? new Error("Snapshot write failed")); + }); + } catch { + // Quota pressure: remove the oldest entry, then retry once. Cache failure + // must never affect the realtime data path. + try { + const tx = db.transaction(STORE, "readwrite"); + const store = tx.objectStore(STORE); + const cursorRequest = store.openCursor(); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (cursor) { + const first = { key: cursor.key, capturedAt: cursor.value?.entry?.capturedAt ?? 0 }; + let oldest = first; + const scan = store.openCursor(); + scan.onsuccess = () => { + const next = scan.result; + if (next) { + const capturedAt = next.value?.entry?.capturedAt ?? 0; + if (capturedAt < oldest.capturedAt) oldest = { key: next.key, capturedAt }; + next.continue(); + } else { + store.delete(oldest.key); + store.put({ key, entry: item.entry }); + } + }; + } + }; + } catch { /* storage is optional */ } + } +} + +export async function deleteSnapshot(topic: string, network: string, wallet?: string): Promise { + const key = scopedKey(topic, network, wallet); memory.delete(key); + try { window.localStorage.removeItem(mirrorKey(key)); } catch { /* optional cache */ } + const db = await openDb(); if (!db) return; + try { db.transaction(STORE, "readwrite").objectStore(STORE).delete(key); } catch { /* storage is optional */ } +} diff --git a/vitest.config.mts b/vitest.config.mts index 3505082..b1c44ef 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,7 +1,14 @@ import { fileURLToPath } from "node:url"; +import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; export default defineConfig({ + plugins: [react()], + oxc: { jsx: { runtime: "automatic" } }, resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, - test: { include: ["src/**/*.test.ts"] }, + test: { + include: ["src/**/*.test.{ts,tsx}"], + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + }, });