204 lines
5.8 KiB
TypeScript
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 };
|
|
}
|