#!/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) })