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