blossom.service.ts raw
1 import { RECOMMENDED_BLOSSOM_SERVERS, RECOMMENDED_SEARCH_RELAYS } from '@/constants'
2 import client from '@/services/client.service'
3 import { getHashFromURL } from 'blossom-client-sdk'
4 import { parseResponsiveImageEvent, UploadedVariant, FILE_METADATA_KIND } from '@/lib/responsive-image-event'
5
6 // Timeout for HEAD request to check if server is responsive (ms)
7 const HEAD_TIMEOUT = 2000
8 // Timeout before trying mirrors in parallel (ms)
9 const MIRROR_FALLBACK_DELAY = 1500
10
11 interface CacheEntry {
12 pubkey?: string
13 resolve: (url: string) => void
14 promise: Promise<string>
15 tried: Set<string>
16 validUrl?: string
17 validating?: boolean
18 }
19
20 class BlossomService {
21 static instance: BlossomService
22 private cacheMap = new Map<string, CacheEntry>()
23
24 constructor() {
25 if (!BlossomService.instance) {
26 BlossomService.instance = this
27 }
28 return BlossomService.instance
29 }
30
31 /**
32 * Validates a URL by sending a HEAD request with timeout.
33 * Returns true if the server responds quickly with 200/206.
34 */
35 private async validateUrl(url: string, timeoutMs: number = HEAD_TIMEOUT): Promise<boolean> {
36 const controller = new AbortController()
37 const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
38
39 try {
40 const response = await fetch(url, {
41 method: 'HEAD',
42 signal: controller.signal,
43 mode: 'cors'
44 })
45 clearTimeout(timeoutId)
46 return response.ok
47 } catch {
48 clearTimeout(timeoutId)
49 return false
50 }
51 }
52
53 /**
54 * Builds a blob URL from a server base URL and hash.
55 */
56 private buildBlobUrl(serverUrl: string, hash: string, ext: string | null): string {
57 try {
58 const url = new URL(serverUrl)
59 url.pathname = '/' + hash + (ext || '')
60 return url.toString()
61 } catch {
62 return serverUrl + '/' + hash + (ext || '')
63 }
64 }
65
66 /**
67 * Gets all available Blossom servers for a pubkey.
68 * Includes user's servers + recommended fallbacks.
69 */
70 private async getBlossomServers(pubkey: string): Promise<string[]> {
71 const userServers = await client.fetchBlossomServerList(pubkey)
72 const allServers = [...userServers]
73
74 // Add recommended servers as fallbacks (if not already in list)
75 for (const server of RECOMMENDED_BLOSSOM_SERVERS) {
76 try {
77 const serverUrl = new URL(server)
78 if (!allServers.some((s) => new URL(s).hostname === serverUrl.hostname)) {
79 allServers.push(server)
80 }
81 } catch {
82 // Skip invalid URLs
83 }
84 }
85
86 return allServers
87 }
88
89 /**
90 * Races multiple URLs to find the first one that responds.
91 * Returns the winning URL or null if none respond.
92 */
93 private async raceUrls(urls: string[], timeoutMs: number): Promise<string | null> {
94 if (urls.length === 0) return null
95
96 return new Promise((resolve) => {
97 let resolved = false
98 let pendingCount = urls.length
99
100 const tryUrl = async (url: string) => {
101 const isValid = await this.validateUrl(url, timeoutMs)
102 if (isValid && !resolved) {
103 resolved = true
104 resolve(url)
105 } else {
106 pendingCount--
107 if (pendingCount === 0 && !resolved) {
108 resolve(null)
109 }
110 }
111 }
112
113 urls.forEach((url) => tryUrl(url))
114 })
115 }
116
117 /**
118 * Gets a valid URL for an image, proactively checking and racing mirrors.
119 *
120 * Strategy:
121 * 1. If cached and valid, return immediately
122 * 2. Start validating original URL
123 * 3. After MIRROR_FALLBACK_DELAY, start racing mirrors in parallel
124 * 4. Return first URL that responds successfully
125 */
126 async getValidUrl(url: string, pubkey: string): Promise<string> {
127 // Check cache first
128 const cache = this.cacheMap.get(url)
129 if (cache?.validUrl) {
130 return cache.validUrl
131 }
132 if (cache?.promise && cache.validating) {
133 return cache.promise
134 }
135
136 // Parse URL to extract hash
137 let hash: string | null = null
138 let ext: string | null = null
139 try {
140 const parsedUrl = new URL(url)
141 hash = getHashFromURL(parsedUrl)
142 const extMatch = parsedUrl.pathname.match(/\.\w+$/i)
143 ext = extMatch ? extMatch[0] : null
144 } catch {
145 return url
146 }
147
148 if (!hash) {
149 return url
150 }
151
152 // Create cache entry with promise
153 let resolveFunc: (url: string) => void
154 const promise = new Promise<string>((resolve) => {
155 resolveFunc = resolve
156 })
157
158 const entry: CacheEntry = {
159 pubkey,
160 resolve: resolveFunc!,
161 promise,
162 tried: new Set<string>(),
163 validating: true
164 }
165 this.cacheMap.set(url, entry)
166
167 // Start validation in background
168 this.validateAndFindBestUrl(url, pubkey, hash, ext, entry)
169
170 // Return promise that will resolve to the best URL
171 return promise
172 }
173
174 /**
175 * Background validation that races the original URL against mirrors.
176 */
177 private async validateAndFindBestUrl(
178 originalUrl: string,
179 pubkey: string,
180 hash: string,
181 ext: string | null,
182 entry: CacheEntry
183 ): Promise<void> {
184 const originalHostname = new URL(originalUrl).hostname
185 entry.tried.add(originalHostname)
186
187 // Race: original URL validation vs timeout to try mirrors
188 const originalValidPromise = this.validateUrl(originalUrl, HEAD_TIMEOUT)
189 const delayPromise = new Promise<'timeout'>((resolve) =>
190 setTimeout(() => resolve('timeout'), MIRROR_FALLBACK_DELAY)
191 )
192
193 const firstResult = await Promise.race([
194 originalValidPromise.then((valid) => ({ type: 'original' as const, valid })),
195 delayPromise.then(() => ({ type: 'timeout' as const }))
196 ])
197
198 // If original responded quickly and is valid, use it
199 if (firstResult.type === 'original' && firstResult.valid) {
200 entry.validUrl = originalUrl
201 entry.validating = false
202 entry.resolve(originalUrl)
203 return
204 }
205
206 // Original is slow or failed, try mirrors in parallel
207 const servers = await this.getBlossomServers(pubkey)
208 const mirrorUrls = servers
209 .map((server) => {
210 try {
211 const serverHostname = new URL(server).hostname
212 if (entry.tried.has(serverHostname)) return null
213 entry.tried.add(serverHostname)
214 return this.buildBlobUrl(server, hash, ext)
215 } catch {
216 return null
217 }
218 })
219 .filter((u): u is string => u !== null)
220
221 // If original is still pending, include it in the race
222 const urlsToRace = [...mirrorUrls]
223 if (firstResult.type === 'timeout') {
224 // Original is still pending, wait for it too
225 const originalStillValid = await originalValidPromise
226 if (originalStillValid) {
227 entry.validUrl = originalUrl
228 entry.validating = false
229 entry.resolve(originalUrl)
230 return
231 }
232 }
233
234 // Race all mirrors
235 const winningUrl = await this.raceUrls(urlsToRace, HEAD_TIMEOUT)
236 if (winningUrl) {
237 entry.validUrl = winningUrl
238 entry.validating = false
239 entry.resolve(winningUrl)
240 return
241 }
242
243 // All failed, fall back to original (browser will handle the error)
244 entry.validUrl = originalUrl
245 entry.validating = false
246 entry.resolve(originalUrl)
247 }
248
249 /**
250 * Tries the next available URL when current one fails.
251 * Called by Image component on load error.
252 */
253 async tryNextUrl(originalUrl: string): Promise<string | null> {
254 const entry = this.cacheMap.get(originalUrl)
255 if (!entry) {
256 return null
257 }
258
259 if (entry.validUrl && entry.validUrl !== originalUrl) {
260 return entry.validUrl
261 }
262
263 const { pubkey, tried, resolve } = entry
264 let hash: string | null = null
265 let ext: string | null = null
266 try {
267 const parsedUrl = new URL(originalUrl)
268 hash = getHashFromURL(parsedUrl)
269 const extMatch = parsedUrl.pathname.match(/\.\w+$/i)
270 ext = extMatch ? extMatch[0] : null
271 } catch {
272 resolve(originalUrl)
273 return null
274 }
275
276 if (!pubkey || !hash) {
277 resolve(originalUrl)
278 return null
279 }
280
281 const blossomServerList = await this.getBlossomServers(pubkey)
282 const urls = blossomServerList
283 .map((server) => {
284 try {
285 const serverUrl = new URL(server)
286 if (tried.has(serverUrl.hostname)) return null
287 tried.add(serverUrl.hostname)
288 return this.buildBlobUrl(server, hash!, ext)
289 } catch {
290 return null
291 }
292 })
293 .filter((u): u is string => u !== null)
294
295 // Try each URL with validation
296 for (const url of urls) {
297 const isValid = await this.validateUrl(url, HEAD_TIMEOUT)
298 if (isValid) {
299 entry.validUrl = url
300 resolve(url)
301 return url
302 }
303 }
304
305 resolve(originalUrl)
306 return null
307 }
308
309 markAsSuccess(originalUrl: string, successUrl: string) {
310 const entry = this.cacheMap.get(originalUrl)
311 if (!entry) {
312 this.cacheMap.set(originalUrl, {
313 resolve: () => {},
314 promise: Promise.resolve(successUrl),
315 tried: new Set<string>(),
316 validUrl: successUrl,
317 validating: false
318 })
319 return
320 }
321
322 entry.resolve(successUrl)
323 entry.validUrl = successUrl
324 entry.validating = false
325 }
326
327 /**
328 * Check if a string is a valid 64-character hex blob hash
329 */
330 isValidBlobHash(str: string): boolean {
331 return /^[a-f0-9]{64}$/i.test(str)
332 }
333
334 /**
335 * Query relays for a responsive image binding event (kind 1063) by blob hash.
336 * Uses the user's NIP-65 relay list for discovery.
337 *
338 * @param blobHash - The 64-character SHA256 hash of any variant
339 * @returns Parsed variants from the binding event, or null if not found
340 */
341 async queryBindingEvent(blobHash: string): Promise<UploadedVariant[] | null> {
342 if (!this.isValidBlobHash(blobHash)) {
343 return null
344 }
345
346 const hash = blobHash.toLowerCase()
347
348 try {
349 // Query for kind 1063 events with matching x tag
350 // Use recommended search relays combined with user's current relays
351 const relaysToQuery = Array.from(new Set([
352 ...client.currentRelays,
353 ...RECOMMENDED_SEARCH_RELAYS
354 ]))
355
356 const events = await client.fetchEvents(relaysToQuery, {
357 kinds: [FILE_METADATA_KIND],
358 '#x': [hash],
359 limit: 1
360 })
361
362 if (events.length === 0) {
363 return null
364 }
365
366 // Parse the first matching event
367 const event = events[0]
368 const variants = parseResponsiveImageEvent(event)
369
370 return variants.length > 0 ? variants : null
371 } catch {
372 return null
373 }
374 }
375
376 /**
377 * Extract a blob hash from a Blossom URL.
378 * Handles formats like:
379 * - https://server.com/abc123...def456
380 * - https://server.com/abc123...def456.jpg
381 *
382 * @param url - A Blossom blob URL
383 * @returns The 64-character hash, or null if not found
384 */
385 extractHashFromUrl(url: string): string | null {
386 try {
387 const hash = getHashFromURL(new URL(url))
388 return hash && this.isValidBlobHash(hash) ? hash.toLowerCase() : null
389 } catch {
390 return null
391 }
392 }
393 }
394
395 const instance = new BlossomService()
396 export default instance
397