OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / standalone / client.js

283 lines · javascript ref:
/**
 * Standalone Block Storage Client
 * ================================
 * Connects to the server via Hyperswarm, sends blocks via protomux-rpc,
 * and can retrieve them by sequence number.
 *
 * Usage:
 *   node client.js <server-core-public-key>
 *
 * Design decisions:
 *  1. Single Hyperswarm instance — no @hyperswarm/rpc (avoids redundant DHT)
 *  2. Waits for server connection before sending RPC
 *  3. Interactive REPL for manual testing
 */

import Corestore from 'corestore'
import Hyperswarm from 'hyperswarm'
import ProtomuxRPC from 'protomux-rpc'
import c from 'compact-encoding'
import b4a from 'b4a'
import gracedown from 'pear-gracedown'
import { createInterface } from 'readline'
import { mkdirSync } from 'fs'
import crypto from 'crypto'

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

const SERVER_KEY = process.argv[2]
if (!SERVER_KEY) {
  console.error('Usage: node client.js <server-core-public-key>')
  process.exit(1)
}

const STORAGE_DIR = process.env.STORAGE_DIR || './storage-client'

// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------

let store; let swarm; let rpc; let connected = false

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

async function main () {
  console.log('┌─────────────────────────────────────────┐')
  console.log('│  Standalone Block Storage Client         │')
  console.log('└─────────────────────────────────────────┘')
  console.log(`\n  Server key: ${SERVER_KEY.slice(0, 16)}...`)

  // 1. Open Corestore (for replicating the server's Hypercore)
  mkdirSync(STORAGE_DIR, { recursive: true })
  store = new Corestore(STORAGE_DIR)

  // 2. Create Hyperswarm, join as client looking for the server
  swarm = new Hyperswarm()

  // Derive discovery key from the server's core public key
  // (same as server uses: core.discoveryKey)
  const serverPubKey = b4a.from(SERVER_KEY, 'hex')
  const serverCore = store.get({ key: serverPubKey })
  await serverCore.ready()

  swarm.on('connection', (conn, info) => onConnection(conn, info))

  const discovery = swarm.join(serverCore.discoveryKey, { server: false, client: true })
  await discovery.flushed()

  console.log('  Searching for server on DHT...\n')

  // 3. Graceful shutdown
  gracedown(async () => {
    console.log('\n  Shutting down...')
    if (rpc) rpc.destroy()
    await swarm.destroy()
    await store.close()
    console.log('  Done.')
  })
}

// ---------------------------------------------------------------------------
// Connection handler
// ---------------------------------------------------------------------------

function onConnection (conn, info) {
  const shortKey = b4a.toString(conn.remotePublicKey, 'hex').slice(0, 8)
  console.log(`  ← Connected to server: ${shortKey}...`)

  // Replicate corestore (so we get a local copy of the Hypercore)
  store.replicate(conn)

  // Open RPC channel
  rpc = new ProtomuxRPC(conn, {
    id: b4a.from('block-storage-rpc'),
    valueEncoding: c.json
  })

  connected = true
  console.log('  ✓ RPC channel open\n')

  // Start interactive mode
  startREPL()

  conn.on('close', () => {
    console.log('\n  ✗ Server disconnected')
    connected = false
    rpc = null
  })

  conn.on('error', (err) => {
    console.error(`  ✗ Connection error: ${err.message}`)
    connected = false
    rpc = null
  })
}

// ---------------------------------------------------------------------------
// RPC Methods
// ---------------------------------------------------------------------------

async function storeBlock (data) {
  if (!connected || !rpc) {
    console.log('  ✗ Not connected to server')
    return null
  }

  const buf = b4a.isBuffer(data) ? data : b4a.from(data)
  const result = await rpc.request('store-block', {
    data: b4a.toString(buf, 'base64')
  })

  if (result.error) {
    console.log(`  ✗ Error: ${result.error}`)
    return null
  }

  console.log(`  ✓ Stored as block #${result.seq} (core length: ${result.length})`)
  return result
}

async function getBlock (seq) {
  if (!connected || !rpc) {
    console.log('  ✗ Not connected to server')
    return null
  }

  const result = await rpc.request('get-block', { seq })

  if (result.error) {
    console.log(`  ✗ Error: ${result.error}`)
    return null
  }

  const buf = b4a.from(result.data, 'base64')
  console.log(`  ✓ Block #${result.seq} (${result.length} bytes): ${buf.toString()}`)
  return buf
}

async function getInfo () {
  if (!connected || !rpc) {
    console.log('  ✗ Not connected to server')
    return null
  }

  const info = await rpc.request('get-info', {})
  console.log(`  Core key   : ${info.key.slice(0, 16)}...`)
  console.log(`  Blocks     : ${info.length}`)
  console.log(`  Size       : ${info.byteLength} bytes`)
  console.log(`  Peers      : ${info.peers}`)
  console.log(`  Uptime     : ${info.uptime}s`)
  return info
}

// ---------------------------------------------------------------------------
// Interactive REPL
// ---------------------------------------------------------------------------

function startREPL () {
  console.log('  Commands:')
  console.log('    store <text>     Store a text block')
  console.log('    get <seq>        Retrieve block by index')
  console.log('    random [n]       Store n random blocks (default 5)')
  console.log('    info             Server info')
  console.log('    bench [n]        Benchmark n writes (default 100)')
  console.log('    quit             Exit\n')

  const rl = createInterface({
    input: process.stdin,
    output: process.stdout,
    prompt: '  > '
  })

  rl.prompt()

  rl.on('line', async (line) => {
    const parts = line.trim().split(' ')
    const cmd = parts[0]

    try {
      switch (cmd) {
        case 'store':
        case 's': {
          const text = parts.slice(1).join(' ') || `block-${Date.now()}`
          await storeBlock(text)
          break
        }

        case 'get':
        case 'g': {
          const seq = parseInt(parts[1])
          if (isNaN(seq)) {
            console.log('  Usage: get <seq>')
          } else {
            await getBlock(seq)
          }
          break
        }

        case 'random':
        case 'r': {
          const n = parseInt(parts[1]) || 5
          console.log(`  Storing ${n} random blocks...`)
          for (let i = 0; i < n; i++) {
            const data = crypto.randomBytes(64).toString('hex')
            await storeBlock(data)
          }
          break
        }

        case 'info':
        case 'i':
          await getInfo()
          break

        case 'bench':
        case 'b': {
          const n = parseInt(parts[1]) || 100
          console.log(`  Benchmarking ${n} writes...`)
          const start = Date.now()
          for (let i = 0; i < n; i++) {
            const data = crypto.randomBytes(256)
            await storeBlock(data)
          }
          const elapsed = Date.now() - start
          console.log(`  ${n} blocks in ${elapsed}ms (${Math.round(n / (elapsed / 1000))} blocks/sec)`)
          break
        }

        case 'quit':
        case 'q':
        case 'exit':
          rl.close()
          process.exit(0)
          break

        case '':
          break

        default:
          console.log(`  Unknown command: ${cmd}`)
      }
    } catch (err) {
      console.error(`  Error: ${err.message}`)
    }

    rl.prompt()
  })
}

// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------

main().catch((err) => {
  console.error('Fatal:', err)
  process.exit(1)
})

export { storeBlock, getBlock, getInfo }