/** * relayProbe — lightweight connectivity check for an arbitrary Nostr relay. * ───────────────────────────────────────────────────────────────────────────── * Adapted from the Citrine probe: opening a socket alone is not enough to prove * a relay is alive and speaking Nostr, so we complete a minimal handshake — * open → tiny bounded REQ → require a protocol-shaped reply (EVENT / EOSE / * NOTICE / CLOSED) referencing our subscription id. This gives the Relays UI an * honest green "connected" dot instead of an optimistic one. * * Never throws, never rejects — always resolves to a boolean-plus-reason so the * caller can render a status without try/catch noise. */ export interface RelayProbeResult { ok: boolean; /** Short diagnostic for the outcome (useful for debugging / tooltips). */ reason: string; } /** * Probe whether a relay at `url` is reachable and answering as a Nostr relay. * * Mirrors the Citrine handshake: a bare "socket opened" is treated as NOT ok — * we require a real protocol reply so a non-Nostr process squatting on the port * doesn't show a misleading green dot. */ export function probeRelay(url: string, timeoutMs = 4000): Promise { return new Promise((resolve) => { let settled = false; let opened = false; let ws: WebSocket | undefined; const timers: ReturnType[] = []; const done = (ok: boolean, reason: string) => { if (settled) return; settled = true; for (const t of timers) clearTimeout(t); try { ws?.close(); } catch { // ignore close errors } resolve({ ok, reason }); }; try { ws = new WebSocket(url); } catch (err) { resolve({ ok: false, reason: `blocked: ${err instanceof Error ? err.message : String(err)}`, }); return; } const subId = `probe-${Math.random().toString(36).slice(2, 10)}`; timers.push(setTimeout(() => { done(false, opened ? 'opened-but-no-nostr-reply' : 'timeout-no-open'); }, timeoutMs)); ws.onopen = () => { opened = true; try { ws!.send(JSON.stringify(['REQ', subId, { limit: 1 }])); } catch (err) { done(false, `send-failed: ${err instanceof Error ? err.message : String(err)}`); } }; ws.onmessage = (ev) => { try { const data = JSON.parse(typeof ev.data === 'string' ? ev.data : ''); if (Array.isArray(data) && typeof data[0] === 'string') { const verb = data[0]; const matchesSub = data[1] === subId; if ( ((verb === 'EVENT' || verb === 'EOSE' || verb === 'CLOSED') && matchesSub) || verb === 'NOTICE' ) { done(true, 'ok'); return; } } } catch { // Non-JSON frame → keep waiting for a valid reply until timeout. } }; ws.onerror = () => { done(false, opened ? 'error-after-open' : 'connect-error'); }; ws.onclose = (ev) => { done(false, opened ? 'closed-after-open' : `closed-before-open (code ${ev.code})`); }; }); } /** * True if a relay URL points at the local machine (loopback / localhost / *.local * / LAN address). Used to badge local relays (e.g. Citrine, a self-hosted relay) * distinctly in the UI, matching the reference "Local" chip. */ export function isLocalRelay(url: string): boolean { let host = ''; try { host = new URL(url).hostname.toLowerCase(); } catch { // Fall back to a loose match on the raw string. const lower = url.toLowerCase(); return ( lower.includes('127.0.0.1') || lower.includes('localhost') || lower.startsWith('ws://') ); } if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') { return true; } if (host.endsWith('.local')) return true; // Private LAN ranges. if (host.startsWith('10.')) return true; if (host.startsWith('192.168.')) return true; const m = host.match(/^172\.(\d+)\./); if (m) { const second = Number(m[1]); if (second >= 16 && second <= 31) return true; } return false; }