OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / p2p / discovery / browse-client.js

210 lines · javascript ref:
/**
 * P2P Browse Client
 *
 * Connects to a remote pear-git server over Hyperswarm and requests
 * file listings, file content, commits, etc. for browsing remote repos.
 *
 * Uses the `pgh-browse` protocol — no auth required (federation = full read).
 *
 * Uses its OWN Hyperswarm instance (browseSwarm), separate from the git
 * swarm. This is critical: sharing the git swarm causes data delivery
 * failures because Hyperswarm deduplicates connections per peer, and
 * listener ordering between TopicManager and BrowseClient is unreliable.
 *
 * Connection reuse:
 *   Hyperswarm deduplicates connections per peer. After connecting to a
 *   server for one topic, subsequent topic joins to the same server won't
 *   fire new `connection` events. So we cache connections per peer and
 *   reuse them for all browse requests to the same server.
 *
 * Protocol (per request on the connection):
 *   1. Send: pgh-browse\0repoName\0
 *   2. Send: JSON request + newline
 *   3. Receive: JSON response + newline
 *   4. (Connection stays open for more requests)
 */

import { ServerIdentity } from '../identity/server-identity.js'

export class BrowseClient {
  /**
   * @param {Hyperswarm} swarm - Dedicated browse swarm (NOT the git swarm)
   */
  constructor (swarm) {
    this.swarm = swarm
    this._peerConns = new Map() // peerKeyHex -> conn (cached connections)
    this._pendingResolve = null // resolve function for pending connection

    // Persistent listener — handles ALL connections (we own this swarm)
    this._onConnection = this._handleConnection.bind(this)
    this.swarm.on('connection', this._onConnection)
  }

  _handleConnection (conn, peerInfo) {
    var peerKeyHex = peerInfo.publicKey.toString('hex')
    console.log('[BrowseClient] Connected to ' + peerKeyHex.slice(0, 8) + '...')

    // Cache the connection by peer Noise key
    this._peerConns.set(peerKeyHex, conn)

    var self = this
    conn.on('close', function () {
      if (self._peerConns.get(peerKeyHex) === conn) {
        self._peerConns.delete(peerKeyHex)
      }
    })

    // Resolve pending connection request (if any)
    // We don't know the peer's Noise key upfront, so we use a single
    // pending resolver that matches ANY outgoing connection.
    if (self._pendingResolve) {
      var resolve = self._pendingResolve
      self._pendingResolve = null
      resolve(conn)
    }
  }

  /**
   * Get a cached connection or wait for a new one.
   */
  async _getConnection (remoteServerKey, repoName, timeout) {
    // Check cached connections — reuse any live, writable outgoing connection
    for (var entry of this._peerConns) {
      var conn = entry[1]
      if (!conn.destroyed && conn.writable) return conn
      // Clean up stale connections (destroyed or write-ended)
      if (conn.destroyed || !conn.writable) {
        this._peerConns.delete(entry[0])
      }
    }

    console.log('[BrowseClient] Joining topic for ' + repoName + ' (server: ' +
      (typeof remoteServerKey === 'string' ? remoteServerKey.slice(0, 8) : remoteServerKey.toString('hex').slice(0, 8)) + '...)')

    var topic = ServerIdentity.deriveTopicFromKey(remoteServerKey, repoName)
    this.swarm.join(topic, { server: false, client: true })

    // Wait for connection event (handled by _handleConnection)
    var self = this
    return new Promise(function (resolve, reject) {
      var timer = setTimeout(function () {
        if (self._pendingResolve === resolve) self._pendingResolve = null
        reject(new Error('Browse connection timeout'))
      }, timeout)

      // Check again — connection might have arrived between the check above and now
      for (var entry of self._peerConns) {
        if (!entry[1].destroyed && entry[1].writable) {
          clearTimeout(timer)
          resolve(entry[1])
          return
        }
      }

      self._pendingResolve = function (conn) {
        clearTimeout(timer)
        resolve(conn)
      }
    })
  }

  /**
   * Send a browse request to a remote server.
   * Reuses existing connections. Multiple repos can be browsed sequentially.
   *
   * @param {string|Buffer} remoteServerKey - Remote server's public key (hex or Buffer)
   * @param {string} repoName - Repository name
   * @param {Object} request - Browse command
   * @param {Object} opts - Options { timeout: 30000 }
   * @returns {Promise<Object>} Response data
   */
  async browse (remoteServerKey, repoName, request, opts) {
    opts = opts || {}
    var timeout = opts.timeout || 30000

    // Get or create connection to the remote server
    var conn = await this._getConnection(remoteServerKey, repoName, timeout)

    // Send browse header: pgh-browse\0repoName\0
    var header = Buffer.concat([
      Buffer.from('pgh-browse', 'utf-8'),
      Buffer.from([0]),
      Buffer.from(repoName, 'utf-8'),
      Buffer.from([0])
    ])
    conn.write(header)

    // Send request as JSON + newline
    conn.write(JSON.stringify(request) + '\n')

    console.log('[BrowseClient] Sent browse request for ' + repoName)

    // Read response (wait for JSON + newline)
    return new Promise(function (resolve, reject) {
      var responseBuffer = Buffer.alloc(0)
      var settled = false

      var timer = setTimeout(function () {
        if (settled) return
        settled = true
        conn.off('data', onData)
        reject(new Error('Browse response timeout for ' + repoName))
      }, timeout)

      function onData (chunk) {
        if (settled) return
        responseBuffer = Buffer.concat([responseBuffer, chunk])
        var nl = responseBuffer.indexOf(10)
        if (nl !== -1) {
          settled = true
          clearTimeout(timer)
          conn.off('data', onData)
          try {
            var response = JSON.parse(responseBuffer.slice(0, nl).toString('utf-8'))
            resolve(response)
          } catch (err) {
            reject(new Error('Invalid browse response for ' + repoName))
          }
        }
      }

      conn.on('data', onData)

      conn.once('error', function (err) {
        if (settled) return
        settled = true
        clearTimeout(timer)
        conn.off('data', onData)
        reject(err)
      })

      conn.once('close', function () {
        if (settled) return
        settled = true
        clearTimeout(timer)
        conn.off('data', onData)
        reject(new Error('Connection closed before response for ' + repoName))
      })
    })
  }

  /**
   * Send a browse request to a remote server's admin topic.
   * Used for server-level commands like serverCatalog.
   */
  async browseServer (remoteServerKey, request, opts) {
    return this.browse(remoteServerKey, '__admin__', request, opts)
  }

  async destroy () {
    this.swarm.off('connection', this._onConnection)
    for (var entry of this._peerConns) {
      if (!entry[1].destroyed) entry[1].destroy()
    }
    this._peerConns.clear()
    this._pendingResolve = null
  }
}

export default BrowseClient