// 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 }