SkateSpots/src/lib/relayProbe.ts
shakespeare.diy ef86d858c2 fix: make relay-probe timers and effect hooks lint-clean across ESLint versions
- Replace single-assignment `let timer` with a `timers[]` array in
  citrine.ts and relayProbe.ts so stricter prefer-const builds pass
  (the newer ESLint pulled in by `npm audit fix` flagged the let as
  never-reassigned).
- Drop two now-redundant `eslint-disable react-hooks/exhaustive-deps`
  directives in NostrProvider and MapPage. Newer react-hooks no longer
  reports those lines, so the directives were flagged as unused errors
  under reportUnusedDisableDirectives; removing them is safe in both
  old (warning only) and new (silent) ESLint.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 19:55:38 +07:00

131 lines
4.3 KiB
TypeScript

/**
* 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<RelayProbeResult> {
return new Promise<RelayProbeResult>((resolve) => {
let settled = false;
let opened = false;
let ws: WebSocket | undefined;
const timers: ReturnType<typeof setTimeout>[] = [];
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;
}