OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / feed-holder.js

221 lines · javascript ref:
// feed-holder.js — hold registered peers' feeds over the swarm, so the bridge can project them.
//
// ⛔ WHY THIS EXISTS. A bridge is a service peers CONNECT TO. `FavelaSource` read its peer over a
// unix socket, which meant the bridge had to run on the same box as the peer it projects — so
// `PerUser`, whose whole purpose is one bridge fronting many people, could only ever have fronted
// people who shared the bridge's machine. That is a hostname per person, which is the thing #65 §7
// set out to remove.
//
// ★ AND IT IS THE SAME THING A FOLLOWER DOES. A favela feed is a hypercore on a discovery topic.
// Holding one read-only by key is exactly how tabertron holds ashatron's — no new protocol, no new
// trust, and nothing this bridge could not already do.
//
// ⛔ READ-ONLY, AND THE KEY IS THE ONLY INPUT. The bridge is handed a feed key at registration and
// holds it. It has no credential for that feed and cannot write to it; the keyless claim rests on
// exactly that. A feed key is public — it is derived from the feed's public key, which is what makes
// "hold somebody's feed" a thing a stranger may do at all.
//
// ⚠ ONE corestore, ONE swarm, one namespace per feed — favela-peer's own node.js learned this the
// expensive way: two Autobees on one Corestore without separate namespaces derive the SAME local
// writer and DEADLOCK rather than erroring.

import Corestore from 'corestore'
import Hyperswarm from 'hyperswarm'
import Protomux from 'protomux'
import b4a from 'b4a'
import { join } from 'bare-path'
import { Feed } from 'favela-peer/lib/feed-base.js'
import { RecordStore } from 'favela-peer/lib/record-store.js'
import { HyperOverlay } from 'hyper-overlay'
import { Files } from 'favela-peer/lib/files.js'
import { FavelaFeed } from './favela-feed.js'

const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))

export class FeedHolder {
  constructor ({ storage, swarm = null, bootstrap = null, log = () => {} }) {
    if (!storage) throw new Error('FeedHolder: a storage directory is required')
    this.storage = storage
    this.corestore = new Corestore(join(storage, 'store'))
    this.swarm = swarm
    this._ownSwarm = !swarm
    // Tests pass [] to keep the swarm off the public DHT; production passes nothing.
    this._bootstrap = bootstrap
    this.overlay = null
    this.files = null
    this._held = new Map() // feed key hex -> FavelaFeed
    this._log = log
    // ⛔ Keyed on the CORE OBJECT, never on core.id or core.key. A core that has not yet exposed an
    // identifier would be silently skipped, and "silently skipped" is exactly how favela-peer's
    // mirror trigger failed three times before it worked.
    this._hooked = new Map()
    this._watchers = new Map() // feed key hex -> { cb, timer, fired }
    this.debounceMs = 1000
  }

  /**
   * Tell me when a held feed ADVANCES.
   *
   * ⛔ WHY THIS IS NEEDED AT ALL. A registered user's posts reached the outbox — a pull surface —
   * and were never delivered to anybody, because the per-user Publisher had no trigger. The primary
   * gets one from DidPeer's `onHead`; a held favela feed had nothing equivalent, so `onHead()` was
   * never called for a registered user and not one Create ever went out.
   *
   * ⛔ AND THE TRIGGER IS THE WRITER CORES, RE-HOOKED. Measured on a real pipe: a freshly opened
   * remote feed reports ONE writer at hold() and TWO once replication settles — the second arrives
   * with the system core. So a one-shot hook at hold() silently misses a writer, and it is the
   * later one that carries what the peer writes next. favela-peer paid for this shape three times
   * (0.0.39 boot-only, 0.0.40 an `update` event that only fires inside `_onError`, 0.0.41 a
   * snapshot of the writer set). A hypercore emits `append` when blocks land INCLUDING replicated
   * ones, which is the earliest honest signal that somebody else wrote something.
   *
   * ⚠ Debounced, because a single post is several appends and each would otherwise be its own
   * delivery pass. This is a DEBOUNCE, not a poll: it never fires without an event first.
   */
  watch (feedKey, cb) {
    const id = hex(feedKey)
    if (typeof cb !== 'function') throw new Error('FeedHolder.watch: a callback is required')
    this._watchers.set(id, { cb, timer: null, fired: { append: 0, connection: 0, boot: 0 } })
    this._rehook(id)
    this._fire(id, 'boot')
    return () => {
      const w = this._watchers.get(id)
      if (w && w.timer) clearTimeout(w.timer)
      this._watchers.delete(id)
    }
  }

  /** What each trigger has actually fired — so a dead hook is OBSERVABLE rather than inferred. */
  watchStats () {
    const out = {}
    for (const [id, w] of this._watchers) out[id.slice(0, 12)] = { ...w.fired, hooked: this._hooked.size }
    return out
  }

  _rehook (id) {
    const held = this._held.get(id)
    if (!held) return
    let writers
    try { writers = held.feed.base.writers } catch { return }
    if (!writers) return
    try {
      for (const w of writers) {
        const core = w && w.core
        if (!core || typeof core.on !== 'function') continue
        if (this._hooked.has(core)) continue
        const fire = () => this._fire(id, 'append')
        core.on('append', fire)
        this._hooked.set(core, fire)
      }
    } catch { /* a writer set mid-change is not a failure */ }
  }

  _fire (id, why) {
    const w = this._watchers.get(id)
    if (!w) return
    w.fired[why] = (w.fired[why] || 0) + 1
    // A new writer may have arrived carrying this very append — hook it before the pass, not after.
    this._rehook(id)
    if (w.timer) return
    w.timer = setTimeout(() => {
      w.timer = null
      this._rehook(id)
      Promise.resolve(w.cb(why)).catch((err) => this._log({ msg: 'feed watcher failed', feed: id.slice(0, 12), error: err.message }))
    }, this.debounceMs)
    if (typeof w.timer.unref === 'function') w.timer.unref()
  }

  async ready () {
    this.overlay = new HyperOverlay(this.corestore.namespace('overlay'), {
      namespace: 'favela-records',
      destDir: join(this.storage, '.fetch')
    })
    await this.overlay.ready()
    this.files = new Files(this.storage, this.overlay)

    if (!this.swarm) {
      // ⛔ A PERSISTENT keypair. `new Hyperswarm()` alone mints a random one per boot, and this
      // swarm is how peers reach this bridge to ask for a name — an address that changes on every
      // restart is not an address. See lib/swarm-keypair.js.
      const { loadOrCreateSwarmKeyPair } = await import('./swarm-keypair.js')
      const keyPair = loadOrCreateSwarmKeyPair(join(this.storage, 'swarm-key'))
      this.swarm = new Hyperswarm(this._bootstrap ? { keyPair, bootstrap: this._bootstrap } : { keyPair })
    }
    // ⛔ Every connection replicates every held feed, and the overlay attaches to all of them. One
    // connection carries all of it — hypercore multiplexes, and piping each feed separately onto one
    // socket would give each its own stream over a shared transport.
    this.swarm.on('connection', (socket) => {
      for (const [, held] of this._held) {
        try { held.feed.base.replicate(socket) } catch {}
      }
      try { this.overlay.attachProtocol(Protomux.from(socket)) } catch {}
      // ⛔ A PEER RETURNING IS AN EVENT. Whatever they wrote while they were away lands now, and the
      // writer cores that carry it may not have existed when the feed was first held. This is what
      // makes a ceiling timer unnecessary: absence is expressed by a connection, not by a clock.
      for (const [id] of this._watchers) this._fire(id, 'connection')
    })
    return this
  }

  /**
   * Hold a feed by key, read-only, and return the adapter FavelaSource wants.
   *
   * ⚠ Idempotent: a second registration of the same key returns the feed already held rather than
   * opening a second Autobee on it, which would derive a second local writer on the same log.
   */
  async hold (feedKey) {
    const id = hex(feedKey)
    if (this._held.has(id)) return this._held.get(id)

    const feed = new Feed(this.corestore.namespace('feed:' + id), b4a.from(id, 'hex'), {
      store: new RecordStore(join(this.storage, 'records', id), { overlay: this.overlay }),
      // ⛔ THE SAME RULES AS THE OWNER, or a different view of the same log. Every favela node opens
      // feeds with the doorbell ON (favela-peer node.js), and the owner's apply acks a stranger's
      // knock, so everything the owner writes afterwards links past it. Feed's default is OFF, and a
      // holder that refuses optimistic blocks can never apply the knock — measured 2026-10-12 on
      // fleeky's feed: stuck at 106 of 113, the newest posts never reaching the fediverse (on autobee
      // 2.1.1 it also pinned a core). This grants the bridge nothing: it never appends, and apply
      // still decides every optimistic block exactly as the owner's does.
      optimistic: true
    })
    await feed.ready()

    // Join the feed's topic and replicate to whoever is already connected — a feed held after boot
    // would otherwise wait for a new connection that may never come.
    const dk = feed.base.discoveryKey
    this.swarm.join(dk, { server: true, client: true })
    for (const socket of this.swarm.connections) { try { feed.base.replicate(socket) } catch {} }

    const held = new FavelaFeed({ feed, files: this.files })
    this._held.set(id, held)
    this._log({ msg: 'holding feed', feed: id.slice(0, 12) })
    return held
  }

  /** Stop holding a feed — a released name, or an operator reclaim. */
  async release (feedKey) {
    const id = hex(feedKey)
    const held = this._held.get(id)
    if (!held) return false
    this._held.delete(id)
    try { await this.swarm.leave(held.feed.base.discoveryKey) } catch {}
    try { await held.feed.close() } catch {}
    return true
  }

  get (feedKey) { return this._held.get(hex(feedKey)) || null }

  async close () {
    for (const [, w] of this._watchers) { if (w.timer) clearTimeout(w.timer) }
    this._watchers.clear()
    this._hooked.clear()
    for (const [, held] of this._held) { try { await held.feed.close() } catch {} }
    this._held.clear()
    if (this._ownSwarm && this.swarm) { try { await this.swarm.destroy() } catch {} }
    if (this.overlay) { try { await this.overlay.close() } catch {} }
    try { await this.corestore.close() } catch {}
  }
}

export default { FeedHolder }