/**
* NetworkFetcher wire-level tests
*
* Uses protomux over a duplex pipe (no Hyperswarm testnet) to exercise
* the tree-request / content-request flow end to end. Pattern borrowed
* from hyper-overlay/test/tree-peer.test.js.
*
* Verifies:
* - getTree returns publisher's tree entries
* - getContent returns the exact bytes the publisher has on disk
* - getContent for an unknown hash times out cleanly
*/
import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import Protomux from 'protomux'
import { Duplex } from 'streamx'
import crypto from 'hypercore-crypto'
import Corestore from 'corestore'
import { FileIndex } from 'hyper-overlay/file-index'
import { TransferManager } from 'hyper-overlay/transfer'
// hyper-overlay 0.3.0 renamed lib/protocol-v2.js -> lib/protocol.js and OverlayProtocolV2 ->
// OverlayProtocol (v1 was dropped; the current class IS the v2 wire protocol). The old subpath is not
// in the package's exports map, so this import threw at MODULE LOAD and — because npm test globs
// test/*.test.js — took the whole pgh suite with it. Aliased so the rest of the file is unchanged.
import { OverlayProtocol as OverlayProtocolV2 } from 'hyper-overlay/protocol'
import { computeTreeHash, KIND_FILE } from 'hyper-overlay/tree'
function mktemp (label) {
return fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-wire-' + label + '-'))
}
function makeDuplex () {
let aWrite, bWrite
const a = new Duplex({ write (d, cb) { bWrite(d); cb() }, read () {} })
const b = new Duplex({ write (d, cb) { aWrite(d); cb() }, read () {} })
aWrite = (d) => a.push(d)
bWrite = (d) => b.push(d)
return [a, b]
}
function stubSyncEngine (fileIndex) {
return {
_fileIndex: fileIndex,
feedKey: '0'.repeat(64),
feed: { length: 0, async get () { return null } },
async markSynced () {},
async logPut () {}
}
}
function hashBytes (b) { return crypto.data(b).toString('hex') }
// Set up a "server" holding files + trees in its fileIndex.
async function makeServer (label) {
const dir = mktemp(label + '-storage')
const store = new Corestore(dir)
const fileIndex = new FileIndex(store)
await fileIndex.ready()
const transferManager = new TransferManager(fileIndex)
const filesDir = mktemp(label + '-files')
const protocol = new OverlayProtocolV2(
stubSyncEngine(fileIndex), transferManager,
{ key: Buffer.alloc(32, 0xaa), autoSync: false }
)
return { store, fileIndex, transferManager, filesDir, protocol }
}
async function makeClient (label, onSynced) {
const dir = mktemp(label + '-client-storage')
const store = new Corestore(dir)
const fileIndex = new FileIndex(store)
await fileIndex.ready()
const transferManager = new TransferManager(fileIndex)
const destDir = mktemp(label + '-client-dest')
const protocol = new OverlayProtocolV2(
stubSyncEngine(fileIndex), transferManager,
{ key: Buffer.alloc(32, 0xaa), autoSync: false, onSynced }
)
return { store, fileIndex, transferManager, destDir, protocol }
}
async function connect (server, client) {
const [sA, sB] = makeDuplex()
const muxA = Protomux.from(sA)
const muxB = Protomux.from(sB)
const serverPeer = server.protocol.attach(muxA)
const clientPeer = client.protocol.attach(muxB)
await new Promise(r => setTimeout(r, 50))
return { serverPeer, clientPeer }
}
// Client uses OverlayProtocolV2's onSynced callback — fires when chunk
// flow finalizes a file on the receiver side. That's the point where
// our content is on disk and we can resolve the promise.
function onSyncedHandler (pending) {
return (info) => {
if (info.direction !== 'pull') return
if (!info.path || !info.path.startsWith('content:')) return
const contentHash = info.path.slice('content:'.length)
const entry = pending.get(contentHash)
if (!entry) return
try {
const data = fs.readFileSync(entry.destPath)
entry.resolve(data)
} catch (err) {
entry.reject(err)
}
}
}
// ── Tests ─────────────────────────────────────────────────────
test('getTree over protomux pipe returns stored entries', async (t) => {
const server = await makeServer('tree')
const client = await makeClient('tree')
const entries = [
{ kind: KIND_FILE, exec: 0, name: 'index.js', childHash: 'a'.repeat(64), size: 100 }
]
const hash = computeTreeHash(entries)
await server.fileIndex.putTree(hash, { entries, size: 100 })
const { clientPeer } = await connect(server, client)
const got = await client.protocol.requestTree(clientPeer, hash, { timeout: 2000 })
t.is(got.length, 1)
t.is(got[0].name, 'index.js')
server.protocol.destroy()
client.protocol.destroy()
await server.fileIndex.close()
await client.fileIndex.close()
})
test('getContent round-trip — client receives the publisher bytes', async (t) => {
const server = await makeServer('ctr')
const pending = new Map()
const client = await makeClient('ctr', onSyncedHandler(pending))
// Publisher puts a file on disk + registers its content hash
const content = Buffer.from('hello world — p2p content-addressed content!')
const contentHash = hashBytes(content)
const srvFile = path.join(server.filesDir, 'greeting.txt')
fs.writeFileSync(srvFile, content)
await server.fileIndex.putFile('/greeting.txt', {
contentHash,
size: content.length,
mtime: Date.now()
})
server.protocol._filePaths.set('/greeting.txt', srvFile)
server.protocol._contentHashPaths.set(contentHash, srvFile)
const { clientPeer } = await connect(server, client)
const destPath = path.join(client.destDir, 'blob-' + contentHash)
const syntheticPath = 'content:' + contentHash
client.protocol._filePaths.set(syntheticPath, destPath)
const got = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), 5000)
pending.set(contentHash, {
destPath,
resolve: (v) => { clearTimeout(timer); resolve(v) },
reject: (e) => { clearTimeout(timer); reject(e) }
})
client.protocol.requestContent(clientPeer, contentHash, null)
})
t.ok(Buffer.isBuffer(got), 'got is a Buffer')
t.is(got.toString('utf8'), content.toString('utf8'), 'bytes round-trip exactly')
server.protocol.destroy()
client.protocol.destroy()
await server.fileIndex.close()
await client.fileIndex.close()
})
test('getContent — server without the content silently drops (client times out)', async (t) => {
const server = await makeServer('miss')
const client = await makeClient('miss')
const { clientPeer } = await connect(server, client)
const unknownHash = 'f'.repeat(64)
client.protocol._filePaths.set('content:' + unknownHash, path.join(client.destDir, 'blob'))
let settled = false
const timer = setTimeout(() => {
settled = true
t.pass('no response received for unknown content hash (graceful miss)')
}, 500)
client.protocol.requestContent(clientPeer, unknownHash, null)
await new Promise(r => setTimeout(r, 600))
clearTimeout(timer)
t.ok(settled, 'timeout elapsed without a transferComplete')
server.protocol.destroy()
client.protocol.destroy()
await server.fileIndex.close()
await client.fileIndex.close()
})