OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge / index.js

109 lines · javascript ref:
#!/usr/bin/env bare
// favela-bridge — projects a node's own favela records onto the networks its rooms may reach.
//
//     favela-bridge            plan every record and report; sends nothing
//     favela-bridge --send     actually project
//
//   FAVELA_PEER_SOCKET   favela-peer's socket   (default ~/.config/favela-peer/favela.sock)
//   FAVELA_BSKY_SOCKET   hyper-proto-peer's control socket — REQUIRED for --send
//
// ⛔ DRY BY DEFAULT. Projection is publication: it puts words under an identity on a network that
// does not forget. The default run says what would happen and does nothing, because the cost of
// discovering the plan was wrong after publishing is not symmetric with reading it first.
//
// ⛔ ONE REACH DECISION, TWO PROJECTIONS. See lib/plan.js — each network derives from the markdown,
// never from the other's output.
//
// ⚠ v1 SENDS TO BSKY ONLY. The fediverse plan is computed, reported, and not delivered: ap-bridge
// holds the RSA key and signs at delivery, and wiring that is the next step, not this one. It is
// reported rather than omitted so the gap is visible (library.md §4).

import process from 'bare-process'
import os from 'bare-os'
import { join } from 'bare-path'
import { FavelaPeer } from './lib/peer.js'
import { BskySink } from './lib/bsky.js'
// ⛔ THE PASS ITSELF LIVES IN lib/, WITH NO TRANSPORT IN IT. It has to run in two places — here
// against unix sockets, and inside the handset worklet, where bsky records are authored and SIGNED
// next to the DID key because that key is on the phone. It used to live in this file next to argv
// parsing and process.exit, so the only way to run it on a phone was to write it a second time.
import { projectCycle } from './lib/project.js'

const argv = process.argv.slice(2)
const SEND = argv.includes('--send')
// ⛔ A CEILING ON ONE CYCLE, for the first send into a repo that has followers. A feed with a
// history projects ALL of it the first time, and "16 posts at once" is a different act from
// "post". Absent, this is Infinity and nothing changes.
const LIMIT = (() => {
  const i = argv.indexOf('--limit')
  if (i === -1) return Infinity
  const n = Number(argv[i + 1])
  if (!Number.isFinite(n) || n < 0) throw new Error('--limit needs a non-negative number')
  return n
})()
// ⛔ --watch implies --send. A watch that only planned would be a process that costs power and
// changes nothing, and the flag would read as if it were working.
const WATCH = argv.includes('--watch')
// ⛔ RECONCILIATION, the counterpart to the event path — the same verb ap-bridge has. A post is
// skipped once its rkey is in the repo, which is right for delivery and wrong the moment the
// PROJECTION improves: every post made before images worked keeps its text-only version for ever,
// and no event will ever revisit it. This re-derives each one and updates the ones that CHANGED.
// Not a --force: it compares against the repo and writes only a real difference.
const REPROJECT = argv.includes('--reproject')
const EVERY = Math.max(5, Number(process.env.FAVELA_BRIDGE_INTERVAL || 15)) * 1000
const PEER_SOCK = process.env.FAVELA_PEER_SOCKET || join(os.homedir(), '.config', 'favela-peer', 'favela.sock')
const BSKY_SOCK = process.env.FAVELA_BSKY_SOCKET ||
  join(os.homedir(), '.config', 'hyper-proto-peer', 'data', 'control.sock')

// ⚠ v1 assumes the node's own feed is a PUBLIC one-writer room. identity.md §7.6: "a personal feed
// is a one-writer room". That assumption is stated here rather than buried, because it is the input
// the reach lattice cannot infer — and if it is ever wrong, it is wrong in the direction of a leak.
const ROOM_TYPE = process.env.FAVELA_ROOM_TYPE || 'public'

async function main () {
  if (WATCH) {
    // ⛔ WATCH ALWAYS SENDS, and --send has no bearing on it. The loop below calls cycle(true)
    // unconditionally; SEND is read only by the one-shot path at the end of this function. Said out
    // loud because the two flags read as orthogonal and are not: `favela-bridge --watch` expecting a
    // dry run would publish, and the deployed pgh args are exactly that (["--watch"], no --send).
    // Deliberately NOT "fixed" by making watch honour SEND — both deployed daemons rely on this and
    // would silently stop publishing. Use --limit 0 for a watch that sends nothing.
    console.log('favela-bridge · watching every ' + (EVERY / 1000) + 's · SENDS on every cycle · ctrl-c to stop')
    for (;;) {
      // ⛔ One cycle NEVER throws the loop. A peer restarting, a sink respawning on OTA, a socket
      // that vanished — all ordinary. A bridge that exited on the first of them would stop
      // publishing silently, which is the failure this whole component is meant to make visible.
      try { await cycle(true) } catch (err) { console.error('  cycle failed: ' + ((err && err.message) || err)) }
      await new Promise((r) => setTimeout(r, EVERY))
    }
  }
  await cycle(SEND)
}


// ⛔ THIS FILE OWNS THE PROCESS, lib/project.js owns the projection. The split is exactly the exit
// code: a refusal to send must end a CLI run non-zero and must NOT end a worklet, where exiting kills
// the app the person is holding.
async function cycle (send) {
  const peer = new FavelaPeer(PEER_SOCK)
  const bsky = new BskySink(BSKY_SOCK)
  const r = await projectCycle({
    peer,
    bsky,
    roomType: ROOM_TYPE,
    roomTypeNote: process.env.FAVELA_ROOM_TYPE ? '' : ' (assumed)',
    sinkLabel: BSKY_SOCK,
    send,
    limit: LIMIT,
    reproject: REPROJECT,
    log: (line) => console.log(line)
  })
  if (r.refused && send) {
    console.error('  refusing to send: ' + r.refused)
    process.exit(1)
  }
  return r
}

main().catch((err) => { console.error('favela-bridge: ' + ((err && err.message) || err)); process.exit(1) })