OverviewFilesCommitsIssuesDocsReleasesPeers

git-remote-punch / lib / topic-connector.js

118 lines · javascript ref:
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import crypto from 'hypercore-crypto'
import Hyperswarm from 'hyperswarm'

import { deriveTopicFromKey } from './derive-topic.js'
import { durableConnect } from './durable-connect.js'

/**
 * Client-side P2P connector for punch:// URLs.
 *
 * Joins the Hyperswarm topic derived from a server's public key + repo
 * name, waits for the first connection (the server), and returns the
 * encrypted stream to the caller.
 *
 * Uses a persistent Noise keypair at ~/.config/pear-git/peer-identity.json
 * so the peer's identity is stable across invocations (servers can then
 * recognize returning clients by pubkey).
 */
export class TopicConnector {
  constructor (options = {}) {
    this.swarm = null
    this.publicKey = null
    this.options = options
  }

  /**
   * Initialize the swarm with a persistent keypair.
   */
  async init () {
    const keyPair = await this._loadOrCreateKeyPair()
    this.publicKey = keyPair.publicKey
    this.swarm = new Hyperswarm({ keyPair })
  }

  /**
   * Return this peer's public key as hex.
   */
  getPublicKeyHex () {
    return this.publicKey ? this.publicKey.toString('hex') : null
  }

  /**
   * Connect to a server-advertised repository topic.
   * @param {string|Buffer} serverKey - Server public key
   * @param {string} repoName - Repository name
   * @returns {Promise<Stream>} - Encrypted Noise connection
   */
  async connect (serverKey, repoName, opts = {}) {
    const topic = deriveTopicFromKey(serverKey, repoName)

    // Durable, event-driven connect (lib/durable-connect.js). NO aggressive
    // wall-clock deadline racing the DHT+hole-punch: keep the swarm joined,
    // react to EVERY connection (on, not once), reconcile across connections.
    //
    // WHY: a punch:// connection forms via DHT lookup + hole-punch, inherently
    // variable and much slower under CONCURRENCY — N `git fetch punch://` (the
    // org-repo reverse puller) each spin their own swarm and compete for
    // hole-punch budget. The old hard 15s deadline killed the losers of that
    // race, which failed with ZERO objects (observed on ashatron 2026-07-18:
    // repos that started in the same burst all failed empty; less-contended
    // ones completed). Removing the aggressive deadline lets the losers WAIT
    // out the contention instead of dying. See pear-git org issue #3.
    //
    // `fallbackAfter`/`timeout` (ms) is a GENEROUS no-progress escape ONLY —
    // it lets the helper's HTTPS fallback fire on a truly offline server. It
    // must never race a live connect. 0/undefined = wait indefinitely.
    const fallbackAfter = Number(this.options.fallbackAfter || this.options.timeout) || 0
    return durableConnect(this.swarm, topic, { signal: opts.signal, fallbackAfter })
  }

  /**
   * Tear down the swarm. Safe to call multiple times.
   */
  async destroy () {
    if (this.swarm) {
      await this.swarm.destroy()
      this.swarm = null
    }
  }

  /**
   * Load or create a persistent Noise keypair for this peer.
   * Stored in ~/.config/pear-git/peer-identity.json (0600).
   */
  async _loadOrCreateKeyPair () {
    const configDir = path.join(os.homedir(), '.config', 'pear-git')
    const keyFile = path.join(configDir, 'peer-identity.json')

    try {
      const data = await fs.promises.readFile(keyFile, 'utf-8')
      const saved = JSON.parse(data)
      return {
        publicKey: Buffer.from(saved.publicKey, 'hex'),
        secretKey: Buffer.from(saved.secretKey, 'hex')
      }
    } catch {
      // Generate new persistent identity
      const keyPair = crypto.keyPair()

      try {
        await fs.promises.mkdir(configDir, { recursive: true })
      } catch {}

      await fs.promises.writeFile(keyFile, JSON.stringify({
        publicKey: keyPair.publicKey.toString('hex'),
        secretKey: keyPair.secretKey.toString('hex'),
        createdAt: Date.now()
      }, null, 2), { encoding: 'utf-8', mode: 0o600 })

      return keyPair
    }
  }
}

export default TopicConnector