import { useCallback, useMemo, useState } from 'react'; import { useNostr } from '@nostrify/react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { COORDINATOR_STORAGE_KEY, CORDN_RELAY_URL, coordinatorNameFromEvent, type CoordinatorConfig, } from '@/lib/coordinator'; /** The user's chosen Cordn coordinator, persisted device-locally. */ export function useCoordinator() { const [stored, setStored] = useLocalStorage( COORDINATOR_STORAGE_KEY, null, ); const coordinator = stored && stored.pubkey ? stored : null; const setCoordinator = useCallback( (config: Omit) => { setStored({ pubkey: config.pubkey, relays: config.relays, addedAt: Date.now(), }); }, [setStored], ); const removeCoordinator = useCallback(() => setStored(null), [setStored]); return { coordinator, setCoordinator, removeCoordinator }; } export type CoordinatorHealthStatus = | 'idle' | 'checking' | 'online' | 'stale' | 'offline' | 'error'; export interface CoordinatorHealth { status: CoordinatorHealthStatus; /** Unix seconds of the newest event authored by the coordinator. */ lastSeen: number | null; eventCount: number; /** Display name parsed from the announcement, when we can find one. */ name: string | null; error: string | null; checkedAt: number | null; /** Actual ContextVM RPC probe, not just presence. */ rpcStatus: 'idle' | 'checking' | 'ok' | 'error'; rpcLatencyMs: number | null; rpcError: string | null; } /** * Same freshness rule as the CVM server probe: ContextVM servers re-announce on * a heartbeat, so allow a few missed beats before calling it stale. */ const STALE_AFTER_MS = 45 * 60 * 1000; const INITIAL: CoordinatorHealth = { status: 'idle', lastSeen: null, eventCount: 0, name: null, error: null, checkedAt: null, rpcStatus: 'idle', rpcLatencyMs: null, rpcError: null, }; function randomKeyHex(): string { const bytes = new Uint8Array(32); if (globalThis.crypto?.getRandomValues) { globalThis.crypto.getRandomValues(bytes); } else { for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256); } return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } async function probeCoordinatorRpc( coordinator: CoordinatorConfig, relays: string[], ): Promise> { let client: { listAvailableKeyPackages: () => Promise; disconnect: () => Promise } | null = null; const started = Date.now(); try { const { CordnCoordinatorClient } = await import('@/lib/cordn/native/coordinator'); client = new CordnCoordinatorClient({ serverPubkey: coordinator.pubkey, relays, privateKeyHex: randomKeyHex(), }); await Promise.race([ client.listAvailableKeyPackages(), new Promise((_, reject) => setTimeout(() => reject(new Error('Coordinator RPC timed out.')), 7_000), ), ]); return { rpcStatus: 'ok', rpcLatencyMs: Date.now() - started, rpcError: null }; } catch (e) { return { rpcStatus: 'error', rpcLatencyMs: null, rpcError: e instanceof Error ? e.message : 'Coordinator RPC failed.', }; } finally { if (client) await client.disconnect().catch(() => undefined); } } /** * Presence probe for a coordinator. * * A Cordn coordinator is a ContextVM server, so "is it alive?" is answerable * without speaking Cordn's RPC: query the relays it advertises for events it * authored (its announcement + heartbeat). A private/unannounced coordinator * legitimately shows nothing here — the copy says so rather than alarming. */ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) { const { nostr } = useNostr(); const [result, setResult] = useState(INITIAL); const relays = useMemo( () => (coordinator?.relays.length ? coordinator.relays : [CORDN_RELAY_URL]), [coordinator], ); const check = useCallback(async () => { if (!coordinator) return; setResult((r) => ({ ...r, status: 'checking', error: null, rpcStatus: 'checking', rpcLatencyMs: null, rpcError: null, })); const rpc = await probeCoordinatorRpc(coordinator, relays); try { const signal = AbortSignal.timeout(8000); const batches = await Promise.all( relays.map(async (url) => { try { return await nostr.relay(url).query( [{ authors: [coordinator.pubkey], limit: 25 }], { signal }, ); } catch { // One relay failing must not stop the probe. return []; } }), ); const events = batches.flat(); if (events.length === 0) { setResult({ status: 'offline', lastSeen: null, eventCount: 0, name: null, error: null, checkedAt: Date.now(), ...rpc, }); return; } const newest = events.reduce((a, b) => (b.created_at > a.created_at ? b : a)); const ageMs = Date.now() - newest.created_at * 1000; setResult({ status: ageMs <= STALE_AFTER_MS ? 'online' : 'stale', lastSeen: newest.created_at, eventCount: events.length, name: coordinatorNameFromEvent(newest), error: null, checkedAt: Date.now(), ...rpc, }); } catch (err) { setResult({ status: 'error', lastSeen: null, eventCount: 0, name: null, error: err instanceof Error ? err.message : 'Could not reach the relays', checkedAt: Date.now(), ...rpc, }); } }, [coordinator, relays, nostr]); return { ...result, check }; }