relay-list.service.ts raw

   1  import { Injectable } from '@angular/core';
   2  import { SimplePool } from 'nostr-tools/pool';
   3  import { FALLBACK_PROFILE_RELAYS } from '../../constants/fallback-relays';
   4  
   5  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   6  declare const chrome: any;
   7  
   8  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
   9  const FETCH_TIMEOUT_MS = 10000; // 10 seconds
  10  const STORAGE_KEY = 'relayListCache';
  11  
  12  /**
  13   * NIP-65 Relay List entry
  14   */
  15  export interface Nip65Relay {
  16    url: string;
  17    read: boolean;
  18    write: boolean;
  19  }
  20  
  21  /**
  22   * Cached relay list for a pubkey
  23   */
  24  export interface RelayListCache {
  25    pubkey: string;
  26    relays: Nip65Relay[];
  27    fetchedAt: number;
  28  }
  29  
  30  /**
  31   * Cache for relay lists, stored in session storage
  32   */
  33  type RelayListCacheMap = Record<string, RelayListCache>;
  34  
  35  @Injectable({
  36    providedIn: 'root',
  37  })
  38  export class RelayListService {
  39    #cache: RelayListCacheMap = {};
  40    #pool: SimplePool | null = null;
  41    #fetchPromises = new Map<string, Promise<Nip65Relay[]>>();
  42    #initialized = false;
  43    #initPromise: Promise<void> | null = null;
  44  
  45    /**
  46     * Initialize the service by loading cache from session storage
  47     */
  48    async initialize(): Promise<void> {
  49      if (this.#initialized) {
  50        return;
  51      }
  52  
  53      if (this.#initPromise) {
  54        return this.#initPromise;
  55      }
  56  
  57      this.#initPromise = this.#loadCacheFromStorage();
  58      await this.#initPromise;
  59      this.#initialized = true;
  60    }
  61  
  62    /**
  63     * Load cache from browser session storage
  64     */
  65    async #loadCacheFromStorage(): Promise<void> {
  66      try {
  67        if (typeof chrome !== 'undefined' && chrome.storage?.session) {
  68          const result = await chrome.storage.session.get(STORAGE_KEY);
  69          if (result[STORAGE_KEY]) {
  70            this.#cache = result[STORAGE_KEY];
  71            this.#pruneStaleCache();
  72          }
  73        }
  74      } catch (error) {
  75        console.error('Failed to load relay list cache from storage:', error);
  76      }
  77    }
  78  
  79    /**
  80     * Save cache to browser session storage
  81     */
  82    async #saveCacheToStorage(): Promise<void> {
  83      try {
  84        if (typeof chrome !== 'undefined' && chrome.storage?.session) {
  85          await chrome.storage.session.set({ [STORAGE_KEY]: this.#cache });
  86        }
  87      } catch (error) {
  88        console.error('Failed to save relay list cache to storage:', error);
  89      }
  90    }
  91  
  92    /**
  93     * Remove stale entries from cache
  94     */
  95    #pruneStaleCache(): void {
  96      const now = Date.now();
  97      for (const pubkey of Object.keys(this.#cache)) {
  98        if (now - this.#cache[pubkey].fetchedAt > CACHE_TTL_MS) {
  99          delete this.#cache[pubkey];
 100        }
 101      }
 102    }
 103  
 104    /**
 105     * Get the SimplePool instance, creating it if necessary
 106     */
 107    #getPool(): SimplePool {
 108      if (!this.#pool) {
 109        this.#pool = new SimplePool();
 110      }
 111      return this.#pool;
 112    }
 113  
 114    /**
 115     * Get cached relay list for a pubkey
 116     */
 117    getCachedRelayList(pubkey: string): Nip65Relay[] | null {
 118      const cached = this.#cache[pubkey];
 119      if (!cached) {
 120        return null;
 121      }
 122  
 123      if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) {
 124        delete this.#cache[pubkey];
 125        return null;
 126      }
 127  
 128      return cached.relays;
 129    }
 130  
 131    /**
 132     * Fetch NIP-65 relay list for a single pubkey
 133     */
 134    async fetchRelayList(pubkey: string): Promise<Nip65Relay[]> {
 135      await this.initialize();
 136  
 137      // Check cache first
 138      const cached = this.getCachedRelayList(pubkey);
 139      if (cached) {
 140        return cached;
 141      }
 142  
 143      // Check if already fetching
 144      const existingPromise = this.#fetchPromises.get(pubkey);
 145      if (existingPromise) {
 146        return existingPromise;
 147      }
 148  
 149      // Start new fetch
 150      const fetchPromise = this.#doFetchRelayList(pubkey);
 151      this.#fetchPromises.set(pubkey, fetchPromise);
 152  
 153      try {
 154        const result = await fetchPromise;
 155        return result;
 156      } finally {
 157        this.#fetchPromises.delete(pubkey);
 158      }
 159    }
 160  
 161    /**
 162     * Internal method to fetch a single relay list
 163     */
 164    async #doFetchRelayList(pubkey: string): Promise<Nip65Relay[]> {
 165      const pool = this.#getPool();
 166  
 167      try {
 168        const events = await this.#queryWithTimeout(
 169          pool,
 170          FALLBACK_PROFILE_RELAYS,
 171          [{ kinds: [10002], authors: [pubkey] }],
 172          FETCH_TIMEOUT_MS
 173        );
 174  
 175        if (events.length === 0) {
 176          return [];
 177        }
 178  
 179        // Get the most recent event (kind 10002 is replaceable)
 180        const latestEvent = events.reduce((latest, event) =>
 181          event.created_at > latest.created_at ? event : latest
 182        );
 183  
 184        // Parse relay tags
 185        const relays: Nip65Relay[] = [];
 186        for (const tag of latestEvent.tags) {
 187          if (tag[0] === 'r' && tag[1]) {
 188            const url = tag[1];
 189            const marker = tag[2]; // Optional: "read" or "write"
 190  
 191            let read = true;
 192            let write = true;
 193  
 194            if (marker === 'read') {
 195              write = false;
 196            } else if (marker === 'write') {
 197              read = false;
 198            }
 199            // No marker means both read and write
 200  
 201            relays.push({ url, read, write });
 202          }
 203        }
 204  
 205        // Cache the result
 206        this.#cache[pubkey] = {
 207          pubkey,
 208          relays,
 209          fetchedAt: Date.now(),
 210        };
 211        await this.#saveCacheToStorage();
 212  
 213        return relays;
 214      } catch (error) {
 215        console.error(`Failed to fetch relay list for ${pubkey}:`, error);
 216        return [];
 217      }
 218    }
 219  
 220    /**
 221     * Query relays with a timeout
 222     */
 223    // eslint-disable-next-line @typescript-eslint/no-explicit-any
 224    async #queryWithTimeout(pool: SimplePool, relays: string[], filters: any[], timeoutMs: number): Promise<any[]> {
 225      return new Promise((resolve) => {
 226        // eslint-disable-next-line @typescript-eslint/no-explicit-any
 227        const events: any[] = [];
 228        let settled = false;
 229  
 230        const timeout = setTimeout(() => {
 231          if (!settled) {
 232            settled = true;
 233            resolve(events);
 234          }
 235        }, timeoutMs);
 236  
 237        const sub = pool.subscribeMany(relays, filters, {
 238          onevent(event) {
 239            events.push(event);
 240          },
 241          oneose() {
 242            if (!settled) {
 243              settled = true;
 244              clearTimeout(timeout);
 245              sub.close();
 246              resolve(events);
 247            }
 248          },
 249        });
 250      });
 251    }
 252  
 253    /**
 254     * Clear the cache
 255     */
 256    async clearCache(): Promise<void> {
 257      this.#cache = {};
 258      await this.#saveCacheToStorage();
 259    }
 260  
 261    /**
 262     * Clear cache for a specific pubkey
 263     */
 264    async clearCacheForPubkey(pubkey: string): Promise<void> {
 265      delete this.#cache[pubkey];
 266      await this.#saveCacheToStorage();
 267    }
 268  }
 269