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