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