OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer / integration / layer6_sidecar.js

216 lines · javascript ref:
// Layer 6 — Test Sidecar Instance
//
// Stripped-down sidecar for integration tests.
// Uses DHT testnet bootstrap (no public DHT).
// Connects to Hyperswarm topic, exchanges metadata (name, encryptionKey, hosting, gamePort).
//
// Usage: node layer6_sidecar.js <index> <bootstrap_file> <socket_path>

import Hyperswarm from 'hyperswarm'
import DHT from 'hyperdht'
import { createHash } from 'crypto'
import net from 'net'
import fs from 'fs'

const index = parseInt(process.argv[2] || '0', 10)
const bootstrapFile = process.argv[3] || '/tmp/layer_bootstrap.txt'
const socketPath = process.argv[4] || `/tmp/layer6_sidecar_${index}.sock`
const peerInfoFile = `/tmp/layer6_peer_${index}.json`
const GAME_TOPIC = 'hyper-game:test'

// Read bootstrap
const bootstrapAddrs = fs.readFileSync(bootstrapFile, 'utf8').trim().split(',')
const bootstrap = bootstrapAddrs.map(addr => {
  const [host, port] = addr.split(':')
  return { host, port: parseInt(port, 10) }
})

console.log(`[sidecar-${index}] bootstrap: ${bootstrapAddrs.join(', ')}`)

// Identity
const kp = DHT.keyPair()
const identity = {
  name: `Player-${index}`,
  publicKey: kp.publicKey.toString('hex'),
  encryptionKey: '',
  hosting: false,
  gamePort: 0
}

console.log(`[sidecar-${index}] pk: ${identity.publicKey.slice(0, 12)}...`)

// Peer tracking
const peers = new Map()
let godotSocket = null
let closed = false

function send (obj) {
  if (!godotSocket || godotSocket.destroyed) return
  godotSocket.write(JSON.stringify(obj) + '\n')
}

// Hyperswarm discovery
const swarm = new Hyperswarm({ keyPair: kp, bootstrap })
const topic = createHash('sha256').update(GAME_TOPIC).digest()

swarm.on('connection', (conn, info) => {
  const remoteKey = info.publicKey.toString('hex')
  console.log(`[sidecar-${index}] connection from ${remoteKey.slice(0, 12)}...`)

  // Send our metadata
  conn.write(JSON.stringify(identity) + '\n')

  let buf = ''
  conn.on('data', (chunk) => {
    buf += chunk.toString()
    let idx
    while ((idx = buf.indexOf('\n')) !== -1) {
      const line = buf.slice(0, idx).trim()
      buf = buf.slice(idx + 1)
      if (!line) continue
      try {
        const meta = JSON.parse(line)
        if (meta.publicKey && meta.name) {
          const existing = peers.get(meta.publicKey)
          if (!existing) {
            peers.set(meta.publicKey, { ...meta, conn, info })
            send({ event: 'peer-discovered', ...meta })
            console.log(`[sidecar-${index}] discovered: ${meta.name} (${meta.publicKey.slice(0, 12)}...) hosting=${meta.hosting}`)
          } else {
            // Update
            Object.assign(existing, meta)
            send({ event: 'peer-updated', ...meta })
          }
        }
        if (meta.requestAddress) {
          const raw = conn.rawStream
          if (raw) {
            send({ event: 'peer-address', publicKey: meta.from || remoteKey, host: raw.remoteHost || '127.0.0.1', port: meta.gamePort || 9514 })
          }
        }
      } catch {}
    }
  })

  conn.on('close', () => {
    for (const [pk, p] of peers) {
      if (p.conn === conn) {
        peers.delete(pk)
        send({ event: 'peer-gone', publicKey: pk })
        break
      }
    }
  })
  conn.on('error', () => {})
})

const discovery = swarm.join(topic, { server: true, client: true })
await discovery.flushed()
console.log(`[sidecar-${index}] topic joined, waiting for peers...`)

// Write peer info
fs.writeFileSync(peerInfoFile, JSON.stringify(identity))

// IPC server for Godot
try { fs.unlinkSync(socketPath) } catch {}
const server = net.createServer((socket) => {
  if (godotSocket) { socket.destroy(); return }
  godotSocket = socket
  let buffer = ''

  socket.on('data', (chunk) => {
    buffer += chunk.toString()
    let idx
    while ((idx = buffer.indexOf('\n')) !== -1) {
      const line = buffer.slice(0, idx).trim()
      buffer = buffer.slice(idx + 1)
      if (!line) continue
      try {
        const msg = JSON.parse(line)
        handleMessage(msg)
      } catch {}
    }
  })

  socket.on('close', () => { godotSocket = null })
  socket.on('error', () => { godotSocket = null })
})

server.listen(socketPath, () => {
  console.log(`[sidecar-${index}] IPC on ${socketPath}`)
  send({ event: 'identity', ...identity })
})

function handleMessage (msg) {
  switch (msg.method) {
    case 'host': {
      identity.hosting = true
      identity.gamePort = msg.gamePort || 19518
      identity.encryptionKey = msg.encryptionKey || ''
      // Broadcast update to all peers
      for (const [, peer] of peers) {
        if (peer.conn && !peer.conn.destroyed) {
          peer.conn.write(JSON.stringify(identity) + '\n')
        }
      }
      console.log(`[sidecar-${index}] hosting on port ${identity.gamePort}`)
      // Update peer info file
      fs.writeFileSync(peerInfoFile, JSON.stringify(identity))
      break
    }
    case 'stop-hosting': {
      identity.hosting = false
      identity.encryptionKey = ''
      for (const [, peer] of peers) {
        if (peer.conn && !peer.conn.destroyed) {
          peer.conn.write(JSON.stringify(identity) + '\n')
        }
      }
      break
    }
    case 'join': {
      const pk = msg.publicKey
      const peer = peers.get(pk)
      if (!peer) {
        send({ event: 'error', message: `Unknown peer: ${pk}` })
        return
      }
      const raw = peer.conn?.rawStream
      if (raw) {
        send({ event: 'peer-address', publicKey: pk, host: raw.remoteHost || '127.0.0.1', port: peer.gamePort || 19518 })
      }
      break
    }
    case 'stats': {
      send({
        event: 'stats',
        identity,
        peers: Array.from(peers.entries()).map(([pk, p]) => ({
          publicKey: pk.slice(0, 12),
          name: p.name,
          hosting: p.hosting,
          encryptionKey: (p.encryptionKey || '').slice(0, 12)
        }))
      })
      break
    }
    case 'shutdown': {
      close()
      break
    }
  }
}

async function close () {
  if (closed) return
  closed = true
  await swarm.destroy()
  server.close()
  try { fs.unlinkSync(socketPath) } catch {}
  try { fs.unlinkSync(peerInfoFile) } catch {}
  process.exit(0)
}

process.on('SIGTERM', close)
process.on('SIGINT', close)