OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / integration / dht-relay-ws.test.js

137 lines · javascript ref:
/**
 * End-to-end test: a "browser-like" client tunnels HyperDHT operations
 * through DHTRelayWS over a real WebSocket against a real HyperDHT node.
 *
 * This is the test that proves the original reviewer feedback ("no
 * DHT-relay WebSocket out of the box") is actually closed — not just that
 * the WS server starts, but that DHT control traffic round-trips correctly.
 */

import test from 'brittle'
import createTestnet from '@hyperswarm/testnet'
import { WebSocket } from 'ws'
// The npm @hyperswarm/dht-relay dep was retired — the vendored 0.4.3 copy is
// the single source for both server (DHTRelayWS) and this browser-side client.
import RelayedDHT from '../../packages/core/transports/dht-relay-ws/vendor/dht-relay/index.js'
import RelayedStream from '../../packages/core/transports/dht-relay-ws/vendor/dht-relay/ws.js'
import { DHTRelayWS } from 'p2p-hiverelay/transports/dht-relay-ws/index.js'

function pickPort () {
  return 50000 + Math.floor(Math.random() * 10000)
}

test('e2e: relayed client completes handshake and ready() through the WS', async (t) => {
  // Critical "the protocol actually works" test. Proves that:
  //   1. The WS server accepts a real client socket
  //   2. The dht-relay handshake completes (Node ↔ NodeProxy)
  //   3. The relayed DHT exposes its keypair (proxied from the real DHT)
  // This is the minimum that closes the original "no DHT-relay-WS" feedback —
  // we have an actual end-to-end DHT instance reachable through a browser WS.
  const testnet = await createTestnet(2, t.teardown)
  const relayDHT = testnet.nodes[0]

  const port = pickPort()
  const relayWs = new DHTRelayWS({ dht: relayDHT, port, host: '127.0.0.1' })
  await relayWs.start()
  t.teardown(() => relayWs.stop())

  const socket = new WebSocket(`ws://127.0.0.1:${port}`)
  await new Promise((resolve, reject) => {
    socket.on('open', resolve)
    socket.on('error', reject)
  })

  const browserDHT = new RelayedDHT(new RelayedStream(true, socket))
  await browserDHT.ready()

  t.ok(browserDHT.defaultKeyPair, 'relayed DHT has a keypair after ready')
  t.ok(browserDHT.defaultKeyPair.publicKey, 'keypair has a public key')
  t.is(browserDHT.defaultKeyPair.publicKey.length, 32, 'public key is 32 bytes')
  t.is(relayWs.getStats().activeConnections, 1, 'server tracks the active session')

  await browserDHT.destroy()
  socket.close()
  // Give the server-side close handler a moment.
  await new Promise(resolve => setTimeout(resolve, 100))
})

test('e2e: WS server cleanly handles client disconnect mid-session', async (t) => {
  const testnet = await createTestnet(2, t.teardown)
  const relayDHT = testnet.nodes[0]
  const port = pickPort()
  const relayWs = new DHTRelayWS({ dht: relayDHT, port, host: '127.0.0.1' })
  await relayWs.start()
  t.teardown(() => relayWs.stop())

  // Connect, ready up, then yank the socket without graceful close.
  const socket = new WebSocket(`ws://127.0.0.1:${port}`)
  await new Promise((resolve) => socket.on('open', resolve))

  const browserDHT = new RelayedDHT(new RelayedStream(true, socket))
  await browserDHT.ready()
  t.is(relayWs.getStats().activeConnections, 1, 'relay sees one active session')

  // Hard close — the relay should drop the session, not leak it.
  socket.terminate()
  await new Promise((resolve) => setTimeout(resolve, 100))

  t.is(relayWs.getStats().activeConnections, 0, 'relay cleaned up the dropped connection')
})

test('crash-safety: a proxied DHT op that throws tears down only its connection, never the process', async (t) => {
  const testnet = await createTestnet(2, t.teardown)
  const relayDHT = testnet.nodes[0]

  // Wrap the real DHT so lookup() throws synchronously — simulating a
  // malformed/hostile frame or an op against a closed DHT. Upstream let such a
  // throw propagate out of the protocol EventEmitter → uncaughtException →
  // whole-relay crash-loop under systemd. Our vendored node-proxy `guard` must
  // contain it to the one faulting connection. Everything else proxies to the
  // real DHT (methods bound to it) so the handshake still completes.
  const throwingDht = new Proxy(relayDHT, {
    get (target, prop) {
      if (prop === 'lookup') return () => { throw new Error('boom: simulated DHT fault') }
      const v = target[prop]
      return typeof v === 'function' ? v.bind(target) : v
    }
  })

  const port = pickPort()
  const relayWs = new DHTRelayWS({ dht: throwingDht, port, host: '127.0.0.1' })
  await relayWs.start()
  t.teardown(() => relayWs.stop())

  // A clean run already implies the process survived (brittle aborts on an
  // uncaught fault), but assert it explicitly by trapping any that escape.
  const fatal = []
  const onFatal = (err) => fatal.push(err)
  process.on('uncaughtException', onFatal)
  process.on('unhandledRejection', onFatal)
  t.teardown(() => {
    process.off('uncaughtException', onFatal)
    process.off('unhandledRejection', onFatal)
  })

  const socket = new WebSocket(`ws://127.0.0.1:${port}`)
  await new Promise((resolve, reject) => { socket.on('open', resolve); socket.on('error', reject) })
  const browserDHT = new RelayedDHT(new RelayedStream(true, socket))
  await browserDHT.ready()
  t.is(relayWs.getStats().activeConnections, 1, 'session established (handshake unaffected)')

  // Trigger the throwing op. The relayed lookup stream errors/closes as the
  // relay tears its connection down; we only care that the process survives.
  try {
    const stream = browserDHT.lookup(Buffer.alloc(32))
    stream.on('error', () => {})
    if (typeof stream.resume === 'function') stream.resume()
  } catch (_) {}

  await new Promise((resolve) => setTimeout(resolve, 300))

  t.is(fatal.length, 0, 'no uncaughtException/unhandledRejection escaped the guard')
  t.is(relayWs.getStats().activeConnections, 0, 'only the faulting connection was torn down')

  try { await browserDHT.destroy() } catch (_) {}
  try { socket.close() } catch (_) {}
})