- 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>
131 lines
4.3 KiB
TypeScript
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;
|
|
}
|