/**
* 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