OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge commits / dec83e9

the bsky sink works with a transport and no socket
Groundwork for running the projection ON THE HANDSET, which is where it has to run: bsky records are authored and signed next to the DID key, and that key is on the phone. The fediverse needs no equivalent — ap-bridge HOLDS the feed and projects server-side, which is why a phone with a correct DID, a resolving handle and 9 fediverse posts had an atproto repo containing one bootstrap record and nothing else. Two things stopped the sink being usable there, both the same mistake: asking about the SOCKET when the question is about the TRANSPORT. ⛔ `get configured ()` returned `!!this.sock`, so every method short-circuited to "no bsky sink configured" for a caller that had supplied a working `connect` and no path. An injected transport IS a configured sink. ⛔ `import { Client } from 'hyper-svc'` sat at module scope. It reaches bare-net and a unix socket, and SELinux refuses an unprivileged Android app a `sock_file` — but bare-pack bundles by IMPORT, not by use, so naming it there ships a transport the worklet can never call. Now loaded on the socket path only, the same way favela-peer's rpc.js defers ./bridge.js. Neither failure raises anything: the projection simply does nothing, quietly, which is exactly the state the handset was in. The test drives the full surface the projector uses — putProfile/getProfile, send, have, uploadBlob, getRecord — over a fake device with the same `call(cmd, args)` shape hpp's UDS surface exposes, and asserts that absent and unreadable stay distinguishable. It omits `close` deliberately: the sink closes its client after every call, which is right for a socket it opened and fatal for a device that must outlive the projection. 33/33 pass. The new assertions fail against the previous lib. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dec83e9be01278f714c42b0a331367961c47e015
by plankatron <fleeky@gmail.com> · 2026-09-11T22:38:18.000Z

Changed files (2) +108 -2

lib/bsky.js +12 -2
……@@ -9,9 +9,14 @@
99// says so, and favela-web was its first co-located client. So the socket path IS the capability:
1010// whoever can open it can write that DID. That is why it is configuration and never a default.
1111
12import { Client } from 'hyper-svc'
1312import { tidFor } from './tid.js'
1413
14// ⛔ hyper-svc IS NOT IMPORTED AT MODULE SCOPE, and that is what makes this sink usable on a handset.
15// It reaches bare-net and a unix socket; SELinux refuses an unprivileged Android app a `sock_file`, so
16// the phone can never use that path — but bare-pack bundles by IMPORT, not by use, so naming it up here
17// shipped the whole transport into a worklet that cannot call it. Loaded only on the socket path, which
18// is the only path that needs it. Same reason favela-peer's rpc.js defers `./bridge.js`.
19
1520export const PROFILE_COLLECTION = 'app.bsky.actor.profile'
1621export const PROFILE_RKEY = 'self'
1722export const POST_COLLECTION = 'app.bsky.feed.post'
……@@ -23,10 +28,15 @@ export class BskySink {
2328 this._log = opts.log || (() => {})
2429 }
2530
26 get configured () { return !!this.sock }
31 // ⛔ AN INJECTED CONNECT IS A CONFIGURED SINK. This asked only about `sock`, so every method
32 // short-circuited to "no bsky sink configured" for a caller that had supplied a working transport and
33 // no socket path — which is exactly the in-process case on a phone, where hpp is in the same worklet
34 // and there is no socket to name.
35 get configured () { return !!this.sock || !!this._connect }
2736
2837 async _open () {
2938 if (this._connect) return this._connect(this.sock)
39 const { Client } = await import('hyper-svc')
3040 return Client.discover({ uds: this.sock, prefer: 'uds' })
3141 }
3242
test/sink-injection.test.js +96 -0
……@@ -0,0 +1,96 @@
1// The sink must work with no socket at all — because on a phone there cannot be one.
2//
3// ⛔ WHY THIS MATTERS. The bsky projection runs next to the SIGNING KEY. On a desktop that is a
4// co-located hpp reached over a unix socket; on a handset hpp is in the same worklet and SELinux
5// refuses an unprivileged app a `sock_file` at all. So the phone supplies a transport and no path.
6//
7// Two things stopped that working, and both are the same mistake in different clothes — asking about
8// the SOCKET when the question is about the TRANSPORT:
9//
10// 1. `get configured ()` returned `!!this.sock`, so every method short-circuited to "no bsky sink
11// configured" for a caller that had already supplied a working connect.
12// 2. `import { Client } from 'hyper-svc'` sat at module scope. bare-pack bundles by IMPORT, not by
13// use, so naming it there ships bare-net and a unix-socket client into a worklet that can never
14// call them.
15//
16// The measured consequence of getting this wrong is not an error — it is a projection that silently
17// does nothing, which is the state a handset was in with a Bluesky identity that resolved correctly
18// and an atproto repo holding one bootstrap record.
19
20import test from 'brittle'
21import fs from 'bare-fs'
22import { BskySink, PROFILE_COLLECTION, PROFILE_RKEY } from '../lib/bsky.js'
23
24const ROOT = new URL('..', import.meta.url).pathname
25
26// A stand-in for the co-located device: the same `call(cmd, args)` shape hpp's UDS surface exposes,
27// which is also the shape favela-host's inProcessClient already hands the surface.
28function fakeDevice () {
29 const records = new Map()
30 const calls = []
31 return {
32 records,
33 calls,
34 client: {
35 call: async (cmd, args = {}) => {
36 calls.push(cmd)
37 if (cmd === 'get-state') {
38 return { records: [...records.keys()].map((k) => { const i = k.indexOf('/'); return { collection: k.slice(0, i), rkey: k.slice(i + 1) } }) }
39 }
40 if (cmd === 'apply-write') { records.set(args.collection + '/' + args.rkey, args.record); return { ok: true } }
41 if (cmd === 'get-record') return records.get(args.collection + '/' + args.rkey) || null
42 if (cmd === 'upload-blob') return { cid: 'bafyfake', mimeType: args.mimeType, size: 3 }
43 throw new Error('unexpected command ' + cmd)
44 }
45 // ⛔ NO `close`. The sink closes its client after every call — correct for a socket it opened,
46 // fatal for a device that must outlive the projection. bsky.js guards with `if (client.close)`,
47 // so omitting it is the adapter saying "this one is not mine to close".
48 }
49 }
50}
51
52test('★★★ a sink with an injected transport and NO socket is configured', (t) => {
53 const d = fakeDevice()
54 const sink = new BskySink(null, { connect: async () => d.client })
55 t.ok(sink.configured, 'a transport is what makes it usable, not a path')
56 t.absent(new BskySink(null).configured, 'and with neither it is still correctly unconfigured')
57})
58
59test('★★ the whole surface the projector uses works over an injected transport', async (t) => {
60 const d = fakeDevice()
61 const sink = new BskySink(null, { connect: async () => d.client })
62
63 const profile = { $type: PROFILE_COLLECTION, displayName: 'Fleeky' }
64 t.alike(await sink.putProfile(profile), { sent: true }, 'the profile is written')
65 t.alike(await sink.getProfile(), profile, 'and read back from the repo, not from a local note')
66
67 const post = { $type: 'app.bsky.feed.post', text: 'hello', createdAt: '2026-09-11T00:00:00.000Z' }
68 const r = await sink.send('a-favela-record', post)
69 t.ok(r.sent, 'a post is sent')
70 t.ok(/^[0-9a-z]{13}$/.test(r.rkey), 'under a real TID rkey — the AppView drops posts without one')
71
72 const have = await sink.have()
73 t.ok(have instanceof Set && have.has(r.rkey), 'and `have` reports it, so a re-run does not duplicate it')
74
75 const blob = await sink.uploadBlob(ROOT + 'package.json', 'image/png')
76 t.is(blob.$type, 'blob')
77 t.is(blob.ref.$link, 'bafyfake', 'blobs travel the same transport')
78})
79
80test('★★ absent and unreadable stay distinguishable over the injected transport', async (t) => {
81 const sink = new BskySink(null, { connect: async () => ({ call: async () => { throw new Error('device is busy') } }) })
82
83 const got = await sink.getProfile()
84 t.ok(got && got.unknown, 'a failed read is `unknown`, never null')
85 t.absent(await new BskySink(null, { connect: async () => ({ call: async () => null }) }).getProfile(),
86 'and a genuinely absent record is null — writing because a read failed republishes on every blip')
87})
88
89// ⛔ SOURCE-LEVEL, because the cost is paid by bare-pack at BUILD time and is invisible at runtime:
90// the socket client works perfectly well on a desktop while bloating a bundle that can never use it.
91test('★★ hyper-svc is not imported at module scope', (t) => {
92 const src = fs.readFileSync(ROOT + 'lib/bsky.js', 'utf8')
93 t.absent(/^import .*from 'hyper-svc'/m.test(src),
94 'a top-level import ships bare-net and a unix socket into the worklet')
95 t.ok(/await import\('hyper-svc'\)/.test(src), 'it is loaded on the socket path, which is the only path that uses it')
96})