/**
* RelayClient - Simple HTTP + short-lived WS relay interaction
*
* Based on proven wiredove-cli patterns:
* - Push: Short-lived WebSocket (connect, send, close)
* - Pull: HTTP GET requests
* - No persistent state
* - Configurable relay URL
*/
import https from 'bare-https'
import http from 'bare-http1'
import { Socket as WebSocket } from 'bare-ws'
export class RelayClient {
constructor(relayUrl = 'https://7rnx.net/relay') {
this.relayUrl = relayUrl
this.wsUrl = relayUrl.replace('https://', 'wss://').replace('http://', 'ws://')
}
/**
* Push post to relay (short-lived WebSocket)
*
* @param {string} pubkey - Author public key (base64, 44 chars)
* @param {string} yaml - YAML message content
* @param {string} signature - ANProto signature (base64, 132 bytes)
* @param {string} [imageData] - Optional image data (data URL format)
* @returns {Promise<void>}
*/
async push(pubkey, yaml, signature, imageData = null) {
// First: Push the post (pubkey, yaml, signature)
await this._pushPost(pubkey, yaml, signature)
// Then: Push the image as a SEPARATE blob (if present)
// This matches APDS/wiredove protocol: images are independent blobs
if (imageData) {
await this._pushImageBlob(imageData)
}
}
/**
* Push post content to relay
* @private
*/
async _pushPost(pubkey, yaml, signature) {
return new Promise((resolve, reject) => {
console.log(`📤 Pushing post to relay: ${this.wsUrl}`)
const ws = new WebSocket(this.wsUrl)
const timeout = setTimeout(() => {
ws.destroy()
reject(new Error('Relay push timeout (10s)'))
}, 10000)
ws.on('open', () => {
clearTimeout(timeout)
console.log(' ✓ Connected to relay')
try {
// Send post: pubkey → yaml → signature
// Note: bare-ws Socket is a Duplex stream, use .write() not .send()
ws.write(pubkey)
console.log(' → Sent pubkey')
ws.write(yaml)
console.log(' → Sent content')
ws.write(signature)
console.log(' → Sent signature')
// Close after 2 seconds (relay processes messages)
setTimeout(() => {
ws.destroy()
console.log('✅ Post pushed to relay')
resolve()
}, 2000)
} catch (err) {
clearTimeout(timeout)
ws.destroy()
reject(err)
}
})
ws.on('error', (err) => {
clearTimeout(timeout)
console.error('❌ Relay post push error:', err.message)
reject(err)
})
ws.on('close', () => {
clearTimeout(timeout)
})
})
}
/**
* Push image blob to relay (separate from post)
* Images are sent as independent blobs that relay stores by hash
* @private
*/
async _pushImageBlob(imageData) {
return new Promise((resolve, reject) => {
console.log(`📤 Pushing image blob to relay: ${this.wsUrl}`)
const ws = new WebSocket(this.wsUrl)
const timeout = setTimeout(() => {
ws.destroy()
reject(new Error('Image blob push timeout (15s)'))
}, 15000)
ws.on('open', () => {
clearTimeout(timeout)
console.log(' ✓ Connected to relay for image')
try {
// Send image data as separate blob
// Relay will store by hash and gossip independently
ws.write(imageData)
console.log(' → Sent image blob')
// Close after 3 seconds (images are larger, need more time)
setTimeout(() => {
ws.destroy()
console.log('✅ Image blob pushed to relay')
resolve()
}, 3000)
} catch (err) {
clearTimeout(timeout)
ws.destroy()
reject(err)
}
})
ws.on('error', (err) => {
clearTimeout(timeout)
console.error('❌ Image blob push error:', err.message)
reject(err)
})
ws.on('close', () => {
clearTimeout(timeout)
})
})
}
/**
* Pull posts from relay (HTTP GET)
*
* @param {Object} options - Query options
* @param {number} [options.limit] - Max posts to fetch
* @param {string} [options.author] - Filter by author pubkey
* @returns {Promise<Array>} Array of posts
*/
async pull(options = {}) {
const endpoint = options.author
? `/directory/${encodeURIComponent(options.author)}`
: '/all'
const url = `${this.relayUrl}${endpoint}`
console.log(`📥 Pulling from relay: ${url}`)
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Relay pull timeout (30s)'))
}, 30000)
const protocol = this.relayUrl.startsWith('https://') ? https : http
const urlObj = new URL(url)
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'GET'
}
const req = protocol.request(options, (res) => {
let data = ''
res.on('data', chunk => {
data += chunk.toString()
})
res.on('end', () => {
clearTimeout(timeout)
try {
const posts = JSON.parse(data)
const count = Array.isArray(posts) ? posts.length : (posts.total || 0)
console.log(`✅ Pulled ${count} posts from relay`)
resolve(posts)
} catch (err) {
reject(new Error(`Invalid JSON response: ${err.message}`))
}
})
res.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
})
req.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
req.end()
})
}
/**
* Pull user directory from relay
*
* @param {string} [searchQuery] - Optional search query
* @returns {Promise<Object>} Directory response
*/
async pullDirectory(searchQuery = null) {
const endpoint = searchQuery
? `/directory/search?q=${encodeURIComponent(searchQuery)}`
: '/directory'
const url = `${this.relayUrl}${endpoint}`
console.log(`📥 Pulling directory: ${url}`)
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Directory timeout (30s)'))
}, 30000)
const protocol = this.relayUrl.startsWith('https://') ? https : http
const urlObj = new URL(url)
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'GET'
}
const req = protocol.request(options, (res) => {
let data = ''
res.on('data', chunk => data += chunk.toString())
res.on('end', () => {
clearTimeout(timeout)
try {
resolve(JSON.parse(data))
} catch (err) {
reject(new Error(`Invalid JSON: ${err.message}`))
}
})
res.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
})
req.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
req.end()
})
}
/**
* Check relay health
*
* @returns {Promise<Object>} Health status
*/
async health() {
try {
const url = `${this.relayUrl}/stats`
console.log(`🏥 Checking relay health: ${url}`)
const stats = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timeout')), 5000)
const protocol = this.relayUrl.startsWith('https://') ? https : http
const urlObj = new URL(url)
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
path: urlObj.pathname,
method: 'GET'
}
const req = protocol.request(options, (res) => {
let data = ''
res.on('data', chunk => data += chunk.toString())
res.on('end', () => {
clearTimeout(timeout)
try {
resolve(JSON.parse(data))
} catch {
reject(new Error('Invalid response'))
}
})
})
req.on('error', reject)
req.end()
})
return {
online: true,
url: this.relayUrl,
stats
}
} catch (err) {
return {
online: false,
url: this.relayUrl,
error: err.message
}
}
}
}