/**
* P2P Browse Client
*
* Connects to a remote pear-git server over Hyperswarm and requests
* file listings, file content, commits, etc. for browsing remote repos.
*
* Uses the `pgh-browse` protocol — no auth required (federation = full read).
*
* Uses its OWN Hyperswarm instance (browseSwarm), separate from the git
* swarm. This is critical: sharing the git swarm causes data delivery
* failures because Hyperswarm deduplicates connections per peer, and
* listener ordering between TopicManager and BrowseClient is unreliable.
*
* Connection reuse:
* Hyperswarm deduplicates connections per peer. After connecting to a
* server for one topic, subsequent topic joins to the same server won't
* fire new `connection` events. So we cache connections per peer and
* reuse them for all browse requests to the same server.
*
* Protocol (per request on the connection):
* 1. Send: pgh-browse\0repoName\0
* 2. Send: JSON request + newline
* 3. Receive: JSON response + newline
* 4. (Connection stays open for more requests)
*/
import { ServerIdentity } from '../identity/server-identity.js'
export class BrowseClient {
/**
* @param {Hyperswarm} swarm - Dedicated browse swarm (NOT the git swarm)
*/
constructor (swarm) {
this.swarm = swarm
this._peerConns = new Map() // peerKeyHex -> conn (cached connections)
this._pendingResolve = null // resolve function for pending connection
// Persistent listener — handles ALL connections (we own this swarm)
this._onConnection = this._handleConnection.bind(this)
this.swarm.on('connection', this._onConnection)
}
_handleConnection (conn, peerInfo) {
var peerKeyHex = peerInfo.publicKey.toString('hex')
console.log('[BrowseClient] Connected to ' + peerKeyHex.slice(0, 8) + '...')
// Cache the connection by peer Noise key
this._peerConns.set(peerKeyHex, conn)
var self = this
conn.on('close', function () {
if (self._peerConns.get(peerKeyHex) === conn) {
self._peerConns.delete(peerKeyHex)
}
})
// Resolve pending connection request (if any)
// We don't know the peer's Noise key upfront, so we use a single
// pending resolver that matches ANY outgoing connection.
if (self._pendingResolve) {
var resolve = self._pendingResolve
self._pendingResolve = null
resolve(conn)
}
}
/**
* Get a cached connection or wait for a new one.
*/
async _getConnection (remoteServerKey, repoName, timeout) {
// Check cached connections — reuse any live, writable outgoing connection
for (var entry of this._peerConns) {
var conn = entry[1]
if (!conn.destroyed && conn.writable) return conn
// Clean up stale connections (destroyed or write-ended)
if (conn.destroyed || !conn.writable) {
this._peerConns.delete(entry[0])
}
}
console.log('[BrowseClient] Joining topic for ' + repoName + ' (server: ' +
(typeof remoteServerKey === 'string' ? remoteServerKey.slice(0, 8) : remoteServerKey.toString('hex').slice(0, 8)) + '...)')
var topic = ServerIdentity.deriveTopicFromKey(remoteServerKey, repoName)
this.swarm.join(topic, { server: false, client: true })
// Wait for connection event (handled by _handleConnection)
var self = this
return new Promise(function (resolve, reject) {
var timer = setTimeout(function () {
if (self._pendingResolve === resolve) self._pendingResolve = null
reject(new Error('Browse connection timeout'))
}, timeout)
// Check again — connection might have arrived between the check above and now
for (var entry of self._peerConns) {
if (!entry[1].destroyed && entry[1].writable) {
clearTimeout(timer)
resolve(entry[1])
return
}
}
self._pendingResolve = function (conn) {
clearTimeout(timer)
resolve(conn)
}
})
}
/**
* Send a browse request to a remote server.
* Reuses existing connections. Multiple repos can be browsed sequentially.
*
* @param {string|Buffer} remoteServerKey - Remote server's public key (hex or Buffer)
* @param {string} repoName - Repository name
* @param {Object} request - Browse command
* @param {Object} opts - Options { timeout: 30000 }
* @returns {Promise<Object>} Response data
*/
async browse (remoteServerKey, repoName, request, opts) {
opts = opts || {}
var timeout = opts.timeout || 30000
// Get or create connection to the remote server
var conn = await this._getConnection(remoteServerKey, repoName, timeout)
// Send browse header: pgh-browse\0repoName\0
var header = Buffer.concat([
Buffer.from('pgh-browse', 'utf-8'),
Buffer.from([0]),
Buffer.from(repoName, 'utf-8'),
Buffer.from([0])
])
conn.write(header)
// Send request as JSON + newline
conn.write(JSON.stringify(request) + '\n')
console.log('[BrowseClient] Sent browse request for ' + repoName)
// Read response (wait for JSON + newline)
return new Promise(function (resolve, reject) {
var responseBuffer = Buffer.alloc(0)
var settled = false
var timer = setTimeout(function () {
if (settled) return
settled = true
conn.off('data', onData)
reject(new Error('Browse response timeout for ' + repoName))
}, timeout)
function onData (chunk) {
if (settled) return
responseBuffer = Buffer.concat([responseBuffer, chunk])
var nl = responseBuffer.indexOf(10)
if (nl !== -1) {
settled = true
clearTimeout(timer)
conn.off('data', onData)
try {
var response = JSON.parse(responseBuffer.slice(0, nl).toString('utf-8'))
resolve(response)
} catch (err) {
reject(new Error('Invalid browse response for ' + repoName))
}
}
}
conn.on('data', onData)
conn.once('error', function (err) {
if (settled) return
settled = true
clearTimeout(timer)
conn.off('data', onData)
reject(err)
})
conn.once('close', function () {
if (settled) return
settled = true
clearTimeout(timer)
conn.off('data', onData)
reject(new Error('Connection closed before response for ' + repoName))
})
})
}
/**
* Send a browse request to a remote server's admin topic.
* Used for server-level commands like serverCatalog.
*/
async browseServer (remoteServerKey, request, opts) {
return this.browse(remoteServerKey, '__admin__', request, opts)
}
async destroy () {
this.swarm.off('connection', this._onConnection)
for (var entry of this._peerConns) {
if (!entry[1].destroyed) entry[1].destroy()
}
this._peerConns.clear()
this._pendingResolve = null
}
}
export default BrowseClient