OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore / pear-app / lib / relay-client.js

320 lines · javascript ref:
/**
 * RelayClient - Simple HTTP + short-lived WS relay interaction
 *
 * Based on proven wiredove-cli patterns:
 * - Push: Short-lived WebSocket (connect, send, close)
 * - Pull: HTTP GET requests
 * - No persistent state
 * - Configurable relay URL
 */

import https from 'bare-https'
import http from 'bare-http1'
import { Socket as WebSocket } from 'bare-ws'

export class RelayClient {
  constructor(relayUrl = 'https://7rnx.net/relay') {
    this.relayUrl = relayUrl
    this.wsUrl = relayUrl.replace('https://', 'wss://').replace('http://', 'ws://')
  }

  /**
   * Push post to relay (short-lived WebSocket)
   *
   * @param {string} pubkey - Author public key (base64, 44 chars)
   * @param {string} yaml - YAML message content
   * @param {string} signature - ANProto signature (base64, 132 bytes)
   * @param {string} [imageData] - Optional image data (data URL format)
   * @returns {Promise<void>}
   */
  async push(pubkey, yaml, signature, imageData = null) {
    // First: Push the post (pubkey, yaml, signature)
    await this._pushPost(pubkey, yaml, signature)

    // Then: Push the image as a SEPARATE blob (if present)
    // This matches APDS/wiredove protocol: images are independent blobs
    if (imageData) {
      await this._pushImageBlob(imageData)
    }
  }

  /**
   * Push post content to relay
   * @private
   */
  async _pushPost(pubkey, yaml, signature) {
    return new Promise((resolve, reject) => {
      console.log(`📤 Pushing post to relay: ${this.wsUrl}`)

      const ws = new WebSocket(this.wsUrl)
      const timeout = setTimeout(() => {
        ws.destroy()
        reject(new Error('Relay push timeout (10s)'))
      }, 10000)

      ws.on('open', () => {
        clearTimeout(timeout)
        console.log('  ✓ Connected to relay')

        try {
          // Send post: pubkey → yaml → signature
          // Note: bare-ws Socket is a Duplex stream, use .write() not .send()
          ws.write(pubkey)
          console.log('  → Sent pubkey')

          ws.write(yaml)
          console.log('  → Sent content')

          ws.write(signature)
          console.log('  → Sent signature')

          // Close after 2 seconds (relay processes messages)
          setTimeout(() => {
            ws.destroy()
            console.log('✅ Post pushed to relay')
            resolve()
          }, 2000)
        } catch (err) {
          clearTimeout(timeout)
          ws.destroy()
          reject(err)
        }
      })

      ws.on('error', (err) => {
        clearTimeout(timeout)
        console.error('❌ Relay post push error:', err.message)
        reject(err)
      })

      ws.on('close', () => {
        clearTimeout(timeout)
      })
    })
  }

  /**
   * Push image blob to relay (separate from post)
   * Images are sent as independent blobs that relay stores by hash
   * @private
   */
  async _pushImageBlob(imageData) {
    return new Promise((resolve, reject) => {
      console.log(`📤 Pushing image blob to relay: ${this.wsUrl}`)

      const ws = new WebSocket(this.wsUrl)
      const timeout = setTimeout(() => {
        ws.destroy()
        reject(new Error('Image blob push timeout (15s)'))
      }, 15000)

      ws.on('open', () => {
        clearTimeout(timeout)
        console.log('  ✓ Connected to relay for image')

        try {
          // Send image data as separate blob
          // Relay will store by hash and gossip independently
          ws.write(imageData)
          console.log('  → Sent image blob')

          // Close after 3 seconds (images are larger, need more time)
          setTimeout(() => {
            ws.destroy()
            console.log('✅ Image blob pushed to relay')
            resolve()
          }, 3000)
        } catch (err) {
          clearTimeout(timeout)
          ws.destroy()
          reject(err)
        }
      })

      ws.on('error', (err) => {
        clearTimeout(timeout)
        console.error('❌ Image blob push error:', err.message)
        reject(err)
      })

      ws.on('close', () => {
        clearTimeout(timeout)
      })
    })
  }

  /**
   * Pull posts from relay (HTTP GET)
   *
   * @param {Object} options - Query options
   * @param {number} [options.limit] - Max posts to fetch
   * @param {string} [options.author] - Filter by author pubkey
   * @returns {Promise<Array>} Array of posts
   */
  async pull(options = {}) {
    const endpoint = options.author
      ? `/directory/${encodeURIComponent(options.author)}`
      : '/all'

    const url = `${this.relayUrl}${endpoint}`
    console.log(`📥 Pulling from relay: ${url}`)

    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        reject(new Error('Relay pull timeout (30s)'))
      }, 30000)

      const protocol = this.relayUrl.startsWith('https://') ? https : http
      const urlObj = new URL(url)

      const options = {
        hostname: urlObj.hostname,
        port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
        path: urlObj.pathname + urlObj.search,
        method: 'GET'
      }

      const req = protocol.request(options, (res) => {
        let data = ''

        res.on('data', chunk => {
          data += chunk.toString()
        })

        res.on('end', () => {
          clearTimeout(timeout)
          try {
            const posts = JSON.parse(data)
            const count = Array.isArray(posts) ? posts.length : (posts.total || 0)
            console.log(`✅ Pulled ${count} posts from relay`)
            resolve(posts)
          } catch (err) {
            reject(new Error(`Invalid JSON response: ${err.message}`))
          }
        })

        res.on('error', (err) => {
          clearTimeout(timeout)
          reject(err)
        })
      })

      req.on('error', (err) => {
        clearTimeout(timeout)
        reject(err)
      })

      req.end()
    })
  }

  /**
   * Pull user directory from relay
   *
   * @param {string} [searchQuery] - Optional search query
   * @returns {Promise<Object>} Directory response
   */
  async pullDirectory(searchQuery = null) {
    const endpoint = searchQuery
      ? `/directory/search?q=${encodeURIComponent(searchQuery)}`
      : '/directory'

    const url = `${this.relayUrl}${endpoint}`
    console.log(`📥 Pulling directory: ${url}`)

    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        reject(new Error('Directory timeout (30s)'))
      }, 30000)

      const protocol = this.relayUrl.startsWith('https://') ? https : http
      const urlObj = new URL(url)

      const options = {
        hostname: urlObj.hostname,
        port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
        path: urlObj.pathname + urlObj.search,
        method: 'GET'
      }

      const req = protocol.request(options, (res) => {
        let data = ''
        res.on('data', chunk => data += chunk.toString())
        res.on('end', () => {
          clearTimeout(timeout)
          try {
            resolve(JSON.parse(data))
          } catch (err) {
            reject(new Error(`Invalid JSON: ${err.message}`))
          }
        })
        res.on('error', (err) => {
          clearTimeout(timeout)
          reject(err)
        })
      })

      req.on('error', (err) => {
        clearTimeout(timeout)
        reject(err)
      })

      req.end()
    })
  }

  /**
   * Check relay health
   *
   * @returns {Promise<Object>} Health status
   */
  async health() {
    try {
      const url = `${this.relayUrl}/stats`
      console.log(`🏥 Checking relay health: ${url}`)

      const stats = await new Promise((resolve, reject) => {
        const timeout = setTimeout(() => reject(new Error('Timeout')), 5000)

        const protocol = this.relayUrl.startsWith('https://') ? https : http
        const urlObj = new URL(url)

        const options = {
          hostname: urlObj.hostname,
          port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
          path: urlObj.pathname,
          method: 'GET'
        }

        const req = protocol.request(options, (res) => {
          let data = ''
          res.on('data', chunk => data += chunk.toString())
          res.on('end', () => {
            clearTimeout(timeout)
            try {
              resolve(JSON.parse(data))
            } catch {
              reject(new Error('Invalid response'))
            }
          })
        })

        req.on('error', reject)
        req.end()
      })

      return {
        online: true,
        url: this.relayUrl,
        stats
      }
    } catch (err) {
      return {
        online: false,
        url: this.relayUrl,
        error: err.message
      }
    }
  }
}