/**
* Trystero Content Transport (Browser-side)
*
* WebRTC-based content-by-hash synchronization for browsers.
* Compatible with wiredove's sendHash/sendBlob pattern.
*
* Uses Trystero for signaling and WebRTC connections.
* Works with browser's IndexedDB storage via apds.
*/
export class TrysteroContentTransport {
constructor(apds, options = {}) {
this.apds = apds
this.options = {
appId: options.appId || 'wirecore-v1',
password: options.password || 'wirecore-content-sync-2026',
...options
}
this.room = null
this.sendHash = null
this.sendBlob = null
this.peers = new Set()
// Pending requests: requestId -> { hash, resolve, reject, timeout }
this.pendingRequests = new Map()
// Stats
this.stats = {
sent: 0,
received: 0,
peers: 0
}
console.log('TrysteroContentTransport: initialized')
}
/**
* Join a Trystero room for P2P communication
* @param {string} roomName - Room identifier (usually public key)
* @param {Function} joinRoomFn - Trystero's joinRoom function
*/
async joinRoom(roomName, joinRoomFn) {
if (this.room) {
console.log('TrysteroContentTransport: already in room')
return
}
console.log(`TrysteroContentTransport: joining room ${roomName}`)
// Create Trystero room
const roomConfig = {
appId: this.options.appId,
password: this.options.password
}
// Add custom relay URLs if provided
if (this.options.relayUrls) {
roomConfig.relayUrls = this.options.relayUrls
}
// Add custom RTC config (TURN/STUN servers) if provided
if (this.options.rtcConfig) {
roomConfig.rtcConfig = this.options.rtcConfig
}
const room = joinRoomFn(roomConfig, roomName)
// Create hash and blob actions (matching wiredove's pattern)
const [sendHash, onHash] = room.makeAction('hash')
const [sendBlob, onBlob] = room.makeAction('blob')
this.room = room
this.sendHash = sendHash
this.sendBlob = sendBlob
// Handle incoming hash requests
onHash(async (data, peerId) => {
console.log(`TrysteroContentTransport: received hash request from ${peerId}:`, data)
await this.handleHashRequest(data, peerId)
})
// Handle incoming blob responses
onBlob(async (data, peerId) => {
console.log(`TrysteroContentTransport: received blob from ${peerId}`)
await this.handleBlobResponse(data, peerId)
})
// Track peers
room.onPeerJoin((peerId) => {
console.log(`TrysteroContentTransport: peer joined ${peerId}`)
this.peers.add(peerId)
this.stats.peers = this.peers.size
// Send our latest content when peer joins
this.announceLatestContent(peerId)
})
room.onPeerLeave((peerId) => {
console.log(`TrysteroContentTransport: peer left ${peerId}`)
this.peers.delete(peerId)
this.stats.peers = this.peers.size
})
console.log('TrysteroContentTransport: room joined successfully')
}
/**
* Announce latest content to a specific peer
*/
async announceLatestContent(peerId) {
try {
const pubkey = await this.apds.pubkey()
if (!pubkey) return
// Get latest message from own feed
// This would need integration with Wirecore's feed system
// For now, just log
console.log(`TrysteroContentTransport: would announce latest to ${peerId}`)
} catch (err) {
console.error('TrysteroContentTransport: failed to announce latest:', err)
}
}
/**
* Handle incoming hash request from peer
* Peer is asking if we have content for this hash
*/
async handleHashRequest(data, peerId) {
try {
const { hash, requestId } = typeof data === 'string' ? { hash: data, requestId: null } : data
if (!hash) {
console.error('TrysteroContentTransport: invalid hash request')
return
}
// Try to get content from local storage
let content = null
// Check if this is a profile-image request (starts with 'profile-image:')
if (hash.startsWith('profile-image:')) {
// For own profile image
if (hash === `profile-image:${await this.apds.pubkey()}`) {
content = await this.apds.get('profile-image')
} else {
// For other users (we might have cached it)
content = await this.apds.get(hash)
}
} else {
// Check post images
const postImage = await this.apds.get(`post-image:${hash}`)
if (postImage) {
content = postImage
} else {
// Check regular content
content = await this.apds.get(hash)
}
}
if (content) {
console.log(`TrysteroContentTransport: found content for ${hash}, sending to ${peerId}`)
// Send blob back to requesting peer
const response = requestId ? { content, hash, requestId } : content
this.sendBlob(response, peerId)
this.stats.sent++
} else {
console.log(`TrysteroContentTransport: content not found for ${hash}`)
}
} catch (err) {
console.error('TrysteroContentTransport: error handling hash request:', err)
}
}
/**
* Handle incoming blob response from peer
* Peer is sending us content we requested
*/
async handleBlobResponse(data, peerId) {
try {
const { content, hash, requestId } = typeof data === 'string' ? { content: data, hash: null, requestId: null } : data
if (!content) {
console.error('TrysteroContentTransport: invalid blob response')
return
}
this.stats.received++
// If this is a response to a request, resolve the promise
if (requestId && this.pendingRequests.has(requestId)) {
const pending = this.pendingRequests.get(requestId)
clearTimeout(pending.timeout)
this.pendingRequests.delete(requestId)
pending.resolve(content)
return
}
// Otherwise, this is unsolicited content (e.g., new post)
// Store it and process it
if (hash) {
await this.apds.put(`post-image:${hash}`, content)
console.log(`TrysteroContentTransport: stored content for ${hash}`)
}
// Emit event for processing
this.emit?.('blob-received', { content, hash, peerId })
} catch (err) {
console.error('TrysteroContentTransport: error handling blob response:', err)
}
}
/**
* Request content from peers
* @param {string} hash - Content hash to request
* @param {Object} options - { timeout }
* @returns {Promise<string>} Content or null
*/
async requestContent(hash, options = {}) {
const timeout = options.timeout || 10000
const requestId = this.generateRequestId()
console.log(`TrysteroContentTransport: requesting content for ${hash}`)
// Create promise for this request
const promise = new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pendingRequests.delete(requestId)
reject(new Error(`Timeout requesting ${hash}`))
}, timeout)
this.pendingRequests.set(requestId, {
hash,
resolve,
reject,
timeout: timeoutId
})
})
// Send hash request to all peers
if (this.sendHash) {
this.sendHash({ hash, requestId })
this.stats.sent++
}
return promise
}
/**
* Generate unique request ID
*/
generateRequestId() {
return `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
/**
* Check if we have active peers
*/
hasActivePeers() {
return this.peers.size > 0
}
/**
* Get peer count
*/
getPeerCount() {
return this.peers.size
}
/**
* Leave room and cleanup
*/
leaveRoom() {
if (this.room) {
console.log('TrysteroContentTransport: leaving room')
this.room.leave()
this.room = null
this.sendHash = null
this.sendBlob = null
this.peers.clear()
this.pendingRequests.clear()
}
}
/**
* Get stats
*/
getStats() {
return {
...this.stats,
pendingRequests: this.pendingRequests.size
}
}
}
export default TrysteroContentTransport