OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / transport.js

92 lines · javascript ref:
// Transport — how a feed meets its peers.
//
// Injected, exactly as the KV was. Nothing above this file knows what a socket is: `Feed.replicate`
// takes a boolean or a duplex, and a transport's whole job is to produce one and hand it over. That
// is what makes the alternatives — mDNS on a LAN (`hyperdht-mdns`), BLE (`ble-swarm`,
// `nearby-discovery`) — additions rather than rewrites, which matters because bootstrap is the
// weakest part of this design and the DHT is not the only answer to it.

import Protomux from 'protomux'

/**
 * SwarmTransport — Hyperswarm over the DHT.
 *
 * ⚠ NOT COVERED BY THE TEST SUITE. It needs a live DHT, and a test that needs one is a test that
 * fails for reasons unrelated to the code. What IS covered is everything it feeds: replication is
 * proven over an in-memory duplex pair in replicate.test.js, which is the same seam this hands a
 * socket to. This file is glue, and it is marked as such rather than counted as tested.
 */
export class SwarmTransport {
  // `swarm` is a Hyperswarm instance, injected so this file does not own its lifecycle either.
  constructor (swarm, opts = {}) {
    this.swarm = swarm
    // Off by default; the daemon turns it on with FAVELA_DEBUG. A transport that fails silently is
    // exactly what shipped here — a follow that records and replicates nothing, with no signal.
    this._log = opts.log || (() => {})
    // ⛔ The overlay must attach to EVERY connection or the log replicates and the bytes never do.
    // That failure is silent and looks like "nothing arrived": the follower converges the pointer
    // set, reports every record as UNRESOLVED, and shows an empty timeline. Measured 2026-09-06 —
    // two daemons, swarm connected, feeds replicating, zero bytes, because this line did not exist.
    this.overlay = opts.overlay || null
    this.feeds = new Map() // discovery key hex -> Feed
    this._onconnection = (socket) => {
      // Pass the SOCKET, never a boolean, and never pipe by hand.
      //
      // Two bugs lived here and neither showed up in a test, because the suite replicates over a
      // dedicated stream pair where a boolean is right:
      //   1. `replicate(false)` hardcoded BOTH ends as responder, so no handshake ever completed.
      //   2. Piping each feed separately onto one socket gives each its own stream over a shared
      //      transport. Hypercore multiplexes — every base attaches to the socket's existing muxer,
      //      which is what lets one connection carry all of them.
      // Symptom of either: a follow that records fine and replicates nothing, silently, forever.
      let n = 0
      for (const [, feed] of this.feeds) {
        try { feed.replicate(socket); n++ } catch (err) { this._log('replicate-failed', err.message) }
      }
      let overlay = false
      if (this.overlay) {
        try { this.overlay.attachProtocol(Protomux.from(socket)); overlay = true } catch (err) { this._log('overlay-attach-failed', { err: err.message }) }
      }
      this._log('connection', { feeds: n, overlay })
    }
    this.swarm.on('connection', this._onconnection)
  }

  // Announce and look for peers of this feed. The topic is the feed's discovery key — derived from
  // its public key, so it reveals which feed you want without revealing that you can write to it.
  async join (feed) {
    const dk = feed.discoveryKey
    const id = Buffer.from(dk).toString('hex')
    if (this.feeds.has(id)) return
    this.feeds.set(id, feed)
    const discovery = this.swarm.join(dk, { server: true, client: true })
    // Attach to connections we ALREADY have — a feed held after boot (a fresh follow) would
    // otherwise wait for a new connection that may never come.
    let existing = 0
    for (const socket of this.swarm.connections) { try { feed.replicate(socket); existing++ } catch {} }
    // ⛔ NOT awaited. `flushed()` waits for the DHT to acknowledge the announce, and its result is
    // never used — replication to peers we already have is set up on the line above, and new peers
    // arrive on the 'connection' handler whenever the announce lands. Awaiting it put a DHT round
    // trip inside every feed open: a socket request that hung, and a boot that paid it once per
    // followed feed, in series.
    discovery.flushed().then(
      () => this._log('join', { topic: id.slice(0, 12), existingConns: existing }),
      (err) => this._log('join-flush-failed', { topic: id.slice(0, 12), err: err.message })
    )
  }

  async leave (feed) {
    const id = Buffer.from(feed.discoveryKey).toString('hex')
    if (!this.feeds.delete(id)) return
    await this.swarm.leave(feed.discoveryKey)
    // ⚠ Leaving stops ANNOUNCING. It does not un-replicate what a peer already has, and it does not
    // close live connections. Same shape as delisting: propagation stops, nothing is retracted.
  }

  async destroy () {
    this.swarm.off('connection', this._onconnection)
    this.feeds.clear()
    await this.swarm.destroy()
  }
}