OverviewFilesCommitsIssuesDocsReleasesPeers

favela-host commits / f395b99

0.1.16: the bsky projection watch starts in bootAtproto — the phone never had one
watchProjection was started by bin/favela-host.js alone. The Android worklet boots through bootAtproto too and never started it, so a post from the phone reached bsky only when someone pressed publish in Settings. bootAtproto now starts the watch once the identity is up (node.projection); the desktop entry's own copy is removed, since two watches would race to post the same record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f395b992a75fe5d47043c224aee53563082b9bda
by plankatron <fleeky@gmail.com> · 2026-10-15T00:34:31.000Z

Changed files (4) +40 -11

bin/favela-host.js +3 -8
……@@ -28,7 +28,6 @@ import Hyperswarm from 'hyperswarm'
2828import { createServer } from 'favela-peer/lib/rpc.js'
2929import { startControl } from 'favela-peer/lib/control.js'
3030import { boot, inProcessClient } from '../lib/host.js'
31import { watchProjection } from '../lib/project.js'
3231
3332const storage = process.env.FAVELA_STORAGE || join(os.homedir(), '.favela')
3433const sock = process.env.FAVELA_SOCK || join(storage, 'favela.sock')
……@@ -89,12 +88,8 @@ async function main () {
8988 createServer(node, sock, { call: (cmd, args) => client.call(cmd, args), onError: () => {} })
9089 say('favela-host up', { feed: node.key.toString('hex'), storage, sock, url: surface.url, atproto: peerConfig || 'node storage', version: pkgVersion() })
9190
92 // favela-bridge's --watch, inside the app: event-driven on the node's own feed. Waits for the atproto
93 // half; a node with no identity projects nothing.
94 let projection = null
95 watchProjection(node, atproto, { log: (line) => say('bsky', { line }) })
96 .then((w) => { projection = w; say(w.watching ? 'bsky projection watching' : 'no atproto identity — nothing to project') })
97 .catch((e) => say('bsky projection did not start', { error: (e && e.message) || String(e) }))
91 // favela-bridge's --watch now starts inside bootAtproto (lib/atproto.js), the boot this shares with the
92 // Android worklet, which never had it. One watch per node: starting a second here would race the first.
9893
9994 // Idempotent and BOUNDED: a close that never settles must not keep an OTA from respawning (the
10095 // supervisor respawns only once we exit). The first caller's exit code wins: 75 for release-ready,
……@@ -105,7 +100,7 @@ async function main () {
105100 if (stopping) return
106101 stopping = true
107102 const deadline = setTimeout(() => { say('shutdown: deadline reached, exiting anyway', { code }); process.exit(code) }, 15000)
108 try { if (projection) projection.stop() } catch {}
103 try { if (node.projection) node.projection.stop() } catch {}
109104 try { if (control) await control.close() } catch {}
110105 try { surface.server.close() } catch {}
111106 try { const hpp = await Promise.race([atproto, new Promise((resolve) => setTimeout(() => resolve(null), 2000))]); if (hpp) await hpp.close() } catch {}
lib/atproto.js +11 -1
……@@ -416,7 +416,7 @@ export default { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath }
416416 * ⚠ Returns null rather than throwing when there is no identity. A node with no DID is the normal
417417 * state, not a failure, and a read-only node that boots beats a node that does not.
418418 */
419export async function bootAtproto (node, { log = () => {}, configFile = null, storage = null } = {}) {
419export async function bootAtproto (node, { log = () => {}, configFile = null, storage = null, project = null } = {}) {
420420 // `configFile` + `storage`: hpp's EXISTING config and data, when this machine already has them (the
421421 // desktop app on a server). Absent — the handset, a fresh desktop — both live under node storage.
422422 if (configFile) node.atprotoConfigFile = configFile
……@@ -464,5 +464,15 @@ export async function bootAtproto (node, { log = () => {}, configFile = null, st
464464 // Attaching it here rather than in each caller's boot means the desktop host and the Android
465465 // worklet both get it without either knowing it happened.
466466 node.atproto = hpp
467 // ⛔ AND THE PROJECTION WATCH, for the same reason. It was started by the desktop entry alone, so the
468 // Android worklet — which boots through here too — never projected a post on its own: a post reached
469 // bsky only when someone pressed publish in Settings (2026-10-14). Started here, neither shell can be
470 // without it, and there is exactly one per node (two would race to post the same record).
471 // `project` is injectable for tests; the watch itself logs every refusal and failure.
472 if (up > 0) {
473 const { watchProjection } = await import('./project.js')
474 node.projection = await watchProjection(node, hpp, { log: (line) => log('bsky', { line }), ...(project ? { project } : {}) })
475 log(node.projection.watching ? 'bsky projection watching' : 'bsky projection not watching')
476 }
467477 return hpp
468478}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-host",
3 "version": "0.1.15",
3 "version": "0.1.16",
44 "description": "THE favela app on a desktop, home node or server: a favela-peer node, favela-surface rendering it, the atproto signer and the bsky projection, in one process.",
55 "type": "module",
66 "main": "index.js",
test/boot-atproto.test.js +25 -1
……@@ -32,7 +32,10 @@ function fixture (t, name) {
3232 process.env.PEER_CONFIG = realCfg || ''
3333 process.env.ATPROTO_OVERLAY_ROOT = realOverlay || ''
3434 })
35 return { storage, node: { storage }, client: inProcessClient({ storage }) }
35 const node = { storage }
36 // A booted node watches for posts to project; a test must not leave that running past its hpp.
37 t.teardown(() => { try { if (node.projection) node.projection.stop() } catch {} })
38 return { storage, node, client: inProcessClient(node) }
3639}
3740
3841test('a node with no identity boots no atproto, and says so by returning null', async (t) => {
……@@ -99,3 +102,24 @@ test('given hpp\'s own config and storage, it boots THOSE and writes nothing und
99102 t.absent(fs.existsSync(join(node.storage, 'atproto', 'statebase')), 'no second store under node storage')
100103 t.is((await client.call('did-status')).did, r.did, 'did-status reads the same config')
101104})
105
106// ⛔ THE PHONE NEVER PROJECTED A POST ON ITS OWN. The desktop entry started watchProjection after boot;
107// the Android worklet boots through bootAtproto too and never did, so a post from the phone reached bsky
108// only when someone pressed publish in Settings (2026-10-14). The watch now starts HERE, the one boot
109// both shells share, so neither can be without it.
110test('booting the identity starts the bsky projection watch — one pass at boot', async (t) => {
111 const { node, client } = fixture(t, 'watch')
112 await client.call('did-mint', { passphrase: PASS })
113 const runs = []
114 const hpp = await bootAtproto(node, { project: async (n, h, opts) => { runs.push(opts.send) } })
115 t.teardown(async () => { try { await hpp.close() } catch {} })
116 t.ok(node.projection && node.projection.watching, 'the node is watching for posts to project')
117 await new Promise((resolve) => setTimeout(resolve, 50))
118 t.alike(runs, [true], 'and ran one SENDING pass at boot')
119})
120
121test('a node with no identity starts no watch', async (t) => {
122 const { node } = fixture(t, 'nowatch')
123 await bootAtproto(node)
124 t.absent(node.projection, 'nothing to project, nothing watching')
125})