/**
* Wirecore Relay Server
*
* Bridges pub.wiredove.net with Hypercore backend
* - Relays messages from wiredove.net to Hypercore
* - Relays messages from local clients to wiredove.net
* - Provides APDS-compatible API backed by Hypercore
*/
import http from 'http'
import https from 'https'
import { WebSocket, WebSocketServer } from 'ws'
import { apds, config } from '../lib/wirecore-apds.js'
// Initialize wirecore-apds
await apds.start('wirecore-relay-v1')
if (!await apds.pubkey()) {
const keypair = await apds.generate()
await apds.put('keypair', keypair)
console.log('New relay identity created')
console.log('Pubkey:', await apds.pubkey())
} else {
console.log('Relay identity:', await apds.pubkey())
}
// Track connected WebSocket clients
const sockets = new Set()
/**
* WebSocket handler (apdsbot)
* Compatible with APDS WebSocket protocol
*/
const handleWebSocket = (ws, isBridge = false) => {
sockets.add(ws)
ws.isBridge = isBridge // Mark bridge connections
console.log(isBridge ? 'Bridge connected' : 'Client connected, total:', sockets.size)
ws.on('message', async (data) => {
try {
// Size validation (max 1MB)
const MAX_MESSAGE_SIZE = 1024 * 1024
if (data.length > MAX_MESSAGE_SIZE) {
console.warn('Message too large:', data.length, 'bytes (max:', MAX_MESSAGE_SIZE, ')')
return
}
const msg = data.toString('utf8')
console.log('Received:', msg.substring(0, 50))
if (msg.length === 44) {
// Hash request - retrieve blob
const blob = await apds.get(msg)
if (blob) {
if (ws.readyState === 1) {
ws.send(blob)
console.log(' Sent blob:', msg.substring(0, 16))
}
} else {
console.log(' Blob not found:', msg.substring(0, 16))
// Forward blob request to bridge if we don't have it
const bridge = bridgeWs
if (!isBridge && bridge && bridge.readyState === 1) {
try {
bridge.send(msg)
console.log(' Requested from bridge')
} catch (err) {
console.error('Failed to request from bridge:', err.message)
}
}
// Request from other peers (gossip)
for (const peer of sockets) {
if (peer !== ws && peer.readyState === 1) {
try {
peer.send(msg)
} catch (err) {
console.error('Failed to send to peer:', err.message)
}
}
}
}
} else {
// New message content or signature - store and gossip
const hash = await apds.make(msg)
const added = await apds.add(msg)
// Store blob regardless of whether it's a valid signature
console.log(' Stored blob:', hash.substring(0, 16))
if (added) {
console.log(' ✅ Valid signature, added to message log')
// Broadcast to all connected clients
for (const peer of sockets) {
if (peer !== ws && peer.readyState === 1) {
try {
peer.send(msg)
} catch (err) {
console.error('Failed to broadcast to peer:', err.message)
}
}
}
// Check for missing content and request it
const opened = await apds.open(msg)
if (opened && opened.length >= 13) {
const contentHash = opened.substring(13)
const content = await apds.get(contentHash)
if (!content && ws.readyState === 1) {
try {
ws.send(contentHash)
console.log(' Requesting missing content:', contentHash.substring(0, 16))
} catch (err) {
console.error('Failed to request missing content:', err.message)
}
}
}
}
// Forward ALL messages from local clients to bridge (content blobs + signatures)
// But don't forward messages that came FROM the bridge back to it
// Only forward if bridge is enabled
if (!isBridge && ENABLE_BRIDGE) {
const bridge = bridgeWs
if (bridge && bridge.readyState === 1) {
try {
bridge.send(msg)
console.log(' 📤 Forwarded to wiredove.net:', added ? 'signature' : 'content')
} catch (err) {
console.error('Failed to forward to bridge:', err.message)
}
}
}
}
} catch (err) {
console.error('Error handling WebSocket message:', err.message)
// Don't crash relay, just log and continue
}
})
ws.on('close', () => {
sockets.delete(ws)
console.log(isBridge ? 'Bridge disconnected' : 'Client disconnected, total:', sockets.size)
})
ws.on('error', (err) => {
console.error('WebSocket error:', err.message)
sockets.delete(ws)
})
}
/**
* HTTP handler (directory)
* Compatible with APDS HTTP API
*/
const handleHTTP = async (req, res) => {
const url = new URL(req.url, 'http://localhost')
const key = url.pathname.substring(1)
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
if (req.method === 'OPTIONS') {
res.writeHead(200)
res.end()
return
}
try {
// GET / - web UI
if (key === '' || key === 'index.html') {
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wirecore Relay</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
h1 { margin-bottom: 20px; color: #58a6ff; }
.stats { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 20px; margin-bottom: 20px; }
.stat { display: inline-block; margin-right: 30px; }
.stat-value { font-size: 24px; font-weight: bold; color: #58a6ff; }
.stat-label { font-size: 12px; color: #8b949e; text-transform: uppercase; }
.messages { margin-top: 20px; }
.message { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 15px; margin-bottom: 10px; }
.message-header { display: flex; justify-content: space-between; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #30363d; }
.author { font-family: monospace; font-size: 12px; color: #58a6ff; }
.timestamp { font-size: 12px; color: #8b949e; }
.message-text { white-space: pre-wrap; line-height: 1.6; }
.message-text pre { background: #0d1117; padding: 10px; border-radius: 4px; overflow-x: auto; }
.no-messages { text-align: center; color: #8b949e; padding: 40px; }
button { background: #238636; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; margin-bottom: 20px; }
button:hover { background: #2ea043; }
.mode { font-size: 14px; color: #f85149; margin-left: 20px; }
.mode.local { color: #3fb950; }
</style>
</head>
<body>
<div class="container">
<h1>🚀 Wirecore Relay <span class="mode" id="mode"></span></h1>
<div class="stats">
<div class="stat">
<div class="stat-value" id="messageCount">-</div>
<div class="stat-label">Messages</div>
</div>
<div class="stat">
<div class="stat-value" id="authorCount">-</div>
<div class="stat-label">Authors</div>
</div>
<div class="stat">
<div class="stat-value" id="clients">-</div>
<div class="stat-label">Connected</div>
</div>
<div class="stat">
<div class="stat-value" id="bridge">-</div>
<div class="stat-label">Bridge</div>
</div>
</div>
<button onclick="loadMessages()">🔄 Refresh</button>
<button onclick="window.location.href='stats'">📊 Stats JSON</button>
<button onclick="window.location.href='all'">📋 Messages JSON</button>
<div class="messages" id="messages">
<div class="no-messages">Loading messages...</div>
</div>
</div>
<script>
async function loadStats() {
const res = await fetch('stats')
const stats = await res.json()
document.getElementById('messageCount').textContent = stats.messages.total
document.getElementById('authorCount').textContent = stats.messages.authors
document.getElementById('clients').textContent = stats.relay.clients
const bridgeEnabled = ${ENABLE_BRIDGE}
document.getElementById('bridge').textContent = bridgeEnabled ? 'Enabled' : 'Local'
document.getElementById('mode').textContent = bridgeEnabled ? '(Bridge Mode)' : '(Local Mode)'
document.getElementById('mode').className = bridgeEnabled ? 'mode' : 'mode local'
}
async function loadMessages() {
const res = await fetch('all')
const messages = await res.json()
const container = document.getElementById('messages')
if (messages.length === 0) {
container.innerHTML = '<div class="no-messages">No messages yet</div>'
return
}
container.innerHTML = messages.slice().reverse().map(msg => {
const date = msg.ts ? new Date(parseInt(msg.ts)).toLocaleString() : 'Unknown'
const text = msg.text || '(No content)'
const author = msg.author ? msg.author.substring(0, 16) + '...' : 'Unknown'
return \`
<div class="message">
<div class="message-header">
<span class="author">\${author}</span>
<span class="timestamp">\${date}</span>
</div>
<div class="message-text">\${escapeHtml(text)}</div>
</div>
\`
}).join('')
}
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
loadStats()
loadMessages()
setInterval(loadStats, 5000)
</script>
</body>
</html>`
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(html)
return
}
// GET /all - all messages
if (key === 'all') {
const messages = await apds.query()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(messages))
return
}
// GET /latest - recent messages (last 5 minutes)
if (key === 'latest') {
const messages = await apds.query()
const cutoff = Date.now() - (5 * 60 * 1000)
const latest = messages.filter(m => m.ts && parseInt(m.ts) > cutoff)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(latest))
return
}
// GET /stats - wirecore relay stats
if (key === 'stats') {
const info = apds.getCoreInfo()
const stats = {
relay: {
pubkey: await apds.pubkey(),
clients: sockets.size,
uptime: process.uptime()
},
wirecore: info,
messages: {
total: (await apds.getHashLog()).length,
authors: (await apds.getPubkeys()).length
}
}
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(stats, null, 2))
return
}
// Helper function to extract name from YAML frontmatter
const extractName = (text) => {
if (!text) return null
// Match name: field anywhere in YAML frontmatter (between --- markers)
const nameMatch = text.match(/^---[\s\S]*?\nname:\s*['"]?([^'"\n]+)['"]?/m)
return nameMatch ? nameMatch[1].trim() : null
}
// GET /directory/{pubkey} - view user profile with recent posts
if (key.startsWith('directory/') && key !== 'directory/search') {
try {
const pubkey = key.substring('directory/'.length)
const messages = await apds.query(pubkey) // Get all messages from this user
if (messages.length === 0) {
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'User not found' }))
return
}
// Extract name from latest message
let name = null
for (let i = messages.length - 1; i >= 0 && !name; i--) {
name = extractName(messages[i].text)
}
// Get recent posts (last 10)
const recentPosts = messages.slice(-10).reverse().map(msg => ({
text: msg.text,
timestamp: msg.ts,
hash: msg.hash
}))
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
pubkey: pubkey,
name: name || 'Unknown',
messageCount: messages.length,
lastSeen: messages[messages.length - 1]?.ts,
recentPosts: recentPosts
}, null, 2))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
return
}
// GET /directory/search?q=name - search users
if (key === 'directory/search') {
try {
const query = url.searchParams.get('q') || ''
const pubkeys = await apds.getPubkeys()
const users = []
// Get latest message from each user for their name
for (const pubkey of pubkeys) {
const latest = await apds.getLatest(pubkey)
if (latest && latest.text) {
const name = extractName(latest.text)
if (name) {
// Filter by query if provided
if (!query || name.toLowerCase().includes(query.toLowerCase())) {
users.push({
pubkey: pubkey,
name: name,
lastSeen: latest.ts
})
}
}
}
}
users.sort((a, b) => b.lastSeen - a.lastSeen)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
query: query,
total: users.length,
users: users
}, null, 2))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
return
}
// GET /directory - list all users
if (key === 'directory') {
try {
const pubkeys = await apds.getPubkeys()
const users = []
// Get latest message from each user for their name
for (const pubkey of pubkeys) {
const latest = await apds.getLatest(pubkey)
if (latest && latest.text) {
const name = extractName(latest.text)
if (name) {
users.push({
pubkey: pubkey,
name: name,
lastSeen: latest.ts
})
}
}
}
users.sort((a, b) => b.lastSeen - a.lastSeen)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
total: users.length,
users: users
}, null, 2))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
return
}
// GET /config - view current configuration
// NOTE: This endpoint should be protected in production (firewall, auth, etc.)
// Currently only accessible via localhost or internal network
if (key === 'config' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
maxConcurrentFetches: config.maxConcurrentFetches,
maxCachedMessages: config.maxCachedMessages,
lazyLoadLimit: config.lazyLoadLimit,
fetchTimeout: config.fetchTimeout,
remoteServer: config.remoteServer
}, null, 2))
return
}
// POST /config - update configuration
if (key === 'config' && req.method === 'POST') {
let body = ''
req.on('data', chunk => body += chunk)
req.on('end', () => {
try {
const updates = JSON.parse(body)
// Validate and apply updates
if (updates.maxConcurrentFetches !== undefined) {
const val = parseInt(updates.maxConcurrentFetches)
if (val > 0 && val <= 100) {
config.maxConcurrentFetches = val
https.globalAgent.maxSockets = val
console.log(`Updated maxConcurrentFetches: ${val}`)
}
}
if (updates.maxCachedMessages !== undefined) {
const val = parseInt(updates.maxCachedMessages)
if (val >= 100 && val <= 100000) {
config.maxCachedMessages = val
console.log(`Updated maxCachedMessages: ${val}`)
}
}
if (updates.lazyLoadLimit !== undefined) {
const val = parseInt(updates.lazyLoadLimit)
if (val > 0 && val <= 100) {
config.lazyLoadLimit = val
console.log(`Updated lazyLoadLimit: ${val}`)
}
}
if (updates.fetchTimeout !== undefined) {
const val = parseInt(updates.fetchTimeout)
if (val >= 1000 && val <= 30000) {
config.fetchTimeout = val
console.log(`Updated fetchTimeout: ${val}ms`)
}
}
if (updates.remoteServer !== undefined && typeof updates.remoteServer === 'string') {
if (updates.remoteServer.startsWith('http://') || updates.remoteServer.startsWith('https://')) {
config.remoteServer = updates.remoteServer
console.log(`Updated remoteServer: ${updates.remoteServer}`)
}
}
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ success: true, config }))
} catch (err) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Invalid JSON' }))
}
})
return
}
// GET /{hash} - specific blob
const blob = await apds.get(key)
if (blob) {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(blob)
return
}
// GET /{query} - query messages
const results = await apds.query(key)
if (results && results.length > 0) {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(results))
return
}
// Not found
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not found')
} catch (err) {
console.error('HTTP error:', err)
res.writeHead(500, { 'Content-Type': 'text/plain' })
res.end('Internal server error')
}
}
/**
* Create HTTP server with WebSocket upgrade
*/
const server = http.createServer(handleHTTP)
const wss = new WebSocketServer({ noServer: true })
server.on('upgrade', (req, socket, head) => {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req)
handleWebSocket(ws)
})
})
/**
* Bridge to pub.wiredove.net
* Keeps Hypercore storage in sync with wiredove.net network
*/
let bridgeWs = null
let reconnectAttempts = 0
const MAX_RECONNECT_ATTEMPTS = 20
const INITIAL_BACKOFF = 5000
const MAX_BACKOFF = 60000
async function syncFromWiredove() {
console.log('🌉 Syncing existing messages from wiredove.net...')
try {
// Fetch all messages via HTTP with size limit and timeout
const response = await new Promise((resolve, reject) => {
const MAX_RESPONSE_SIZE = 100 * 1024 * 1024 // 100MB max
let totalSize = 0
const req = https.get('https://pub.wiredove.net/all', { timeout: 30000 }, (res) => {
let data = ''
res.on('data', chunk => {
totalSize += chunk.length
if (totalSize > MAX_RESPONSE_SIZE) {
res.destroy()
reject(new Error(`Response too large: ${totalSize} bytes`))
return
}
data += chunk
})
res.on('end', () => {
try {
resolve(JSON.parse(data))
} catch (err) {
reject(new Error(`Invalid JSON response: ${err.message}`))
}
})
res.on('error', reject)
})
req.on('error', reject)
req.on('timeout', () => {
req.destroy()
reject(new Error('Request timeout'))
})
})
// Store each message
let synced = 0
for (const msg of response) {
if (msg.sig) {
try {
const added = await apds.add(msg.sig)
if (added) synced++
} catch (err) {
console.error('Failed to add message:', err.message)
// Continue with next message
}
}
}
console.log(`✅ Synced ${synced} messages from wiredove.net`)
return true
} catch (err) {
console.error('❌ Failed to sync from wiredove.net:', err.message)
return false
}
}
function connectToWiredove() {
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.error('❌ Max reconnect attempts reached, giving up on bridge')
return
}
console.log('🌉 Connecting to pub.wiredove.net bridge...')
bridgeWs = new WebSocket('wss://pub.wiredove.net/')
bridgeWs.on('open', () => {
reconnectAttempts = 0 // Reset on successful connection
bridgeWs.isBridge = true
sockets.add(bridgeWs)
console.log('✅ Bridge connected to wiredove.net')
})
bridgeWs.on('message', async (data) => {
try {
// Size validation (max 1MB)
const MAX_MESSAGE_SIZE = 1024 * 1024
if (data.length > MAX_MESSAGE_SIZE) {
console.warn('Bridge message too large:', data.length)
return
}
const msg = data.toString('utf8')
console.log('📥 From wiredove.net:', msg.substring(0, 50))
// Handle blob responses (when we requested content)
if (msg.length !== 44 && !msg.endsWith('==')) {
// This is content blob from bridge
const hash = await apds.make(msg)
console.log('📥 Got content from bridge:', hash.substring(0, 16))
return
}
// Handle signatures from wiredove.net
if (msg.length > 44 && msg.endsWith('==')) {
const added = await apds.add(msg)
if (added) {
console.log('📥 Relayed signature from wiredove.net:', (await apds.hash(msg)).substring(0, 16))
// Broadcast to local clients (exclude bridge)
for (const socket of sockets) {
if (!socket.isBridge && socket.readyState === 1) {
try {
socket.send(msg)
} catch (err) {
console.error('Failed to broadcast from bridge:', err.message)
}
}
}
}
}
} catch (err) {
console.error('Error handling bridge message:', err.message)
// Don't crash, just log and continue
}
})
bridgeWs.on('close', () => {
sockets.delete(bridgeWs)
console.log('⚠️ Bridge disconnected from wiredove.net')
// Exponential backoff
const backoff = Math.min(
INITIAL_BACKOFF * Math.pow(2, reconnectAttempts),
MAX_BACKOFF
)
reconnectAttempts++
console.log(`Reconnecting in ${backoff}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`)
setTimeout(connectToWiredove, backoff)
})
bridgeWs.on('error', (err) => {
console.error('Bridge error:', err.message)
})
}
// Start server
const PORT = process.env.PORT || 9000
const ENABLE_BRIDGE = process.env.ENABLE_BRIDGE !== 'false' // Default: enabled
server.listen(PORT, async () => {
console.log('\n🚀 Wirecore Relay Server')
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
console.log(`WebSocket: ws://localhost:${PORT}/`)
console.log(`HTTP API: http://localhost:${PORT}/all`)
console.log(`Stats: http://localhost:${PORT}/stats`)
console.log(`Web UI: http://localhost:${PORT}/`)
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
console.log('Backend: Hypercore (P2P replicated)')
console.log('Protocol: APDS-compatible')
console.log(`Bridge: ${ENABLE_BRIDGE ? 'ENABLED' : 'DISABLED'}`)
console.log('Ready for connections...\n')
// Sync existing messages and connect bridge (only if enabled)
if (ENABLE_BRIDGE) {
await syncFromWiredove()
connectToWiredove()
} else {
console.log('⚠️ Bridge to pub.wiredove.net is DISABLED')
console.log(' Running in local-only mode')
console.log(' Set ENABLE_BRIDGE=true to enable bridge\n')
}
})
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\nShutting down...')
// Close bridge connection
if (bridgeWs) bridgeWs.close()
// Close HTTP server and wait for callback
await new Promise((resolve) => {
server.close(() => {
console.log('HTTP server closed')
resolve()
})
})
// Close APDS (Hypercore/Hyperswarm)
await apds.close()
console.log('Shutdown complete')
process.exit(0)
})