/**
* 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)
*
* Uses a shared Hyperswarm instance injected from the server.
*/
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 { ServerIdentity } from '../identity/server-identity.js'
export class TopicManager {
constructor(serverIdentity, options = {}) {
this.identity = serverIdentity
this.swarm = options.swarm || null
this.topics = new Map() // repoName -> { topic, discovery }
this.connectionHandler = options.onConnection || null
this.options = options
}
/**
* Initialize the topic manager
* Requires swarm to be injected via constructor options
*/
async init() {
if (!this.swarm) {
throw new Error('TopicManager requires a shared Hyperswarm instance')
}
this.swarm.on('connection', (conn, peerInfo) => {
var peerKeyHex = peerInfo.publicKey.toString('hex').slice(0, 8)
console.log(`[P2P] ${conn.isInitiator ? 'Outgoing' : 'Incoming'} connection ${peerKeyHex}...`)
if (this.connectionHandler) {
this.connectionHandler(conn, peerInfo)
}
})
console.log('[P2P] TopicManager initialized (shared swarm)')
}
/**
* 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 — join all topics then flush once.
* Previous: Promise.all(65x flushed()) overwhelmed the DHT.
* Now: join all (instant), then swarm.flush() once for the batch.
* @param {Array<string>} repoNames - List of repository names
*/
async announceRepos(repoNames) {
for (const name of repoNames) {
if (this.topics.has(name)) continue
const topic = this.identity.deriveTopic(name)
const topicHex = topic.toString('hex')
const discovery = this.swarm.join(topic, { server: true, client: false })
this.topics.set(name, { topic, topicHex, repoName: name, discovery })
console.log(`[P2P] Announced: ${name} -> ${topicHex.slice(0, 8)}...`)
}
// Single flush for all topics — much faster than per-repo flushed()
await this.swarm.flush()
}
/**
* 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) + '...'
}))
}
/**
* Announce the admin topic (__admin__)
* Uses a reserved name that cannot collide with repo names.
*/
async announceAdminTopic () {
return this.announceRepo('__admin__')
}
/**
* Get the punch:// URL for a repository (P2P git transport)
* @param {string} repoName - Repository name
* @returns {string}
*/
getPunchUrl(repoName) {
const serverKey = this.identity.getPublicKeyHex()
return `punch://${serverKey}/${repoName}`
}
/**
* Get a short punch:// URL for display
* @param {string} repoName - Repository name
* @returns {string}
*/
getShortPunchUrl(repoName) {
const serverKey = this.identity.getPublicKeyHex()
return `punch://${serverKey.slice(0, 8)}.../${repoName}`
}
/**
* Get connection count
*/
getConnectionCount() {
return this.swarm ? this.swarm.connections.size : 0
}
/**
* Cleanup topic announcements (does NOT destroy the shared swarm)
*/
async destroy() {
for (const [name, entry] of this.topics) {
await entry.discovery.destroy()
}
this.topics.clear()
this.swarm = null
console.log('[P2P] TopicManager cleaned up')
}
}
/**
* Client-side topic connector
* Used by git-remote-pear/git-remote-punch to connect to servers.
* Creates its own Hyperswarm (runs in a separate process).
*
* Uses a persistent keypair so the peer's Hyperswarm public key is stable
* across connections. This is required for P2P push auth — the server
* identifies peers by their Noise public key.
*/
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 })
}
/**
* Get this peer's public key hex (for registering with servers)
*/
getPublicKeyHex() {
return this.publicKey ? this.publicKey.toString('hex') : null
}
/**
* 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 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
}
}
/**
* Load or create a persistent Noise keypair for this peer.
* Stored in ~/.config/pear-git/peer-identity.json
*/
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 TopicManager