OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / packages / pear-git-remote / lib / topic-manager.js

214 lines · javascript ref:
/**
 * Topic Manager for Pear-Git P2P
 *
 * Manages Hyperswarm topic announcements for repositories.
 * Each repository gets a unique topic derived from:
 *   topic = hash(serverPublicKey + repoName)
 *
 * This ensures:
 * - Only the server with the private key can serve the topic
 * - Topics are deterministic and reproducible by clients
 * - No name squatting (key-based, not name-based)
 */

import Hyperswarm from 'hyperswarm'

export class TopicManager {
  constructor(serverIdentity, options = {}) {
    this.identity = serverIdentity
    this.swarm = null
    this.topics = new Map() // repoName -> { topic, discovery }
    this.connectionHandler = options.onConnection || null
    this.options = options
  }

  /**
   * Initialize the swarm
   */
  async init() {
    this.swarm = new Hyperswarm()

    this.swarm.on('connection', (conn, peerInfo) => {
      console.log(`[P2P] New connection from ${peerInfo.publicKey.toString('hex').slice(0, 8)}...`)

      if (this.connectionHandler) {
        this.connectionHandler(conn, peerInfo)
      }
    })

    this.swarm.on('error', (err) => {
      console.error('[P2P] Swarm error:', err.message)
    })

    console.log('[P2P] Hyperswarm initialized')
  }

  /**
   * Announce a repository on the DHT
   * @param {string} repoName - Repository name
   * @returns {Object} - { topic, topicHex }
   */
  async announceRepo(repoName) {
    if (this.topics.has(repoName)) {
      return this.topics.get(repoName)
    }

    const topic = this.identity.deriveTopic(repoName)
    const topicHex = topic.toString('hex')

    const discovery = this.swarm.join(topic, { server: true, client: false })
    await discovery.flushed()

    const entry = { topic, topicHex, repoName, discovery }
    this.topics.set(repoName, entry)

    console.log(`[P2P] Announced: ${repoName} -> ${topicHex.slice(0, 8)}...`)
    return entry
  }

  /**
   * Announce multiple repositories
   * @param {Array<string>} repoNames - List of repository names
   */
  async announceRepos(repoNames) {
    const results = []
    for (const name of repoNames) {
      const entry = await this.announceRepo(name)
      results.push(entry)
    }
    return results
  }

  /**
   * Stop announcing a repository
   * @param {string} repoName - Repository name
   */
  async unannounceRepo(repoName) {
    const entry = this.topics.get(repoName)
    if (!entry) return

    await entry.discovery.destroy()
    this.topics.delete(repoName)

    console.log(`[P2P] Unannounced: ${repoName}`)
  }

  /**
   * Get topic info for a repository
   * @param {string} repoName - Repository name
   * @returns {Object|null}
   */
  getTopicInfo(repoName) {
    return this.topics.get(repoName) || null
  }

  /**
   * List all announced topics
   * @returns {Array}
   */
  listTopics() {
    return Array.from(this.topics.values()).map(entry => ({
      repoName: entry.repoName,
      topicHex: entry.topicHex.slice(0, 16) + '...'
    }))
  }

  /**
   * Get the pear:// URL for a repository
   * @param {string} repoName - Repository name
   * @returns {string}
   */
  getPearUrl(repoName) {
    const serverKey = this.identity.getPublicKeyHex()
    return `pear://${serverKey}/${repoName}`
  }

  /**
   * Get a short pear:// URL for display
   * @param {string} repoName - Repository name
   * @returns {string}
   */
  getShortPearUrl(repoName) {
    const serverKey = this.identity.getPublicKeyHex()
    return `pear://${serverKey.slice(0, 8)}.../${repoName}`
  }

  /**
   * Get connection count
   */
  getConnectionCount() {
    return this.swarm ? this.swarm.connections.size : 0
  }

  /**
   * Shutdown the swarm
   */
  async destroy() {
    if (this.swarm) {
      await this.swarm.destroy()
      this.swarm = null
      this.topics.clear()
      console.log('[P2P] Swarm destroyed')
    }
  }
}

/**
 * Client-side topic connector
 * Used by git-remote-pear to connect to servers
 */
export class TopicConnector {
  constructor(options = {}) {
    this.swarm = null
    this.options = options
  }

  /**
   * Initialize the swarm for client connections
   */
  async init() {
    this.swarm = new Hyperswarm()
  }

  /**
   * Connect to a repository topic
   * @param {string|Buffer} serverKey - Server public key
   * @param {string} repoName - Repository name
   * @returns {Promise<Stream>} - Connection stream
   */
  async connect(serverKey, repoName) {
    const { ServerIdentity } = await import('./server-identity.js')
    const topic = ServerIdentity.deriveTopicFromKey(serverKey, repoName)

    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        reject(new Error(`Connection timeout for ${repoName}`))
      }, this.options.timeout || 30000)

      this.swarm.join(topic, { server: false, client: true })

      this.swarm.once('connection', (conn, peerInfo) => {
        clearTimeout(timeout)
        resolve(conn)
      })

      this.swarm.once('error', (err) => {
        clearTimeout(timeout)
        reject(err)
      })
    })
  }

  /**
   * Shutdown the connector
   */
  async destroy() {
    if (this.swarm) {
      await this.swarm.destroy()
      this.swarm = null
    }
  }
}

export default TopicManager