GigaProjects

← Back to nostr-relay-speedtest

nostr.ts

import { SimplePool } from 'nostr-tools';

export interface RelayStats {
  url: string;
  latency?: number;
  reportedLatency?: number;
  status: 'pending' | 'measuring' | 'done' | 'error';
  lastError?: string;
}

const DISCOVERY_RELAYS = [
  'wss://relay.nostr.band',
  'wss://nos.lol',
  'wss://relay.damus.io',
  'wss://purplepag.es',
  'wss://relay.snort.social'
];

export async function discoverRelays(): Promise<RelayStats[]> {
  const pool = new SimplePool();

  try {
    // NIP-66 uses kind 30166 for relay discovery events
    const events = await pool.querySync(DISCOVERY_RELAYS, {
      kinds: [30166],
      limit: 100
    }, { maxWait: 8000 });

    const relayUrls = new Set<string>();
    const stats: RelayStats[] = [];

    for (const event of events) {
      // NIP-66: 'd' tag contains the relay URL
      const dTag = event.tags.find(t => t[0] === 'd')?.[1];

      // Only accept valid websocket URLs
      if (dTag && (dTag.startsWith('wss://') || dTag.startsWith('ws://'))) {
        // Filter out local/private addresses
        if (dTag.includes('127.0.0.1') || dTag.includes('localhost') || dTag.includes('192.168.') || dTag.includes('10.') || dTag.includes('172.')) {
          continue;
        }

        if (!relayUrls.has(dTag)) {
          relayUrls.add(dTag);

          // NIP-66 uses various tags for RTT - check all common variants
          const rttOpenTag = event.tags.find(t => t[0] === 'rtt-open');
          const rttReadTag = event.tags.find(t => t[0] === 'rtt-read');
          const rttWriteTag = event.tags.find(t => t[0] === 'rtt-write');
          const rttTag = event.tags.find(t => t[0] === 'rtt');
          const latencyTag = event.tags.find(t => t[0] === 'latency');

          const reportedLatency =
            rttOpenTag ? parseInt(rttOpenTag[1]) :
              rttReadTag ? parseInt(rttReadTag[1]) :
                rttWriteTag ? parseInt(rttWriteTag[1]) :
                  rttTag ? parseInt(rttTag[1]) :
                    latencyTag ? parseInt(latencyTag[1]) : undefined;

          if (stats.length < 21) {
            stats.push({
              url: dTag,
              reportedLatency,
              status: 'pending'
            });
          }
        }
      }
    }

    return stats;
  } catch (error) {
    console.error('Error during relay discovery:', error);
    throw error;
  } finally {
    pool.close(DISCOVERY_RELAYS);
  }
}

export async function measureLatency(url: string): Promise<number> {
  return new Promise((resolve, reject) => {
    const start = performance.now();
    let ws: WebSocket;

    try {
      ws = new WebSocket(url);
    } catch (e) {
      return reject(new Error('Invalid URL'));
    }

    const timeout = setTimeout(() => {
      ws.close();
      reject(new Error('Timeout'));
    }, 5000);

    ws.onopen = () => {
      const reqId = Math.random().toString(36).substring(7);
      ws.send(JSON.stringify(['REQ', reqId, { kinds: [0], limit: 1 }]));
    };

    ws.onmessage = (msg) => {
      try {
        const data = JSON.parse(msg.data);
        if (data[0] === 'EOSE' || data[0] === 'EVENT') {
          const end = performance.now();
          ws.close();
          clearTimeout(timeout);
          resolve(Math.round(end - start));
        }
      } catch {
        // Ignore malformed messages
      }
    };

    ws.onerror = () => {
      ws.close();
      clearTimeout(timeout);
      reject(new Error('Connection failed'));
    };
  });
}