| … | … | @@ -81,6 +81,15 @@ if (roles.includes('fediverse')) { |
| 81 | 81 | try { configs.fediverse = assertUsable(loadApConfig()) } catch (err) { configs.fediverse = new Error('fediverse config: ' + err.message) } |
| 82 | 82 | } |
| 83 | 83 | |
| 84 | // The lens's feeds: `id=roomKey` pairs, the shape favela-web's FAVELA_COMMUNITY_ROOMS used. | |
| 85 | if (roles.includes('lens')) { | |
| 86 | const feeds = String(process.env.LENS_FEEDS || '').split(',').map((s) => s.trim()).filter(Boolean).map((pair) => { | |
| 87 | const [id, roomKey] = pair.split('=') | |
| 88 | return { id: (id || '').trim(), roomKey: (roomKey || '').trim() } | |
| 89 | }).filter((f) => f.id && /^[0-9a-f]{64}$/i.test(f.roomKey)) | |
| 90 | configs.lens = feeds.length ? { feeds } : new Error('lens config: LENS_FEEDS has no id=roomKey pair') | |
| 91 | } | |
| 92 | ||
| 84 | 93 | const gateway = await startGateway({ roles, log, version, ...configs }) |
| 85 | 94 | |
| 86 | 95 | // The one control socket. A stale socket file from a killed process blocks bind; we only get here once |
| … | … | @@ -77,6 +77,16 @@ export function renderMetrics (m, { now = Date.now() } = {}) { |
| 77 | 77 | } |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | if (m.lens) { | |
| 81 | L.push('') | |
| 82 | L.push('lens (bsky custom feeds)') | |
| 83 | for (const f of m.lens.feeds) { | |
| 84 | L.push(` ${f.id.padEnd(24)} ${f.communityDid ? f.posts + ' posts from ' + f.members + ' members, newest ' + (f.newest ? f.newest.slice(0, 10) : '?') : 'WAITING for the room (community DID not replicated yet)'}${f.curation !== 'roster' ? ' curation ' + f.curation + ' NOT IMPLEMENTED — serving roster' : ''}`) | |
| 85 | if (f.unfronted.length) L.push(` ${''.padEnd(24)} NOT FRONTED (their posts are missing): ${f.unfronted.join(', ')}`) | |
| 86 | for (const p of f.perMember) if (p.error) L.push(` ${''.padEnd(24)} ${p.did}: ${p.error}`) | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 80 | 90 | if (m.fediverse) { |
| 81 | 91 | const f = m.fediverse |
| 82 | 92 | L.push('') |
| … | … | @@ -20,10 +20,19 @@ import { startMembrane } from 'atproto-bridge/pds/lib/membrane.js' |
| 20 | 20 | import { startApBridge } from 'ap-bridge/lib/bridge.js' |
| 21 | 21 | import os from 'bare-os' |
| 22 | 22 | import { createProbes } from './probes.js' |
| 23 | import { createLens } from './lens.js' | |
| 23 | 24 | |
| 24 | 25 | const ROLES = { |
| 25 | 26 | membrane: async (cfg, opts) => startMembrane(cfg, opts), |
| 26 | fediverse: async (cfg, opts) => startApBridge(cfg, opts) | |
| 27 | fediverse: async (cfg, opts) => startApBridge(cfg, opts), | |
| 28 | // The lens serves THROUGH the membrane (its XRPC extensions, its store, its swarm, its fronts), so it | |
| 29 | // needs the membrane role up and started first. | |
| 30 | lens: async (cfg, opts, running) => { | |
| 31 | if (!running.membrane) throw new Error('the lens needs the membrane role (start it before the lens)') | |
| 32 | const lens = createLens({ membrane: running.membrane, feeds: (cfg && cfg.feeds) || [], log: opts.log }) | |
| 33 | await lens.start() | |
| 34 | return lens | |
| 35 | } | |
| 27 | 36 | } |
| 28 | 37 | |
| 29 | 38 | const defaultLog = (o) => console.log(JSON.stringify(o)) |
| … | … | @@ -48,7 +57,7 @@ export async function startGateway ({ roles, log = defaultLog, bootstrap = null, |
| 48 | 57 | try { |
| 49 | 58 | // A config the entry could not load arrives as an Error: that role is down, for that reason. |
| 50 | 59 | if (configs[name] instanceof Error) throw configs[name] |
| 51 | running[name] = await ROLES[name](configs[name], { log: roleLog, bootstrap, version }) | |
| 60 | running[name] = await ROLES[name](configs[name], { log: roleLog, bootstrap, version }, running) | |
| 52 | 61 | log({ msg: 'role up', role: name }) |
| 53 | 62 | } catch (err) { |
| 54 | 63 | down[name] = (err && err.message) || String(err) |
| … | … | @@ -99,11 +108,13 @@ export async function startGateway ({ roles, log = defaultLog, bootstrap = null, |
| 99 | 108 | roles: {}, |
| 100 | 109 | membrane: null, |
| 101 | 110 | fediverse: null, |
| 111 | lens: null, | |
| 102 | 112 | bsky: null |
| 103 | 113 | } |
| 104 | 114 | for (const name of roles) out.roles[name] = running[name] ? { up: true } : { up: false, error: down[name] } |
| 105 | 115 | if (running.membrane) out.membrane = running.membrane.metrics() |
| 106 | 116 | if (running.fediverse) out.fediverse = await running.fediverse.metrics() |
| 117 | if (running.lens) out.lens = running.lens.metrics() | |
| 107 | 118 | if (probes) { |
| 108 | 119 | const dids = out.membrane.dids.map((d) => ({ did: d.did, rev: d.rev })) |
| 109 | 120 | out.bsky = await probes.get({ dids, emitted: out.membrane.relay.emitted }, { force: !!params.refresh }) |
| … | … | @@ -0,0 +1,230 @@ |
| 1 | // The lens role — a favela community's bsky custom feed, served keylessly by the gateway (hyper-social #80 G2). | |
| 2 | // | |
| 3 | // Replaces favela-web's lens (stopped since 2026-10-11, and the reason the loser-club feed was down). Same | |
| 4 | // public contract, same selection rules (favela-web lib/feedgen.js), different sources: | |
| 5 | // | |
| 6 | // WHO the community room's members minus bans — favela-community, READ-ONLY, opened by the membrane on | |
| 7 | // its own store and swarm (membrane.communityRooms()). Watched: a roster change re-applies at once. | |
| 8 | // WHAT each member's app.bsky.feed.post records, read from the repo the membrane already FRONTS. Indexed | |
| 9 | // per member and re-read only when that member's served head moves — checked on each append to the | |
| 10 | // relay's aggregate firehose. Never on bsky's request: a front's listRecords is a full scan. | |
| 11 | // HOW newest record createdAt first; ONLY TID rkeys (one bad rkey — loser-club's own 'self.hello' — | |
| 12 | // makes bsky reject the whole response with InvalidFeedResponse); `cursor` a string or ABSENT. | |
| 13 | // | |
| 14 | // Served through the membrane's XRPC extensions, so did.json advertises #bsky_fg and bsky's AppView reaches | |
| 15 | // it at pds.7rnx.net through the community's PLC #bsky_fg — nothing outside this process changes. | |
| 16 | // | |
| 17 | // Labels: nothing produces labels yet (the labeler design is open). queryLabels answers a valid empty set | |
| 18 | // and subscribeLabels holds an empty stream, so the community's advertised labeler is valid but silent. | |
| 19 | ||
| 20 | const GENERATOR = 'app.bsky.feed.generator' | |
| 21 | const POST = 'app.bsky.feed.post' | |
| 22 | const FLOOR = '0000-01-01T00:00:00.000Z' // favela-web's createdAt for a record that has none | |
| 23 | const TID_RE = /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/ | |
| 24 | ||
| 25 | // Opaque cursor: hex of the URI-encoded sort key — pure ASCII, so no TextEncoder (Bare has none without a | |
| 26 | // polyfill). favela-web returned the raw key, NUL bytes and all. | |
| 27 | const toCursor = (key) => [...encodeURIComponent(key)].map((c) => c.charCodeAt(0).toString(16).padStart(2, '0')).join('') | |
| 28 | const fromCursor = (c) => { | |
| 29 | if (!/^([0-9a-f]{2})+$/.test(String(c || ''))) return null | |
| 30 | let s = '' | |
| 31 | for (let i = 0; i < c.length; i += 2) s += String.fromCharCode(parseInt(c.slice(i, i + 2), 16)) | |
| 32 | try { return decodeURIComponent(s) } catch { return null } | |
| 33 | } | |
| 34 | ||
| 35 | const parseFeedUri = (uri) => { | |
| 36 | const m = /^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(String(uri || '')) | |
| 37 | return m ? { did: m[1], collection: m[2], rkey: m[3] } : null | |
| 38 | } | |
| 39 | ||
| 40 | // favela-web's slugRkey: the feed id → the generator record's rkey. | |
| 41 | export function slugRkey (id) { | |
| 42 | const s = String(id || '').toLowerCase().replace(/[^a-z0-9._~-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 64) | |
| 43 | return s || 'feed' | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * @param {object} opts | |
| 48 | * @param {object} opts.membrane the membrane role (startMembrane's return): extensions, peers, relay, | |
| 49 | * metrics(), communityRooms() | |
| 50 | * @param {Array<{id, roomKey}>} opts.feeds e.g. [{ id: 'loser-club', roomKey: '91ce4dbc…' }] | |
| 51 | */ | |
| 52 | export function createLens ({ membrane, feeds = [], log = () => {}, debounceMs = 1000, frontTimeoutMs = 20000 } = {}) { | |
| 53 | const index = new Map() // did -> { rev, rows: [{ key, did, rkey, createdAt }], at } | |
| 54 | const state = feeds.map((f) => ({ ...f, rkey: slugRkey(f.id), communityDid: null, curation: 'roster', members: [], unwatch: null, error: null })) | |
| 55 | let rooms = null | |
| 56 | let appendTimer = null | |
| 57 | let queue = Promise.resolve() | |
| 58 | let stopped = false | |
| 59 | const errors = new Map() // did -> last indexing error | |
| 60 | ||
| 61 | const fronted = (did) => !!(membrane.peers && membrane.peers.isFronted && membrane.peers.isFronted(did)) | |
| 62 | const servedRevs = () => { | |
| 63 | const out = new Map() | |
| 64 | for (const d of (membrane.metrics().dids || [])) out.set(d.did, d.rev) | |
| 65 | return out | |
| 66 | } | |
| 67 | ||
| 68 | // Read one member's posts from its fronted repo. Serialized: one rebuild at a time keeps the load on the | |
| 69 | // membrane's fronts bounded however many appends arrive. | |
| 70 | function rebuild (did, rev) { | |
| 71 | queue = queue.then(async () => { | |
| 72 | if (stopped) return | |
| 73 | try { | |
| 74 | const front = await membrane.peers.front(did, { timeout: frontTimeoutMs }) | |
| 75 | const rows = [] | |
| 76 | let cursor = null | |
| 77 | do { | |
| 78 | const page = await front.listRecords(POST, { limit: 100, cursor }) | |
| 79 | for (const r of page.records || []) { | |
| 80 | const rkey = String(r.uri || '').split('/').pop() | |
| 81 | const v = r.value || {} | |
| 82 | if (v.$type && v.$type !== POST) continue | |
| 83 | if (!TID_RE.test(rkey)) continue | |
| 84 | const createdAt = typeof v.createdAt === 'string' ? v.createdAt : FLOOR | |
| 85 | rows.push({ key: `${createdAt}\u0000${did}\u0000${rkey}`, did, rkey, createdAt }) | |
| 86 | } | |
| 87 | cursor = page.cursor || null | |
| 88 | } while (cursor) | |
| 89 | index.set(did, { rev, rows, at: Date.now() }) | |
| 90 | errors.delete(did) | |
| 91 | log({ msg: 'lens: indexed member', did, rev, posts: rows.length }) | |
| 92 | } catch (err) { | |
| 93 | errors.set(did, (err && err.message) || String(err)) | |
| 94 | log({ msg: 'lens: could not index member', did, error: errors.get(did) }) | |
| 95 | } | |
| 96 | }) | |
| 97 | return queue | |
| 98 | } | |
| 99 | ||
| 100 | // Re-read every member (of every feed) whose served head differs from the one indexed. | |
| 101 | function reconcile () { | |
| 102 | const revs = servedRevs() | |
| 103 | const wanted = new Set() | |
| 104 | for (const f of state) for (const did of f.members) wanted.add(did) | |
| 105 | for (const did of wanted) { | |
| 106 | if (!fronted(did)) continue | |
| 107 | const rev = revs.get(did) || null | |
| 108 | const have = index.get(did) | |
| 109 | if (!have || have.rev !== rev) rebuild(did, rev) | |
| 110 | } | |
| 111 | return queue | |
| 112 | } | |
| 113 | ||
| 114 | const onAppend = () => { | |
| 115 | clearTimeout(appendTimer) | |
| 116 | appendTimer = setTimeout(() => { reconcile() }, debounceMs) | |
| 117 | } | |
| 118 | ||
| 119 | function rowsFor (f) { | |
| 120 | const all = [] | |
| 121 | for (const did of f.members) { | |
| 122 | const e = index.get(did) | |
| 123 | if (e) all.push(...e.rows) | |
| 124 | } | |
| 125 | return all.sort((a, b) => (a.key < b.key ? 1 : a.key > b.key ? -1 : 0)) // newest first, as favela-web | |
| 126 | } | |
| 127 | ||
| 128 | const findFeed = (did, rkey) => state.find((f) => f.communityDid === did && f.rkey === rkey) || null | |
| 129 | const feedUri = (f) => `at://${f.communityDid}/${GENERATOR}/${f.rkey}` | |
| 130 | ||
| 131 | async function getFeedSkeleton ({ query }) { | |
| 132 | const p = parseFeedUri(query.get('feed')) | |
| 133 | const f = p && p.collection === GENERATOR ? findFeed(p.did, p.rkey) : null | |
| 134 | if (!f) return { status: 400, body: { error: 'UnsupportedAlgorithm', message: 'Unsupported algorithm' } } | |
| 135 | const limit = Math.max(1, Math.min(Number(query.get('limit')) || 50, 100)) | |
| 136 | const after = query.get('cursor') ? fromCursor(query.get('cursor')) : null | |
| 137 | let rows = rowsFor(f) | |
| 138 | if (after != null) rows = rows.filter((r) => r.key < after) | |
| 139 | const page = rows.slice(0, limit) | |
| 140 | const body = { feed: page.map((r) => ({ post: `at://${r.did}/${POST}/${r.rkey}` })) } | |
| 141 | // atproto: a string or ABSENT — `cursor: null` fails bsky's response validation for the whole feed. | |
| 142 | if (rows.length > limit) body.cursor = toCursor(page[page.length - 1].key) | |
| 143 | return { status: 200, body } | |
| 144 | } | |
| 145 | ||
| 146 | async function describeFeedGenerator ({ query }) { | |
| 147 | const want = query.get('did') | |
| 148 | const served = state.filter((f) => f.communityDid && (!want || f.communityDid === want)) | |
| 149 | // The lexicon requires `did`: the asked-for community, or the first served one. | |
| 150 | const did = want || (served[0] && served[0].communityDid) || null | |
| 151 | return { status: 200, body: { did, feeds: served.filter((f) => f.communityDid === did).map((f) => ({ uri: feedUri(f) })) } } | |
| 152 | } | |
| 153 | ||
| 154 | async function queryLabels () { return { status: 200, body: { labels: [] } } } | |
| 155 | ||
| 156 | // An empty label stream: hold the socket open until the client goes; nothing to send yet. | |
| 157 | function subscribeLabels (ws) { | |
| 158 | ws.on('error', () => {}) | |
| 159 | ws.on('close', () => {}) | |
| 160 | } | |
| 161 | ||
| 162 | const XRPC = { | |
| 163 | 'app.bsky.feed.getFeedSkeleton': getFeedSkeleton, | |
| 164 | 'app.bsky.feed.describeFeedGenerator': describeFeedGenerator, | |
| 165 | 'com.atproto.label.queryLabels': queryLabels | |
| 166 | } | |
| 167 | const WS = { '/xrpc/com.atproto.label.subscribeLabels': subscribeLabels } | |
| 168 | ||
| 169 | async function start () { | |
| 170 | rooms = await membrane.communityRooms() | |
| 171 | for (const f of state) { | |
| 172 | f.unwatch = await rooms.watch(f.roomKey, async (members) => { | |
| 173 | f.members = [...members] | |
| 174 | // meta:did and curation replicate with the room; re-read them on every change until they land. | |
| 175 | try { f.communityDid = (await rooms.communityDid(f.roomKey)) || f.communityDid } catch {} | |
| 176 | try { const c = await rooms.curation(f.roomKey); f.curation = (c && c.curator) || 'roster' } catch {} | |
| 177 | if (f.curation !== 'roster') log({ msg: 'lens: curation is not roster — serving roster (only roster is implemented)', feed: f.id, curation: f.curation }) | |
| 178 | log({ msg: 'lens: roster', feed: f.id, members: f.members.length, communityDid: f.communityDid }) | |
| 179 | await reconcile() | |
| 180 | }) | |
| 181 | } | |
| 182 | if (membrane.relay && membrane.relay.seq && membrane.relay.seq.core) membrane.relay.seq.core.on('append', onAppend) | |
| 183 | for (const [nsid, fn] of Object.entries(XRPC)) membrane.extensions.xrpc.set(nsid, fn) | |
| 184 | for (const [path, fn] of Object.entries(WS)) membrane.extensions.ws.set(path, fn) | |
| 185 | await queue | |
| 186 | } | |
| 187 | ||
| 188 | async function stop () { | |
| 189 | stopped = true | |
| 190 | clearTimeout(appendTimer) | |
| 191 | for (const nsid of Object.keys(XRPC)) membrane.extensions.xrpc.delete(nsid) | |
| 192 | for (const path of Object.keys(WS)) membrane.extensions.ws.delete(path) | |
| 193 | if (membrane.relay && membrane.relay.seq && membrane.relay.seq.core && membrane.relay.seq.core.off) membrane.relay.seq.core.off('append', onAppend) | |
| 194 | for (const f of state) { if (f.unwatch) { try { await f.unwatch() } catch {} } } | |
| 195 | } | |
| 196 | ||
| 197 | function metrics () { | |
| 198 | return { | |
| 199 | feeds: state.map((f) => { | |
| 200 | const rows = rowsFor(f) | |
| 201 | return { | |
| 202 | id: f.id, | |
| 203 | roomKey: f.roomKey, | |
| 204 | communityDid: f.communityDid, | |
| 205 | feedUri: f.communityDid ? feedUri(f) : null, | |
| 206 | curation: f.curation, | |
| 207 | members: f.members.length, | |
| 208 | unfronted: f.members.filter((d) => !fronted(d)), | |
| 209 | posts: rows.length, | |
| 210 | newest: rows.length ? rows[0].createdAt : null, | |
| 211 | perMember: f.members.map((d) => { | |
| 212 | const e = index.get(d) | |
| 213 | return { did: d, posts: e ? e.rows.length : 0, indexedAt: e ? e.at : null, error: errors.get(d) || null } | |
| 214 | }) | |
| 215 | } | |
| 216 | }) | |
| 217 | } | |
| 218 | } | |
| 219 | ||
| 220 | return { | |
| 221 | start, | |
| 222 | stop, | |
| 223 | metrics, | |
| 224 | status: () => ({ ok: true, lens: true, feeds: state.map((f) => ({ id: f.id, communityDid: f.communityDid, members: f.members.length })) }), | |
| 225 | handlers: {}, | |
| 226 | steps: [['lens', () => stop()]] | |
| 227 | } | |
| 228 | } | |
| 229 | ||
| 230 | export default createLens |
| … | … | @@ -1,12 +1,12 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-gateway", |
| 3 | "version": "0.2.1", | |
| 3 | "version": "0.3.0", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "favela-gateway", |
| 9 | "version": "0.2.1", | |
| 9 | "version": "0.3.0", | |
| 10 | 10 | "dependencies": { |
| 11 | 11 | "ap-bridge": "file:../ap-bridge", |
| 12 | 12 | "atproto-bridge": "file:../atproto-bridge", |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-gateway", |
| 3 | "version": "0.2.1", | |
| 3 | "version": "0.3.0", | |
| 4 | 4 | "description": "The favela gateway: the bsky membrane (atproto-bridge) and the fediverse bridge (ap-bridge) in one process, keyless towards atproto. Later roles: the bsky lens and a blind seeder (hyper-social #80).", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "lib/gateway.js", |
| … | … | @@ -37,13 +37,13 @@ |
| 37 | 37 | ], |
| 38 | 38 | "defaults": { |
| 39 | 39 | "env": { |
| 40 | "GATEWAY_ROLES": "membrane,fediverse", | |
| 40 | "GATEWAY_ROLES": "membrane,fediverse,lens", | |
| 41 | "LENS_FEEDS": "loser-club=91ce4dbcb6b4fe0cfa489582cb9931923c05e0d14cf0a5c939d8334edefe21ed", | |
| 41 | 42 | "GATEWAY_SOCK": "${HOME}/.config/favela-gateway/svc.sock", |
| 42 | 43 | "PDS_BIND": "0.0.0.0", |
| 43 | 44 | "PDS_PORT": "3399", |
| 44 | 45 | "PDS_STORAGE": "${HOME}/.config/atproto-bridge/data", |
| 45 | 46 | "PDS_CRAWL_RELAY": "https://bsky.network", |
| 46 | "FAVELA_WEB_UPSTREAM": "http://127.0.0.1:4041", | |
| 47 | 47 | "AP_BIND": "0.0.0.0", |
| 48 | 48 | "AP_PORT": "3400", |
| 49 | 49 | "AP_HOST": "ap.7rnx.net", |
| … | … | @@ -92,7 +92,7 @@ test('⛔ if every requested role fails, the gateway refuses to start', async (t |
| 92 | 92 | }) |
| 93 | 93 | |
| 94 | 94 | test('⛔ an unknown role is refused rather than ignored', async (t) => { |
| 95 | await t.exception(startGateway({ roles: ['membrane', 'lens'], membrane: membraneConfig(), ...quiet }), /unknown role: lens/) | |
| 95 | await t.exception(startGateway({ roles: ['membrane', 'seeder'], membrane: membraneConfig(), ...quiet }), /unknown role: seeder/) | |
| 96 | 96 | }) |
| 97 | 97 | |
| 98 | 98 | test('the stop steps free everything: the same ports start again in one process', async (t) => { |
| … | … | @@ -136,3 +136,10 @@ test('metrics still answers with a role down, and names it', async (t) => { |
| 136 | 136 | t.is(m.fediverse, null, 'no section for a role that is not running') |
| 137 | 137 | t.ok(m.membrane, 'the running role still reports') |
| 138 | 138 | }) |
| 139 | ||
| 140 | test('⛔ the lens without a running membrane is down, with the reason — it serves THROUGH the membrane', async (t) => { | |
| 141 | const g = await startGateway({ roles: ['fediverse', 'lens'], fediverse: apConfig(), lens: { feeds: [{ id: 'x', roomKey: 'a'.repeat(64) }] }, ...quiet }) | |
| 142 | t.teardown(() => stop(g)) | |
| 143 | t.absent(g.roles.lens) | |
| 144 | t.ok(/needs the membrane/.test(g.down.lens), g.down.lens) | |
| 145 | }) |
| … | … | @@ -0,0 +1,165 @@ |
| 1 | // The lens: the loser-club bsky custom feed, served by the gateway in place of favela-web (hyper-social #80 G2). | |
| 2 | // | |
| 3 | // favela-web's rules, kept (feedgen.js): a community's feed is its room members' app.bsky.feed.post records, | |
| 4 | // newest createdAt first; ONLY TID rkeys (one bad rkey — loser-club's own 'self.hello' — makes bsky reject | |
| 5 | // the whole response); cursor a string or ABSENT, never null. What changes: posts come from the repos the | |
| 6 | // membrane already fronts, indexed per member only when that member's head moves — never on bsky's request. | |
| 7 | ||
| 8 | import test from 'brittle' | |
| 9 | import { createLens } from '../lib/lens.js' | |
| 10 | ||
| 11 | const ROOM = '91ce4dbcb6b4fe0cfa489582cb9931923c05e0d14cf0a5c939d8334edefe21ed' | |
| 12 | const LC = 'did:plc:wqnwulql7ndcz6xmtvaovl6u' | |
| 13 | const WJ = 'did:plc:wj62ebbusln6gxo5azo6ogzo' | |
| 14 | const E2 = 'did:plc:e2jevhuurrhlm36ufk7fklpg' | |
| 15 | const FEED = `at://${LC}/app.bsky.feed.generator/loser-club` | |
| 16 | ||
| 17 | // TIDs in increasing time order. | |
| 18 | const T = ['3mr2222222222', '3mr3333333333', '3mr4444444444', '3mr5555555555', '3mr6666666666', '3mr7777777777'] | |
| 19 | const post = (rkey, createdAt) => ({ uri: 'x/' + rkey, value: { $type: 'app.bsky.feed.post', text: rkey, createdAt } }) | |
| 20 | ||
| 21 | function fakeMembrane ({ members = [WJ, E2], records, revs }) { | |
| 22 | const listeners = [] | |
| 23 | const state = { members, records, revs, listCalls: 0, watchers: [] } | |
| 24 | const membrane = { | |
| 25 | state, | |
| 26 | extensions: { xrpc: new Map(), ws: new Map() }, | |
| 27 | relay: { seq: { core: { on: (ev, fn) => { if (ev === 'append') listeners.push(fn) }, off: () => {} } } }, | |
| 28 | appendNow: () => { for (const fn of listeners) fn() }, | |
| 29 | metrics: () => ({ dids: Object.keys(state.revs).map((did) => ({ did, rev: state.revs[did] })) }), | |
| 30 | peers: { | |
| 31 | isFronted: (did) => did in state.revs, | |
| 32 | front: async (did) => ({ | |
| 33 | listRecords: async (col, { limit = 100, cursor = null } = {}) => { | |
| 34 | state.listCalls++ | |
| 35 | const all = (state.records[did] || []).filter(() => col === 'app.bsky.feed.post') | |
| 36 | const start = cursor ? all.findIndex((r) => r.uri === cursor) + 1 : 0 | |
| 37 | const page = all.slice(start, start + limit) | |
| 38 | return { records: page, cursor: start + limit < all.length ? page[page.length - 1].uri : undefined } | |
| 39 | } | |
| 40 | }) | |
| 41 | }, | |
| 42 | communityRooms: async () => ({ | |
| 43 | members: async () => state.members, | |
| 44 | communityDid: async () => LC, | |
| 45 | curation: async () => ({ curator: 'roster', params: {} }), | |
| 46 | watch: async (roomKey, onChange) => { state.watchers.push(onChange); await onChange(state.members); return async () => {} } | |
| 47 | }) | |
| 48 | } | |
| 49 | return membrane | |
| 50 | } | |
| 51 | ||
| 52 | const baseRecords = () => ({ | |
| 53 | [WJ]: [post(T[0], '2026-09-01T00:00:00Z'), post(T[3], '2026-09-04T00:00:00Z'), post('self.hello', '2026-09-09T00:00:00Z')], | |
| 54 | [E2]: [post(T[1], '2026-09-02T00:00:00Z'), post(T[4], '2026-09-05T00:00:00Z')] | |
| 55 | }) | |
| 56 | ||
| 57 | async function started (opts = {}) { | |
| 58 | const m = fakeMembrane({ records: baseRecords(), revs: { [WJ]: 'r1', [E2]: 'r1', [LC]: 'r1' }, ...opts }) | |
| 59 | const lens = createLens({ membrane: m, feeds: [{ id: 'loser-club', roomKey: ROOM }], log: () => {}, debounceMs: 5 }) | |
| 60 | await lens.start() | |
| 61 | return { m, lens } | |
| 62 | } | |
| 63 | ||
| 64 | const skeleton = (m, q) => m.extensions.xrpc.get('app.bsky.feed.getFeedSkeleton')({ query: new URLSearchParams(q) }) | |
| 65 | ||
| 66 | test('★ the skeleton: members\' posts, newest createdAt first, TID rkeys only', async (t) => { | |
| 67 | const { m, lens } = await started() | |
| 68 | t.teardown(() => lens.stop()) | |
| 69 | const r = await skeleton(m, { feed: FEED }) | |
| 70 | t.is(r.status, 200) | |
| 71 | t.alike(r.body.feed.map((f) => f.post), [ | |
| 72 | `at://${E2}/app.bsky.feed.post/${T[4]}`, | |
| 73 | `at://${WJ}/app.bsky.feed.post/${T[3]}`, | |
| 74 | `at://${E2}/app.bsky.feed.post/${T[1]}`, | |
| 75 | `at://${WJ}/app.bsky.feed.post/${T[0]}` | |
| 76 | ], 'self.hello is not a TID, so it is not in the feed') | |
| 77 | t.absent('cursor' in r.body, 'exhausted: cursor ABSENT, never null') | |
| 78 | }) | |
| 79 | ||
| 80 | test('★ pagination: pages concatenate to the whole feed, and only the last lacks a cursor', async (t) => { | |
| 81 | const { m, lens } = await started() | |
| 82 | t.teardown(() => lens.stop()) | |
| 83 | const p1 = (await skeleton(m, { feed: FEED, limit: '3' })).body | |
| 84 | t.is(p1.feed.length, 3) | |
| 85 | t.is(typeof p1.cursor, 'string', 'a string cursor while more remain') | |
| 86 | const p2 = (await skeleton(m, { feed: FEED, limit: '3', cursor: p1.cursor })).body | |
| 87 | t.is(p2.feed.length, 1) | |
| 88 | t.absent('cursor' in p2) | |
| 89 | t.ok(!/\u0000/.test(p1.cursor), 'the cursor is opaque text (favela-web\'s carried NUL bytes)') | |
| 90 | }) | |
| 91 | ||
| 92 | test('limit: default 50, at most 100', async (t) => { | |
| 93 | const { m, lens } = await started() | |
| 94 | t.teardown(() => lens.stop()) | |
| 95 | t.is((await skeleton(m, { feed: FEED, limit: '1000' })).body.feed.length, 4, 'clamped, never an error') | |
| 96 | }) | |
| 97 | ||
| 98 | test('⛔ an unknown feed is UnsupportedAlgorithm (400), as the feed-generator contract says', async (t) => { | |
| 99 | const { m, lens } = await started() | |
| 100 | t.teardown(() => lens.stop()) | |
| 101 | const r = await skeleton(m, { feed: `at://${LC}/app.bsky.feed.generator/nope` }) | |
| 102 | t.is(r.status, 400) | |
| 103 | t.is(r.body.error, 'UnsupportedAlgorithm') | |
| 104 | }) | |
| 105 | ||
| 106 | test('describeFeedGenerator names the community DID (the lexicon requires `did`)', async (t) => { | |
| 107 | const { m, lens } = await started() | |
| 108 | t.teardown(() => lens.stop()) | |
| 109 | const r = await m.extensions.xrpc.get('app.bsky.feed.describeFeedGenerator')({ query: new URLSearchParams() }) | |
| 110 | t.alike(r.body, { did: LC, feeds: [{ uri: FEED }] }) | |
| 111 | }) | |
| 112 | ||
| 113 | test('⛔ requests never rebuild the index; a member whose head MOVED is re-read on the next relay append', async (t) => { | |
| 114 | const { m, lens } = await started() | |
| 115 | t.teardown(() => lens.stop()) | |
| 116 | const before = m.state.listCalls | |
| 117 | await skeleton(m, { feed: FEED }); await skeleton(m, { feed: FEED }) | |
| 118 | t.is(m.state.listCalls, before, 'two skeleton requests, zero repo reads') | |
| 119 | ||
| 120 | m.state.records[WJ].push(post(T[5], '2026-09-06T00:00:00Z')) | |
| 121 | m.appendNow() // an append whose head did not move for anyone: nothing to re-read | |
| 122 | await new Promise((resolve) => setTimeout(resolve, 40)) | |
| 123 | t.is(m.state.listCalls, before, 'no head moved: no re-read') | |
| 124 | ||
| 125 | m.state.revs[WJ] = 'r2' | |
| 126 | m.appendNow() | |
| 127 | await new Promise((resolve) => setTimeout(resolve, 60)) | |
| 128 | t.ok(m.state.listCalls > before, 'wj62\'s head moved: re-read') | |
| 129 | t.is((await skeleton(m, { feed: FEED })).body.feed[0].post, `at://${WJ}/app.bsky.feed.post/${T[5]}`, 'and its new post leads the feed') | |
| 130 | }) | |
| 131 | ||
| 132 | test('a member removed from the room leaves the feed', async (t) => { | |
| 133 | const { m, lens } = await started() | |
| 134 | t.teardown(() => lens.stop()) | |
| 135 | m.state.members = [WJ] | |
| 136 | for (const w of m.state.watchers) await w([WJ]) | |
| 137 | const r = (await skeleton(m, { feed: FEED })).body | |
| 138 | t.ok(r.feed.every((f) => f.post.includes(WJ)), 'only wj62 remains') | |
| 139 | }) | |
| 140 | ||
| 141 | test('a member the membrane does not front is reported, not silently missing', async (t) => { | |
| 142 | const { m, lens } = await started({ members: [WJ, E2, 'did:plc:stranger'] }) | |
| 143 | t.teardown(() => lens.stop()) | |
| 144 | const x = lens.metrics() | |
| 145 | t.alike(x.feeds[0].unfronted, ['did:plc:stranger']) | |
| 146 | t.is(x.feeds[0].members, 3) | |
| 147 | t.is(x.feeds[0].posts, 4, 'indexed posts for the fronted members') | |
| 148 | t.is(x.feeds[0].communityDid, LC) | |
| 149 | t.is(x.feeds[0].curation, 'roster') | |
| 150 | }) | |
| 151 | ||
| 152 | test('labels: queryLabels answers an empty, valid set; subscribeLabels is served', async (t) => { | |
| 153 | const { m, lens } = await started() | |
| 154 | t.teardown(() => lens.stop()) | |
| 155 | const r = await m.extensions.xrpc.get('com.atproto.label.queryLabels')({ query: new URLSearchParams({ uriPatterns: '*' }) }) | |
| 156 | t.alike(r.body, { labels: [] }) | |
| 157 | t.ok(m.extensions.ws.has('/xrpc/com.atproto.label.subscribeLabels')) | |
| 158 | }) | |
| 159 | ||
| 160 | test('stop removes every registered method — a stopped lens serves nothing', async (t) => { | |
| 161 | const { m, lens } = await started() | |
| 162 | await lens.stop() | |
| 163 | t.is(m.extensions.xrpc.size, 0) | |
| 164 | t.is(m.extensions.ws.size, 0) | |
| 165 | }) |