OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore / public / trystero-content-transport.js

298 lines · javascript ref:
/**
 * Trystero Content Transport (Browser-side)
 *
 * WebRTC-based content-by-hash synchronization for browsers.
 * Compatible with wiredove's sendHash/sendBlob pattern.
 *
 * Uses Trystero for signaling and WebRTC connections.
 * Works with browser's IndexedDB storage via apds.
 */

export class TrysteroContentTransport {
  constructor(apds, options = {}) {
    this.apds = apds
    this.options = {
      appId: options.appId || 'wirecore-v1',
      password: options.password || 'wirecore-content-sync-2026',
      ...options
    }

    this.room = null
    this.sendHash = null
    this.sendBlob = null
    this.peers = new Set()

    // Pending requests: requestId -> { hash, resolve, reject, timeout }
    this.pendingRequests = new Map()

    // Stats
    this.stats = {
      sent: 0,
      received: 0,
      peers: 0
    }

    console.log('TrysteroContentTransport: initialized')
  }

  /**
   * Join a Trystero room for P2P communication
   * @param {string} roomName - Room identifier (usually public key)
   * @param {Function} joinRoomFn - Trystero's joinRoom function
   */
  async joinRoom(roomName, joinRoomFn) {
    if (this.room) {
      console.log('TrysteroContentTransport: already in room')
      return
    }

    console.log(`TrysteroContentTransport: joining room ${roomName}`)

    // Create Trystero room
    const roomConfig = {
      appId: this.options.appId,
      password: this.options.password
    }

    // Add custom relay URLs if provided
    if (this.options.relayUrls) {
      roomConfig.relayUrls = this.options.relayUrls
    }

    // Add custom RTC config (TURN/STUN servers) if provided
    if (this.options.rtcConfig) {
      roomConfig.rtcConfig = this.options.rtcConfig
    }

    const room = joinRoomFn(roomConfig, roomName)

    // Create hash and blob actions (matching wiredove's pattern)
    const [sendHash, onHash] = room.makeAction('hash')
    const [sendBlob, onBlob] = room.makeAction('blob')

    this.room = room
    this.sendHash = sendHash
    this.sendBlob = sendBlob

    // Handle incoming hash requests
    onHash(async (data, peerId) => {
      console.log(`TrysteroContentTransport: received hash request from ${peerId}:`, data)
      await this.handleHashRequest(data, peerId)
    })

    // Handle incoming blob responses
    onBlob(async (data, peerId) => {
      console.log(`TrysteroContentTransport: received blob from ${peerId}`)
      await this.handleBlobResponse(data, peerId)
    })

    // Track peers
    room.onPeerJoin((peerId) => {
      console.log(`TrysteroContentTransport: peer joined ${peerId}`)
      this.peers.add(peerId)
      this.stats.peers = this.peers.size

      // Send our latest content when peer joins
      this.announceLatestContent(peerId)
    })

    room.onPeerLeave((peerId) => {
      console.log(`TrysteroContentTransport: peer left ${peerId}`)
      this.peers.delete(peerId)
      this.stats.peers = this.peers.size
    })

    console.log('TrysteroContentTransport: room joined successfully')
  }

  /**
   * Announce latest content to a specific peer
   */
  async announceLatestContent(peerId) {
    try {
      const pubkey = await this.apds.pubkey()
      if (!pubkey) return

      // Get latest message from own feed
      // This would need integration with Wirecore's feed system
      // For now, just log
      console.log(`TrysteroContentTransport: would announce latest to ${peerId}`)
    } catch (err) {
      console.error('TrysteroContentTransport: failed to announce latest:', err)
    }
  }

  /**
   * Handle incoming hash request from peer
   * Peer is asking if we have content for this hash
   */
  async handleHashRequest(data, peerId) {
    try {
      const { hash, requestId } = typeof data === 'string' ? { hash: data, requestId: null } : data

      if (!hash) {
        console.error('TrysteroContentTransport: invalid hash request')
        return
      }

      // Try to get content from local storage
      let content = null

      // Check if this is a profile-image request (starts with 'profile-image:')
      if (hash.startsWith('profile-image:')) {
        // For own profile image
        if (hash === `profile-image:${await this.apds.pubkey()}`) {
          content = await this.apds.get('profile-image')
        } else {
          // For other users (we might have cached it)
          content = await this.apds.get(hash)
        }
      } else {
        // Check post images
        const postImage = await this.apds.get(`post-image:${hash}`)
        if (postImage) {
          content = postImage
        } else {
          // Check regular content
          content = await this.apds.get(hash)
        }
      }

      if (content) {
        console.log(`TrysteroContentTransport: found content for ${hash}, sending to ${peerId}`)

        // Send blob back to requesting peer
        const response = requestId ? { content, hash, requestId } : content
        this.sendBlob(response, peerId)
        this.stats.sent++
      } else {
        console.log(`TrysteroContentTransport: content not found for ${hash}`)
      }
    } catch (err) {
      console.error('TrysteroContentTransport: error handling hash request:', err)
    }
  }

  /**
   * Handle incoming blob response from peer
   * Peer is sending us content we requested
   */
  async handleBlobResponse(data, peerId) {
    try {
      const { content, hash, requestId } = typeof data === 'string' ? { content: data, hash: null, requestId: null } : data

      if (!content) {
        console.error('TrysteroContentTransport: invalid blob response')
        return
      }

      this.stats.received++

      // If this is a response to a request, resolve the promise
      if (requestId && this.pendingRequests.has(requestId)) {
        const pending = this.pendingRequests.get(requestId)
        clearTimeout(pending.timeout)
        this.pendingRequests.delete(requestId)
        pending.resolve(content)
        return
      }

      // Otherwise, this is unsolicited content (e.g., new post)
      // Store it and process it
      if (hash) {
        await this.apds.put(`post-image:${hash}`, content)
        console.log(`TrysteroContentTransport: stored content for ${hash}`)
      }

      // Emit event for processing
      this.emit?.('blob-received', { content, hash, peerId })
    } catch (err) {
      console.error('TrysteroContentTransport: error handling blob response:', err)
    }
  }

  /**
   * Request content from peers
   * @param {string} hash - Content hash to request
   * @param {Object} options - { timeout }
   * @returns {Promise<string>} Content or null
   */
  async requestContent(hash, options = {}) {
    const timeout = options.timeout || 10000
    const requestId = this.generateRequestId()

    console.log(`TrysteroContentTransport: requesting content for ${hash}`)

    // Create promise for this request
    const promise = new Promise((resolve, reject) => {
      const timeoutId = setTimeout(() => {
        this.pendingRequests.delete(requestId)
        reject(new Error(`Timeout requesting ${hash}`))
      }, timeout)

      this.pendingRequests.set(requestId, {
        hash,
        resolve,
        reject,
        timeout: timeoutId
      })
    })

    // Send hash request to all peers
    if (this.sendHash) {
      this.sendHash({ hash, requestId })
      this.stats.sent++
    }

    return promise
  }

  /**
   * Generate unique request ID
   */
  generateRequestId() {
    return `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
  }

  /**
   * Check if we have active peers
   */
  hasActivePeers() {
    return this.peers.size > 0
  }

  /**
   * Get peer count
   */
  getPeerCount() {
    return this.peers.size
  }

  /**
   * Leave room and cleanup
   */
  leaveRoom() {
    if (this.room) {
      console.log('TrysteroContentTransport: leaving room')
      this.room.leave()
      this.room = null
      this.sendHash = null
      this.sendBlob = null
      this.peers.clear()
      this.pendingRequests.clear()
    }
  }

  /**
   * Get stats
   */
  getStats() {
    return {
      ...this.stats,
      pendingRequests: this.pendingRequests.size
    }
  }
}

export default TrysteroContentTransport