OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-deploy / test / e2e-release-cross-machine.js

374 lines · javascript ref:
#!/usr/bin/env bare
/**
 * hyper-deploy Phase 3: Cross-machine release distribution
 *
 * Server (tabertron): indexes a release directory, serves chunks over Hyperswarm
 * Client (stortron): connects, pulls all files, verifies hashes
 *
 * Usage:
 *   Server: bare test/e2e-release-cross-machine.js server <dir>
 *   Client: bare test/e2e-release-cross-machine.js client <topic> <dest-dir>
 */

await import('bare-node-runtime/global')

import Hyperswarm from 'hyperswarm'
import Corestore from 'corestore'
import Protomux from 'protomux'
import c from 'compact-encoding'
import crypto from 'hypercore-crypto'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'

import { FileIndex } from 'hyper-overlay/file-index'
import { TransferManager } from 'hyper-overlay/transfer'
import { hashChunk } from 'hyper-overlay/chunker'

const PROTOCOL = 'hyper-deploy-release'

const args = process.argv.slice(2)
const mode = args[0]

if (!mode || !['server', 'client'].includes(mode)) {
  console.log('Usage:')
  console.log('  Server: bare test/e2e-release-cross-machine.js server <release-dir>')
  console.log('  Client: bare test/e2e-release-cross-machine.js client <topic-hex> <dest-dir>')
  process.exit(1)
}

// --- Helpers ---

function walkDir (dir, prefix) {
  prefix = prefix || ''
  const results = []
  for (const entry of fs.readdirSync(dir)) {
    const full = path.join(dir, entry)
    const rel = prefix ? prefix + '/' + entry : entry
    try {
      const stat = fs.statSync(full)
      if (stat.isDirectory()) {
        results.push(...walkDir(full, rel))
      } else if (stat.isFile()) {
        results.push({ path: rel, size: stat.size })
      }
    } catch {}
  }
  return results
}

function hashFile (filePath) {
  const data = fs.readFileSync(filePath)
  return crypto.data(data).toString('hex')
}

function formatBytes (bytes) {
  if (bytes < 1024) return bytes + ' B'
  if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
  if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'
  return (bytes / 1073741824).toFixed(2) + ' GB'
}

// ============================================================
// SERVER
// ============================================================

async function runServer () {
  const releaseDir = args[1]
  if (!releaseDir) {
    console.error('Usage: server <release-dir>')
    process.exit(1)
  }

  const absDir = path.resolve(releaseDir)
  if (!fs.existsSync(absDir)) {
    console.error('Directory not found:', absDir)
    process.exit(1)
  }

  console.log('[server] Scanning:', absDir)
  const files = walkDir(absDir)
  let totalSize = 0
  for (const f of files) totalSize += f.size
  console.log(`[server] ${files.length} files, ${formatBytes(totalSize)}`)

  const storePath = path.join(os.tmpdir(), `deploy-srv-${Date.now()}`)
  const store = new Corestore(storePath)
  const fileIndex = new FileIndex(store)
  const transfer = new TransferManager(fileIndex)
  await fileIndex.ready()

  // Index all files (metadata only — no data stored)
  console.log('[server] Indexing...')
  const indexStart = Date.now()
  for (let i = 0; i < files.length; i++) {
    const f = files[i]
    const fullPath = path.join(absDir, f.path)
    const contentHash = crypto.data(fs.readFileSync(fullPath)).toString('hex')
    await fileIndex.putFile(f.path, {
      contentHash,
      size: f.size,
      mtime: fs.statSync(fullPath).mtimeMs
    })
    if ((i + 1) % 1000 === 0) console.log(`  indexed ${i + 1}/${files.length}`)
  }
  console.log(`[server] Indexed in ${((Date.now() - indexStart) / 1000).toFixed(1)}s`)

  const swarm = new Hyperswarm()
  const topic = crypto.randomBytes(32)

  console.log(`[server] Topic: ${topic.toString('hex')}`)
  console.log()
  console.log('Run on client:')
  console.log(`  bare test/e2e-release-cross-machine.js client ${topic.toString('hex')} /tmp/deploy-test-output`)
  console.log()

  swarm.on('connection', (conn) => {
    const peerKey = conn.remotePublicKey.toString('hex').slice(0, 12)
    console.log(`[server] Peer connected: ${peerKey}`)

    store.replicate(conn)
    const mux = Protomux.from(conn)

    let drainResolve = null

    const ch = mux.createChannel({
      protocol: PROTOCOL,
      id: Buffer.from('release'),
      ondrain () {
        // Protomux calls this when the underlying stream drains
        if (drainResolve) {
          const resolve = drainResolve
          drainResolve = null
          resolve()
        }
      }
    })
    if (!ch) { console.error('[server] channel null'); return }

    const fileListMsg = ch.addMessage({ encoding: c.raw })
    const fileReqMsg = ch.addMessage({ encoding: c.raw })
    const chunkInfoMsg = ch.addMessage({ encoding: c.raw })
    const chunkDataMsg = ch.addMessage({ encoding: c.raw })
    const controlMsg = ch.addMessage({ encoding: c.raw })

    let bytesSent = 0
    let filesSent = 0
    let chunksSent = 0
    let drainWaits = 0

    // Handle file requests from client
    fileReqMsg.onmessage = async (data) => {
      const req = JSON.parse(data.toString())
      const filePath = path.join(absDir, req.path)

      const prepared = await transfer.prepareFile(filePath, req.path)
      if (!prepared) {
        controlMsg.send(Buffer.from(JSON.stringify({ type: 'error', path: req.path, msg: 'prepare failed' })))
        return
      }

      // Send chunk info
      chunkInfoMsg.send(Buffer.from(JSON.stringify({
        path: req.path,
        size: prepared.size,
        chunks: prepared.chunks
      })))

      // Send chunks with proper backpressure via ondrain callback
      const haveSet = new Set(req.have || [])

      for (let i = 0; i < prepared.chunks.length; i++) {
        if (haveSet.has(i)) continue

        const bytes = transfer.readChunk(filePath, prepared.chunks[i].offset, prepared.chunks[i].length)
        if (!bytes) continue

        const header = Buffer.alloc(4)
        header.writeUInt32LE(i)
        const drained = chunkDataMsg.send(Buffer.concat([header, bytes]))
        bytesSent += bytes.length
        chunksSent++

        if (!drained) {
          // Wait for ondrain callback before continuing
          drainWaits++
          await new Promise(resolve => { drainResolve = resolve })
        }
      }

      filesSent++
      if (filesSent % 100 === 0 || filesSent === 1) {
        console.log(`[server] sent ${filesSent} files, ${chunksSent} chunks, ${formatBytes(bytesSent)}, ${drainWaits} drain waits`)
      }
    }

    // Handle control messages
    controlMsg.onmessage = (data) => {
      const msg = JSON.parse(data.toString())
      if (msg.type === 'done') {
        console.log(`[server] Client done. Sent ${filesSent} files, ${formatBytes(bytesSent)}`)
      }
    }

    ch.open()

    // Send file list
    const fileList = files.map(f => ({ path: f.path, size: f.size }))
    fileListMsg.send(Buffer.from(JSON.stringify(fileList)))
    console.log(`[server] Sent file list (${files.length} files)`)
  })

  const discovery = swarm.join(topic, { server: true, client: false })
  await discovery.flushed()
  console.log('[server] Announced on DHT, waiting for client...')
}

// ============================================================
// CLIENT
// ============================================================

async function runClient () {
  const topicHex = args[1]
  const destDir = args[2] || '/tmp/deploy-test-output'

  if (!topicHex || topicHex.length !== 64) {
    console.error('Usage: client <64-char-topic-hex> [dest-dir]')
    process.exit(1)
  }

  const topic = Buffer.from(topicHex, 'hex')
  fs.mkdirSync(destDir, { recursive: true })

  console.log(`[client] Dest: ${destDir}`)
  console.log(`[client] Connecting to topic: ${topicHex.slice(0, 16)}...`)

  const storePath = path.join(os.tmpdir(), `deploy-cli-${Date.now()}`)
  const store = new Corestore(storePath)
  const fileIndex = new FileIndex(store)
  const transfer = new TransferManager(fileIndex)
  await fileIndex.ready()

  const swarm = new Hyperswarm()
  const startTime = Date.now()

  let connectTimeout = null

  swarm.on('connection', (conn) => {
    if (connectTimeout) { clearTimeout(connectTimeout); connectTimeout = null }
    const connectTime = Date.now() - startTime
    const peerKey = conn.remotePublicKey.toString('hex').slice(0, 12)
    console.log(`[client] Connected to server: ${peerKey} (${connectTime}ms)`)

    store.replicate(conn)
    const mux = Protomux.from(conn)

    const ch = mux.createChannel({ protocol: PROTOCOL, id: Buffer.from('release') })
    if (!ch) { console.error('[client] channel null'); return }

    const fileListMsg = ch.addMessage({ encoding: c.raw })
    const fileReqMsg = ch.addMessage({ encoding: c.raw })
    const chunkInfoMsg = ch.addMessage({ encoding: c.raw })
    const chunkDataMsg = ch.addMessage({ encoding: c.raw })
    const controlMsg = ch.addMessage({ encoding: c.raw })

    let fileList = []
    let currentFile = null
    let currentChunks = null
    let filesCompleted = 0
    let bytesReceived = 0
    let chunksReceived = 0
    let fileIdx = 0

    function requestNextFile () {
      if (fileIdx >= fileList.length) {
        // All done
        const elapsed = Date.now() - startTime
        console.log()
        console.log(`[client] === COMPLETE ===`)
        console.log(`[client] Files: ${filesCompleted}/${fileList.length}`)
        console.log(`[client] Bytes: ${formatBytes(bytesReceived)}`)
        console.log(`[client] Chunks: ${chunksReceived}`)
        console.log(`[client] Time: ${(elapsed / 1000).toFixed(1)}s`)
        console.log(`[client] Speed: ${(bytesReceived / elapsed * 1000 / 1024 / 1024).toFixed(1)} MB/s`)

        controlMsg.send(Buffer.from(JSON.stringify({ type: 'done' })))

        setTimeout(() => {
          swarm.destroy().then(() => store.close()).then(() => process.exit(0))
        }, 2000)
        return
      }

      const file = fileList[fileIdx]
      fileReqMsg.send(Buffer.from(JSON.stringify({ path: file.path })))
      fileIdx++
    }

    // Receive file list
    fileListMsg.onmessage = (data) => {
      fileList = JSON.parse(data.toString())
      console.log(`[client] Received file list: ${fileList.length} files`)
      requestNextFile()
    }

    // Receive chunk info for a file
    chunkInfoMsg.onmessage = (data) => {
      const info = JSON.parse(data.toString())
      currentFile = info.path
      currentChunks = info.chunks

      const targetPath = path.join(destDir, currentFile)
      transfer.startReceive(targetPath, { size: info.size, chunks: currentChunks })
    }

    // Receive chunk data
    chunkDataMsg.onmessage = (data) => {
      const index = data.readUInt32LE(0)
      const chunkData = data.subarray(4)

      const targetPath = path.join(destDir, currentFile)
      const result = transfer.writeChunk(targetPath, index, chunkData)

      if (!result.ok) {
        console.error(`[client] writeChunk error: ${result.error}`)
        return
      }

      chunksReceived++
      bytesReceived += chunkData.length

      if (transfer.isComplete(targetPath)) {
        const fin = transfer.finalize(targetPath)
        if (!fin.ok) {
          console.error(`[client] finalize error: ${fin.error}`)
          return
        }
        filesCompleted++

        if (filesCompleted % 500 === 0 || filesCompleted === fileList.length) {
          const elapsed = Date.now() - startTime
          const pct = (filesCompleted / fileList.length * 100).toFixed(0)
          console.log(`[client] ${pct}% — ${filesCompleted} files, ${formatBytes(bytesReceived)}, ${(elapsed / 1000).toFixed(0)}s`)
        }

        requestNextFile()
      }
    }

    ch.open()
  })

  swarm.join(topic, { server: false, client: true })
  await swarm.flush()
  console.log('[client] Joined topic, searching for server...')

  // No timeout — let the transfer run as long as it needs.
  // Connection drops are detected by stream error/close events.
}

// ============================================================

if (mode === 'server') runServer().catch(err => { console.error(err); process.exit(1) })
else runClient().catch(err => { console.error(err); process.exit(1) })