From 464b67573567fd8c128c6160a1646a75fec51c94 Mon Sep 17 00:00:00 2001 From: terngunan Date: Wed, 21 Oct 2026 16:26:16 +0000 Subject: [PATCH] feat: harden realtime operations and lifecycle --- .eslintrc.json | 24 +++++++- docs/realtime-operations.md | 19 ++++++ next.config.js | 26 +++++++- public/realtime-control.json | 1 + src/app/providers.tsx | 5 +- src/lib/realtime/allowlist.test.ts | 16 +++++ src/lib/realtime/allowlist.ts | 40 ++++++++++++ src/lib/realtime/client.ts | 74 +++++++++++++++++++--- src/lib/realtime/endpointConfig.d.ts | 5 ++ src/lib/realtime/endpointConfig.js | 14 +++++ src/lib/realtime/index.ts | 9 +++ src/lib/realtime/killSwitch.test.ts | 54 +++++++++++++++++ src/lib/realtime/killSwitch.ts | 91 ++++++++++++++++++++++++++++ src/lib/realtime/lifecycle.test.ts | 42 +++++++++++++ src/lib/realtime/lifecycle.ts | 41 +++++++++++++ src/lib/realtime/useRealtimeTopic.ts | 34 +++++++++++ 16 files changed, 481 insertions(+), 14 deletions(-) create mode 100644 docs/realtime-operations.md create mode 100644 public/realtime-control.json create mode 100644 src/lib/realtime/allowlist.test.ts create mode 100644 src/lib/realtime/allowlist.ts create mode 100644 src/lib/realtime/endpointConfig.d.ts create mode 100644 src/lib/realtime/endpointConfig.js create mode 100644 src/lib/realtime/index.ts create mode 100644 src/lib/realtime/killSwitch.test.ts create mode 100644 src/lib/realtime/killSwitch.ts create mode 100644 src/lib/realtime/lifecycle.test.ts create mode 100644 src/lib/realtime/lifecycle.ts create mode 100644 src/lib/realtime/useRealtimeTopic.ts diff --git a/.eslintrc.json b/.eslintrc.json index 3cbf99e..7a4a046 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,17 @@ { "extends": "next/core-web-vitals", "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["@/lib/realtime/*", "**/realtime/**"], + "message": "Import realtime APIs from @/lib/realtime." + } + ] + } + ], "no-restricted-syntax": [ "error", { @@ -12,7 +23,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." }, { @@ -23,7 +34,16 @@ }, "overrides": [ { - "files": ["src/lib/coverage/metadata.ts", "src/lib/coverage/colors.ts", "src/app/global-error.tsx", "src/lib/format.ts"], + "files": ["src/lib/realtime/**"], + "rules": { "no-restricted-imports": "off" } + }, + { + "files": [ + "src/lib/coverage/metadata.ts", + "src/lib/coverage/colors.ts", + "src/app/global-error.tsx", + "src/lib/format.ts" + ], "rules": { "no-restricted-syntax": "off" } diff --git a/docs/realtime-operations.md b/docs/realtime-operations.md new file mode 100644 index 0000000..f897f38 --- /dev/null +++ b/docs/realtime-operations.md @@ -0,0 +1,19 @@ +# Realtime operations and boundaries + +## Emergency disable + +Serve `/realtime-control.json` from the frontend origin or its CDN with a cache lifetime no longer than five seconds and purge it when tripping the switch. Set `{ "disabled": true }` to stop new connections and close active sockets. Clients check at startup, before reconnects, every five seconds, and when a tab becomes visible. Fetches have a three second timeout and never block app startup. A failed fetch keeps the last known state. The switch is latched in a running page; restoring `{ "disabled": false }` does not re-enable a tripped client. Reload clients after the incident is resolved. `tripKillSwitch("manual" | "auto")` is available to operator/developer code and logs the source. The current implementation does not automatically trip on connection failures. + +The checked-in JSON is a safe serving default. Production operations must publish and purge the same JSON through the configured CDN/backend path; frontend-only deployment is not required. Coordinate the control path and cache TTL with refract-backend/on-call before enabling realtime. + +## Endpoint policy + +`NEXT_PUBLIC_API_URL` selects the API and its corresponding `ws:`/`wss:` realtime origin. Keep it set in production, preview, and CI. Next emits `Content-Security-Policy-Report-Only`; review browser reports for one release before changing the header to enforced `Content-Security-Policy`. Add any intentional connection origin through the canonical API environment configuration, then validate the generated header. Development defaults to the local API at port 4001. + +## Public module boundary + +Consumers import supported APIs from `@/lib/realtime`. Files inside `src/lib/realtime/` are implementation details and may change. To make a new API public, add an explicit named export and its public type to `index.ts`; do not export internal helpers wholesale. The lint rule rejects deep imports outside the module, and is disabled only within the module itself. + +## Page lifecycle + +Hiding the document, `pagehide`, and `freeze` suspend the socket and cancel reconnect timers. Becoming visible, `pageshow`, and `resume` reconnect and consumers clear their live payload while suspended; their REST query remains authoritative. This repository currently has no outbound realtime mutation queue or cross-tab leader-election lease, so there is no pending realtime write or leader role to flush/relinquish. diff --git a/next.config.js b/next.config.js index 27722b5..b84ef79 100644 --- a/next.config.js +++ b/next.config.js @@ -2,6 +2,19 @@ const isPreview = process.env.VERCEL_ENV === "preview"; const withBundleAnalyzer = require("@next/bundle-analyzer")({ enabled: process.env.ANALYZE === "true", }); +const { endpointFromApiBase } = require("./src/lib/realtime/endpointConfig"); + +function realtimeConnectSources() { + let endpoint; + try { + endpoint = endpointFromApiBase( + process.env.NEXT_PUBLIC_API_URL || "http://localhost:4001/api/v1" + ); + } catch { + throw new Error("NEXT_PUBLIC_API_URL must be an absolute URL to build the realtime CSP"); + } + return `'self' ${endpoint.websocketOrigin} ${endpoint.apiOrigin}`; +} /** @type {import('next').NextConfig} */ const nextConfig = { @@ -9,9 +22,7 @@ const nextConfig = { env: { // Preview deployments are ephemeral and have no reachable backend, so force // the app's fixture-fallback demo mode instead of leaking a real API URL. - NEXT_PUBLIC_API_URL: isPreview - ? "" - : process.env.NEXT_PUBLIC_API_URL || "", + NEXT_PUBLIC_API_URL: isPreview ? "" : process.env.NEXT_PUBLIC_API_URL || "", }, async headers() { return [ @@ -22,6 +33,15 @@ const nextConfig = { { key: "X-Content-Type-Options", value: "nosniff" }, ], }, + { + source: "/(.*)", + headers: [ + { + key: "Content-Security-Policy-Report-Only", + value: `default-src 'self'; connect-src ${realtimeConnectSources()}`, + }, + ], + }, ]; }, }; diff --git a/public/realtime-control.json b/public/realtime-control.json new file mode 100644 index 0000000..3a65ba7 --- /dev/null +++ b/public/realtime-control.json @@ -0,0 +1 @@ +{ "disabled": false } diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 653e41b..6ee35ae 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { WalletProvider } from "@/lib/wallet/WalletProvider"; +import { bindRealtimeLifecycle, realtimeClient } from "@/lib/realtime"; export function Providers({ children }: { children: React.ReactNode }) { // One client per browser session. Query functions already fold failures @@ -11,6 +12,8 @@ export function Providers({ children }: { children: React.ReactNode }) { () => new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 30_000 } } }) ); + useEffect(() => bindRealtimeLifecycle(realtimeClient), []); + return ( {children} diff --git a/src/lib/realtime/allowlist.test.ts b/src/lib/realtime/allowlist.test.ts new file mode 100644 index 0000000..599e1f5 --- /dev/null +++ b/src/lib/realtime/allowlist.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { buildRealtimeCsp, isAllowedRealtimeUrl, resolveRealtimeEndpoint } from "./allowlist"; + +describe("realtime endpoint allowlist", () => { + it("derives a single websocket origin and CSP connect policy", () => { + const endpoint = resolveRealtimeEndpoint("https://api.example.com/api/v1"); + expect(endpoint.url).toBe("wss://api.example.com/ws"); + expect(isAllowedRealtimeUrl(endpoint.url, endpoint)).toBe(true); + expect(isAllowedRealtimeUrl("wss://other.example/ws", endpoint)).toBe(false); + expect(buildRealtimeCsp(endpoint)).toContain("wss://api.example.com https://api.example.com"); + }); + it("rejects malformed and untrusted origins", () => { + expect(isAllowedRealtimeUrl("javascript:alert(1)")).toBe(false); + expect(isAllowedRealtimeUrl("wss://attacker.example/socket")).toBe(false); + }); +}); diff --git a/src/lib/realtime/allowlist.ts b/src/lib/realtime/allowlist.ts new file mode 100644 index 0000000..5d8237c --- /dev/null +++ b/src/lib/realtime/allowlist.ts @@ -0,0 +1,40 @@ +import { API_BASE_URL } from "../api/client"; +import { endpointFromApiBase } from "./endpointConfig"; + +export type RealtimeEndpoint = { + url: string; + origin: string; + websocketOrigin: string; + sseOrigin: string; +}; + +export function resolveRealtimeEndpoint(apiBaseUrl: string): RealtimeEndpoint { + const config = endpointFromApiBase(apiBaseUrl); + return { + url: config.url, + origin: config.apiOrigin, + websocketOrigin: config.websocketOrigin, + sseOrigin: config.apiOrigin, + }; +} + +export const REALTIME_ENDPOINT = resolveRealtimeEndpoint(API_BASE_URL); + +export function isAllowedRealtimeUrl( + url: string, + endpoint: RealtimeEndpoint = REALTIME_ENDPOINT +): boolean { + try { + const candidate = new URL(url); + return ( + candidate.origin === endpoint.websocketOrigin && + candidate.protocol === (endpoint.websocketOrigin.startsWith("wss:") ? "wss:" : "ws:") + ); + } catch { + return false; + } +} + +export function buildRealtimeCsp(endpoint: RealtimeEndpoint): string { + return `default-src 'self'; connect-src 'self' ${endpoint.websocketOrigin} ${endpoint.sseOrigin}`; +} diff --git a/src/lib/realtime/client.ts b/src/lib/realtime/client.ts index 8be8d3c..ef8413e 100644 --- a/src/lib/realtime/client.ts +++ b/src/lib/realtime/client.ts @@ -1,6 +1,20 @@ -import { API_BASE_URL } from "@/lib/api/client"; +import { API_BASE_URL } from "../api/client"; +import { isAllowedRealtimeUrl, resolveRealtimeEndpoint } from "./allowlist"; +import { + checkRemoteKillSwitch, + isRealtimeDisabled, + startKillSwitchPolling, + subscribeKillSwitch, +} from "./killSwitch"; type Handler = (payload: unknown) => void; +export class RealtimeEndpointError extends Error { + readonly code = "REALTIME_ENDPOINT_NOT_ALLOWED"; + constructor(url: string) { + super(`Realtime endpoint is not allowlisted: ${url}`); + this.name = "RealtimeEndpointError"; + } +} const MAX_BACKOFF_MS = 30_000; const BASE_BACKOFF_MS = 1_000; @@ -14,21 +28,45 @@ const BASE_BACKOFF_MS = 1_000; * best-effort — always keep an independent REST fetch as the source of * truth for initial state. */ -class RealtimeClient { +export class RealtimeClient { private socket: WebSocket | null = null; private handlers = new Map>(); private attempt = 0; private closedByUser = false; private reconnectTimer: ReturnType | null = null; + private removeKillSwitchListener: (() => void) | null = null; + private suspended = false; + private opening = false; connect(): void { - if (typeof WebSocket === "undefined" || this.socket) return; + if ( + typeof WebSocket === "undefined" || + this.socket || + this.opening || + isRealtimeDisabled() || + this.suspended + ) + return; + startKillSwitchPolling(); + if (!this.removeKillSwitchListener) + this.removeKillSwitchListener = subscribeKillSwitch(({ disabled }) => { + if (disabled) { + this.handlers.clear(); + this.stopSocket(); + } + }); this.closedByUser = false; this.open(); } - private open(): void { - const wsUrl = API_BASE_URL.replace(/^http/, "ws").replace(/\/api\/v1$/, "/ws"); + private async open(): Promise { + if (this.opening || isRealtimeDisabled() || this.suspended) return; + this.opening = true; + await checkRemoteKillSwitch(); + this.opening = false; + if (isRealtimeDisabled() || this.suspended || this.closedByUser || this.socket) return; + const wsUrl = resolveRealtimeEndpoint(API_BASE_URL).url; + if (!isAllowedRealtimeUrl(wsUrl)) throw new RealtimeEndpointError(wsUrl); try { this.socket = new WebSocket(wsUrl); } catch { @@ -41,7 +79,10 @@ class RealtimeClient { }); this.socket.addEventListener("message", (event) => { try { - const { topic, payload } = JSON.parse(event.data as string) as { topic: string; payload: unknown }; + const { topic, payload } = JSON.parse(event.data as string) as { + topic: string; + payload: unknown; + }; this.handlers.get(topic)?.forEach((handler) => handler(payload)); } catch { // Malformed message — ignore, don't crash the connection. @@ -49,7 +90,7 @@ class RealtimeClient { }); this.socket.addEventListener("close", () => { this.socket = null; - if (!this.closedByUser) this.scheduleReconnect(); + if (!this.closedByUser && !isRealtimeDisabled() && !this.suspended) this.scheduleReconnect(); }); this.socket.addEventListener("error", () => { this.socket?.close(); @@ -62,11 +103,12 @@ class RealtimeClient { this.attempt += 1; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; - if (!this.closedByUser) this.open(); + if (!this.closedByUser && !isRealtimeDisabled() && !this.suspended) this.open(); }, delay); } subscribe(topic: string, handler: Handler): () => void { + if (isRealtimeDisabled()) return () => undefined; if (!this.handlers.has(topic)) this.handlers.set(topic, new Set()); this.handlers.get(topic)!.add(handler); this.connect(); @@ -78,7 +120,23 @@ class RealtimeClient { disconnect(): void { this.closedByUser = true; + this.stopSocket(); + } + + /** Page lifecycle adapter commands; handlers remain registered for resync. */ + suspend(): void { + this.suspended = true; + this.stopSocket(); + } + resume(): void { + this.suspended = false; + this.closedByUser = false; + this.connect(); + } + + private stopSocket(): void { if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; this.socket?.close(); this.socket = null; } diff --git a/src/lib/realtime/endpointConfig.d.ts b/src/lib/realtime/endpointConfig.d.ts new file mode 100644 index 0000000..e5e0c63 --- /dev/null +++ b/src/lib/realtime/endpointConfig.d.ts @@ -0,0 +1,5 @@ +export function endpointFromApiBase(apiBaseUrl: string): { + url: string; + apiOrigin: string; + websocketOrigin: string; +}; diff --git a/src/lib/realtime/endpointConfig.js b/src/lib/realtime/endpointConfig.js new file mode 100644 index 0000000..3687649 --- /dev/null +++ b/src/lib/realtime/endpointConfig.js @@ -0,0 +1,14 @@ +/** Canonical API-to-realtime endpoint derivation shared by Next config and browser code. */ +function endpointFromApiBase(apiBaseUrl) { + const api = new URL(apiBaseUrl || "http://localhost:4001/api/v1"); + const websocketProtocol = api.protocol === "https:" ? "wss:" : "ws:"; + const websocket = new URL(api.pathname.replace(/\/api\/v1\/?$/, "/ws"), api); + websocket.protocol = websocketProtocol; + return { + url: websocket.toString(), + apiOrigin: api.origin, + websocketOrigin: `${websocket.protocol}//${websocket.host}`, + }; +} + +module.exports = { endpointFromApiBase }; diff --git a/src/lib/realtime/index.ts b/src/lib/realtime/index.ts new file mode 100644 index 0000000..8cc72f3 --- /dev/null +++ b/src/lib/realtime/index.ts @@ -0,0 +1,9 @@ +export { realtimeClient, RealtimeEndpointError } from "./client"; +export type { RealtimeClient } from "./client"; +export { useRealtimeTopic } from "./useRealtimeTopic"; +export { bindRealtimeLifecycle } from "./lifecycle"; +export { REALTIME_ENDPOINT, isAllowedRealtimeUrl } from "./allowlist"; +export type { RealtimeEndpoint } from "./allowlist"; +export const REALTIME_TOPICS = { ORACLE_UPDATE: "oracle_update" } as const; +export { isRealtimeDisabled, getKillSwitchState, tripKillSwitch } from "./killSwitch"; +export type { KillSwitchState, KillSwitchSource } from "./killSwitch"; diff --git a/src/lib/realtime/killSwitch.test.ts b/src/lib/realtime/killSwitch.test.ts new file mode 100644 index 0000000..87270f7 --- /dev/null +++ b/src/lib/realtime/killSwitch.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { RealtimeClient } from "./client"; +import { + applyRemoteKillSwitchConfiguration, + getKillSwitchState, + isRealtimeDisabled, + subscribeKillSwitch, + tripKillSwitch, +} from "./killSwitch"; + +class MockSocket { + static instances: MockSocket[] = []; + listeners = new Map void>>(); + close = vi.fn(() => this.listeners.get("close")?.forEach((listener) => listener())); + addEventListener(name: string, listener: () => void) { + this.listeners.set(name, [...(this.listeners.get(name) ?? []), listener]); + } + constructor() { + MockSocket.instances.push(this); + } +} + +describe("realtime kill switch", () => { + it("defaults enabled and ignores unavailable or clear remote config", () => { + expect(isRealtimeDisabled()).toBe(false); + applyRemoteKillSwitchConfiguration(null); + applyRemoteKillSwitchConfiguration({ disabled: false }); + expect(isRealtimeDisabled()).toBe(false); + }); + + it("closes an active socket immediately when remotely tripped", async () => { + MockSocket.instances = []; + vi.stubGlobal("WebSocket", MockSocket); + const client = new RealtimeClient(); + const unsubscribe = client.subscribe("oracle_update", vi.fn()); + await new Promise((resolve) => setTimeout(resolve, 0)); + const socket = MockSocket.instances[0]; + expect(socket).toBeDefined(); + applyRemoteKillSwitchConfiguration({ disabled: true }); + expect(socket.close).toHaveBeenCalledOnce(); + expect(getKillSwitchState()).toEqual({ disabled: true, source: "remote" }); + unsubscribe(); + vi.unstubAllGlobals(); + }); + + it("keeps a trip latched when later remote values are clear", () => { + const listener = vi.fn(); + const unsubscribe = subscribeKillSwitch(listener); + tripKillSwitch("manual"); + expect(getKillSwitchState().disabled).toBe(true); + expect(listener).toHaveBeenCalledWith({ disabled: true, source: "manual" }); + unsubscribe(); + }); +}); diff --git a/src/lib/realtime/killSwitch.ts b/src/lib/realtime/killSwitch.ts new file mode 100644 index 0000000..f94d8fb --- /dev/null +++ b/src/lib/realtime/killSwitch.ts @@ -0,0 +1,91 @@ +"use client"; + +export type KillSwitchSource = "remote" | "manual" | "auto"; +export type KillSwitchState = { disabled: boolean; source: KillSwitchSource | null }; +const POLL_MS = 5_000; +const TIMEOUT_MS = 3_000; +const listeners = new Set<(state: KillSwitchState) => void>(); +let state: KillSwitchState = { disabled: false, source: null }; +let timer: ReturnType | null = null; +let started = false; +let inflight: Promise | null = null; + +function setState(next: KillSwitchState) { + if (state.disabled === next.disabled && state.source === next.source) return; + state = next; + listeners.forEach((listener) => listener(state)); + if (next.disabled && typeof console !== "undefined") + console.error(`[realtime] disabled (${next.source})`); +} + +export function isRealtimeDisabled(): boolean { + return state.disabled; +} +export function getKillSwitchState(): KillSwitchState { + return state; +} +export function subscribeKillSwitch(listener: (state: KillSwitchState) => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} +export function tripKillSwitch(source: KillSwitchSource = "manual"): void { + setState({ disabled: true, source }); +} + +/** Unknown or unavailable remote values preserve the current safe state. */ +export function applyRemoteKillSwitchConfiguration(config: unknown): void { + if ( + typeof config === "object" && + config !== null && + "disabled" in config && + config.disabled === true + ) { + setState({ disabled: true, source: "remote" }); + } +} + +export async function checkRemoteKillSwitch(): Promise { + if (typeof fetch === "undefined") return; + if (inflight) return inflight; + inflight = (async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const response = await fetch("/realtime-control.json", { + cache: "no-store", + signal: controller.signal, + }); + if (response.ok) { + applyRemoteKillSwitchConfiguration(await response.json()); + // Remote configuration can trip but never clear a local or remote trip. + } + } catch { + /* Keep the last known state; boot and REST remain available. */ + } finally { + clearTimeout(timeout); + } + })().finally(() => { + inflight = null; + }); + return inflight; +} + +export function startKillSwitchPolling(): () => void { + if (typeof window === "undefined") return () => undefined; + if (!started) { + started = true; + const tick = () => { + void checkRemoteKillSwitch().finally(() => { + timer = setTimeout(tick, POLL_MS); + }); + }; + const onVisibility = () => { + if (document.visibilityState === "visible") void checkRemoteKillSwitch(); + }; + document.addEventListener("visibilitychange", onVisibility); + void checkRemoteKillSwitch().finally(() => { + timer = setTimeout(tick, POLL_MS); + }); + } + return () => undefined; +} diff --git a/src/lib/realtime/lifecycle.test.ts b/src/lib/realtime/lifecycle.test.ts new file mode 100644 index 0000000..a3cd794 --- /dev/null +++ b/src/lib/realtime/lifecycle.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { bindRealtimeLifecycle } from "./lifecycle"; + +describe("realtime page lifecycle", () => { + it("suspends on pagehide and reconnects on pageshow", () => { + const target = new EventTarget() as Window; + Object.defineProperty(target, "document", { value: new EventTarget() }); + const client = { suspend: vi.fn(), resume: vi.fn() }; + const unbind = bindRealtimeLifecycle(client, target); + target.dispatchEvent(new Event("pagehide")); + target.dispatchEvent(new Event("pageshow")); + expect(client.suspend).toHaveBeenCalledOnce(); + expect(client.resume).toHaveBeenCalledOnce(); + unbind(); + }); + + it("does not double-suspend when freeze follows pagehide", () => { + const target = new EventTarget() as Window; + Object.defineProperty(target, "document", { value: new EventTarget() }); + const client = { suspend: vi.fn(), resume: vi.fn() }; + bindRealtimeLifecycle(client, target); + target.dispatchEvent(new Event("pagehide")); + target.dispatchEvent(new Event("freeze")); + expect(client.suspend).toHaveBeenCalledOnce(); + }); + + it("pauses while hidden and resumes when visible", () => { + const documentTarget = new EventTarget() as Document; + let visibilityState: DocumentVisibilityState = "visible"; + Object.defineProperty(documentTarget, "visibilityState", { get: () => visibilityState }); + const target = new EventTarget() as Window; + Object.defineProperty(target, "document", { value: documentTarget }); + const client = { suspend: vi.fn(), resume: vi.fn() }; + bindRealtimeLifecycle(client, target); + visibilityState = "hidden"; + documentTarget.dispatchEvent(new Event("visibilitychange")); + visibilityState = "visible"; + documentTarget.dispatchEvent(new Event("visibilitychange")); + expect(client.suspend).toHaveBeenCalledOnce(); + expect(client.resume).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/realtime/lifecycle.ts b/src/lib/realtime/lifecycle.ts new file mode 100644 index 0000000..123248e --- /dev/null +++ b/src/lib/realtime/lifecycle.ts @@ -0,0 +1,41 @@ +import type { RealtimeClient } from "./client"; + +/** Bind browser lifecycle events to the client without making the client DOM-dependent. */ +export function bindRealtimeLifecycle( + client: Pick, + target: Window = window +): () => void { + let frozen = false; + const suspend = () => { + if (!frozen) { + frozen = true; + client.suspend(); + } + }; + const resume = () => { + if (frozen) { + frozen = false; + client.resume(); + } + }; + const onPageHide = () => suspend(); + const onPageShow = () => resume(); + const onFreeze = () => suspend(); + const onResume = () => resume(); + const onVisibilityChange = () => { + if (target.document.visibilityState === "hidden") suspend(); + else resume(); + }; + target.addEventListener("pagehide", onPageHide); + target.addEventListener("pageshow", onPageShow); + target.addEventListener("freeze", onFreeze); + target.addEventListener("resume", onResume); + target.document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + target.removeEventListener("pagehide", onPageHide); + target.removeEventListener("pageshow", onPageShow); + target.removeEventListener("freeze", onFreeze); + target.removeEventListener("resume", onResume); + target.document.removeEventListener("visibilitychange", onVisibilityChange); + }; +} diff --git a/src/lib/realtime/useRealtimeTopic.ts b/src/lib/realtime/useRealtimeTopic.ts new file mode 100644 index 0000000..fcbd9ac --- /dev/null +++ b/src/lib/realtime/useRealtimeTopic.ts @@ -0,0 +1,34 @@ +"use client"; +import { useEffect, useState } from "react"; +import { realtimeClient } from "./client"; +import { isRealtimeDisabled, subscribeKillSwitch } from "./killSwitch"; + +/** Best-effort updates only. Consumers must keep their REST query as source of truth. */ +export function useRealtimeTopic(topic: string): T | null { + const [payload, setPayload] = useState(null); + useEffect(() => { + if (isRealtimeDisabled()) return; + const clear = () => setPayload(null); + const clearWhenHidden = () => { + if (document.visibilityState === "hidden") clear(); + }; + const unsubscribe = realtimeClient.subscribe(topic, (value) => setPayload(value as T)); + const stopWatching = subscribeKillSwitch(({ disabled }) => { + if (disabled) { + unsubscribe(); + setPayload(null); + } + }); + window.addEventListener("pagehide", clear); + window.addEventListener("freeze", clear); + document.addEventListener("visibilitychange", clearWhenHidden); + return () => { + unsubscribe(); + stopWatching(); + window.removeEventListener("pagehide", clear); + window.removeEventListener("freeze", clear); + document.removeEventListener("visibilitychange", clearWhenHidden); + }; + }, [topic]); + return payload; +}