SkateSpots/src/hooks/useCoordinator.ts

204 lines
5.8 KiB
TypeScript

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<CoordinatorConfig | null>(
COORDINATOR_STORAGE_KEY,
null,
);
const coordinator = stored && stored.pubkey ? stored : null;
const setCoordinator = useCallback(
(config: Omit<CoordinatorConfig, 'addedAt'>) => {
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<Pick<CoordinatorHealth, 'rpcStatus' | 'rpcLatencyMs' | 'rpcError'>> {
let client: { listAvailableKeyPackages: () => Promise<unknown>; disconnect: () => Promise<void> } | 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<never>((_, 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<CoordinatorHealth>(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 };
}