| … | … | @@ -18,6 +18,7 @@ |
| 18 | 18 | // one for the other. |
| 19 | 19 | |
| 20 | 20 | import ProtomuxRPC from 'protomux-rpc' |
| 21 | import Protomux from 'protomux' | |
| 21 | 22 | import c from 'compact-encoding' |
| 22 | 23 | import b4a from 'b4a' |
| 23 | 24 | |
| … | … | @@ -87,11 +88,39 @@ export function serveRegistration ({ swarm, log = () => {}, ...deps }) { |
| 87 | 88 | // The swarm keypair signs observer grants (admit.js): its public key IS the bridgeKey a peer claimed against. |
| 88 | 89 | const handlers = registerHandlers({ bridgeKeyPair: swarm.keyPair || null, ...deps }) |
| 89 | 90 | |
| 90 | const onconnection = (conn) => { | |
| 91 | const onconnection = (conn) => { attachRegistration(conn, handlers) } | |
| 92 | ||
| 93 | swarm.on('connection', onconnection) | |
| 94 | const topic = swarm.keyPair.publicKey | |
| 95 | const disc = swarm.join(topic, { server: true, client: true }) | |
| 96 | ||
| 97 | return { | |
| 98 | key: b4a.toString(topic, 'hex'), | |
| 99 | ready: () => disc.flushed(), | |
| 100 | close: () => { try { swarm.off('connection', onconnection) } catch {} } | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * Serve the registration verbs on one connection. | |
| 106 | * | |
| 107 | * ⛔ AND KEEP SERVING A PEER THAT ASKS LATE (2026-10-14). A peer whose feed this bridge holds stays connected for | |
| 108 | * hours. Our channel opens the moment the connection appears; the peer has none yet, so ITS protomux rejects that | |
| 109 | * open (_requestSession → _rejectSession) and ours closes. When the user then presses "claim", the peer's open met | |
| 110 | * no channel and no pair() handler here and was rejected too — "channel closed", every time, until the connection | |
| 111 | * dropped (measured on a phone). So the responder is (re)created on demand: once now, and from pair() whenever the | |
| 112 | * peer opens and no live channel remains. The same idiom as atproto-room's presence channel. | |
| 113 | */ | |
| 114 | export function attachRegistration (conn, handlers) { | |
| 115 | let mux | |
| 116 | try { mux = Protomux.from(conn) } catch { return null } | |
| 117 | let live = null | |
| 118 | const open = () => { | |
| 119 | if (live && !live.closed) return live | |
| 91 | 120 | let rpc |
| 92 | 121 | try { |
| 93 | 122 | rpc = new ProtomuxRPC(conn, { protocol: PROTOCOL, valueEncoding: c.json }) |
| 94 | } catch { return } | |
| 123 | } catch { return null } | |
| 95 | 124 | for (const [name, fn] of Object.entries(handlers)) { |
| 96 | 125 | rpc.respond(name, async (params) => { |
| 97 | 126 | try { |
| … | … | @@ -103,17 +132,14 @@ export function serveRegistration ({ swarm, log = () => {}, ...deps }) { |
| 103 | 132 | } |
| 104 | 133 | }) |
| 105 | 134 | } |
| 135 | rpc.on('close', () => { if (live === rpc) live = null }) | |
| 136 | live = rpc | |
| 137 | return rpc | |
| 106 | 138 | } |
| 107 | ||
| 108 | swarm.on('connection', onconnection) | |
| 109 | const topic = swarm.keyPair.publicKey | |
| 110 | const disc = swarm.join(topic, { server: true, client: true }) | |
| 111 | ||
| 112 | return { | |
| 113 | key: b4a.toString(topic, 'hex'), | |
| 114 | ready: () => disc.flushed(), | |
| 115 | close: () => { try { swarm.off('connection', onconnection) } catch {} } | |
| 116 | } | |
| 139 | const first = open() | |
| 140 | try { mux.pair({ protocol: PROTOCOL }, () => { open() }) } catch {} | |
| 141 | try { conn.once('close', () => { try { mux.unpair({ protocol: PROTOCOL }) } catch {} }) } catch {} | |
| 142 | return first | |
| 117 | 143 | } |
| 118 | 144 | |
| 119 | export default { serveRegistration, registerHandlers, PROTOCOL } | |
| 145 | export default { serveRegistration, registerHandlers, attachRegistration, PROTOCOL } |
| … | … | @@ -1,12 +1,12 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "ap-bridge", |
| 3 | "version": "0.10.19", | |
| 3 | "version": "0.10.20", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "ap-bridge", |
| 9 | "version": "0.10.19", | |
| 9 | "version": "0.10.20", | |
| 10 | 10 | "license": "MIT", |
| 11 | 11 | "dependencies": { |
| 12 | 12 | "atproto-did-peer": "file:../atproto-did-peer", |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "ap-bridge", |
| 3 | "version": "0.10.19", | |
| 3 | "version": "0.10.20", | |
| 4 | 4 | "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -38,7 +38,8 @@ |
| 38 | 38 | "devDependencies": { |
| 39 | 39 | "anproto": "file:../anproto", |
| 40 | 40 | "brittle": "^3.0.0", |
| 41 | "keet-identity-key": "3.2.0" | |
| 41 | "keet-identity-key": "3.2.0", | |
| 42 | "@hyperswarm/secret-stream": "^6.9.1" | |
| 42 | 43 | }, |
| 43 | 44 | "license": "MIT", |
| 44 | 45 | "pgh": { |
| … | … | @@ -158,3 +158,36 @@ test('no observer configured: the reply is unchanged (no grant field)', async (t |
| 158 | 158 | t.is(r.status, 200) |
| 159 | 159 | t.absent(r.body.observer) |
| 160 | 160 | }) |
| 161 | ||
| 162 | // ⛔ A PEER THAT ASKS LATE ON A LONG-LIVED CONNECTION MUST STILL BE ANSWERED. 2026-10-14: a phone holding fleeky's | |
| 163 | // feed stays connected to the bridge for hours (the bridge holds its feeds). The bridge opens its registration | |
| 164 | // channel the moment the connection appears; the phone has none yet, so ITS protomux rejects that open | |
| 165 | // (_requestSession → _rejectSession) and the bridge's channel closes. When the user later presses "claim", the | |
| 166 | // phone's open meets no channel and no pair() handler and is rejected too: "channel closed", every time, until the | |
| 167 | // connection drops. A fresh connection that asks at once (every earlier registration) never saw it. | |
| 168 | test('★ a client that opens the registration channel LATE, on an already-muxed connection, is answered', async (t) => { | |
| 169 | const SecretStream = (await import('@hyperswarm/secret-stream')).default | |
| 170 | const Protomux = (await import('protomux')).default | |
| 171 | const { attachRegistration, PROTOCOL } = await import('../lib/register-rpc.js') | |
| 172 | const ProtomuxRPC = (await import('protomux-rpc')).default | |
| 173 | const c = (await import('compact-encoding')).default | |
| 174 | ||
| 175 | const bridge = new SecretStream(true) | |
| 176 | const peer = new SecretStream(false) | |
| 177 | bridge.rawStream.pipe(peer.rawStream).pipe(bridge.rawStream) | |
| 178 | t.teardown(() => { try { bridge.destroy() } catch {} try { peer.destroy() } catch {} }) | |
| 179 | ||
| 180 | // As in production: the peer's stream already carries a mux (feed replication), so the bridge's early open is | |
| 181 | // processed — and rejected — on arrival. | |
| 182 | Protomux.from(peer) | |
| 183 | attachRegistration(bridge, registerHandlers(deps())) | |
| 184 | await new Promise((resolve) => setTimeout(resolve, 300)) | |
| 185 | ||
| 186 | const rpc = new ProtomuxRPC(peer, { protocol: PROTOCOL, valueEncoding: c.json }) | |
| 187 | const answer = await Promise.race([ | |
| 188 | rpc.request('policy', {}).then((r) => ({ ok: r }), (e) => ({ err: e.message })), | |
| 189 | new Promise((resolve) => setTimeout(() => resolve({ err: 'timeout' }), 3000)) | |
| 190 | ]) | |
| 191 | t.ok(answer.ok, 'answered (was: ' + (answer.err || 'ok') + ')') | |
| 192 | t.is(answer.ok && answer.ok.policy, 'open') | |
| 193 | }) |