OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore / relay / wirecore-relay.js

773 lines · javascript ref:
/**
 * 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)
})