// feedgen.js — favela's LENS EXPORT to bsky (DESIGN §3.6): a favela community projects into a
// bsky-subscribable custom feed via app.bsky.feed.getFeedSkeleton.
//
// A feed generator is STATELESS and hydration-free (atproto contract): it returns an ordered list of
// post at:// URIs ({ feed:[{post}], cursor }); the requesting AppView (bsky) hydrates text/author/counts.
// favela already holds exactly this — a community's skeleton is its members' posts in reverse-chron,
// straight out of the appview index (getTimeline({authors})). We return ONLY app.bsky.feed.post URIs —
// bsky can't hydrate net.favela.local.post (unknown lexicon), so local-only posts correctly stay local
// (DESIGN §3.6 caveat). The room itself is never bsky-legible; only its post-output projects.
//
// Three surfaces (the starter-kit shape, keyless-native):
// GET /.well-known/did.json — did:web doc; BskyFeedGenerator service → our HTTPS
// GET /xrpc/app.bsky.feed.describeFeedGenerator — advertise every hosted community feed
// GET /xrpc/app.bsky.feed.getFeedSkeleton?feed=… — the skeleton for one community
import { makeCurator } from './curators.js'
const GENERATOR_COLLECTION = 'app.bsky.feed.generator'
const BSKY_POST = 'app.bsky.feed.post'
// Parse an at:// URI into { hostname, collection, rkey }. Returns null if malformed.
export function parseFeedUri (uri) {
const m = /^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(String(uri || ''))
return m ? { hostname: m[1], collection: m[2], rkey: m[3] } : null
}
// A member post's canonical bsky at:// URI (a skeleton entry).
export function postUri (did, rkey) { return `at://${did}/${BSKY_POST}/${rkey}` }
// Turn a community id/name into a valid atproto record key (rkey syntax = [A-Za-z0-9._~-], 1–512, not
// "."/".."). Used both when registering a feed and when publishing its generator record, so the two never
// drift. Deterministic: the same id always slugs to the same rkey.
export function slugRkey (id) {
const s = String(id || '').toLowerCase().replace(/[^a-z0-9._~-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 64)
return s || 'feed'
}
// Is this indexed post bsky-federated (vs favela-local)? Only these belong in a bsky-facing feed.
export function isBskyPost (p) { return !!(p && p.record && p.record.$type === BSKY_POST) }
// bsky post rkeys are TIDs (the app.bsky.feed.post lexicon declares key=tid). A non-TID rkey — e.g. a
// bootstrap placeholder like 'self.hello', or any malformed member record — is NOT a real post, and it
// POISONS a bsky feed: getFeed validates every skeleton URI's rkey as a TID and rejects the ENTIRE response
// (InvalidFeedResponse) on a single bad entry, so ONE stray record breaks the whole community feed. Emit
// only valid-TID post URIs. TID = 13 chars, base32-sortable (matches @atproto ensureValidTid exactly).
const TID_RE = /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/
export function isValidPostRkey (rkey) { return TID_RE.test(String(rkey || '')) }
export class FeedGenerator {
// MULTI-TENANT (phase 4): favela-web serves getFeedSkeleton for MANY communities, each identified by its
// OWN co-owned did:plc (the community DID IS its own feed-generator service — its PLC #bsky_fg points
// here). So a feed is keyed by (communityDid, rkey); there is NO single service/publisher DID. hostname
// is optional (only for a vestigial did:web fallback doc — communities self-advertise via their PLC docs).
constructor ({ hostname = null } = {}) {
this.hostname = hostname
this._algos = new Map() // `${communityDid}\x00${rkey}` -> { communityDid, rkey, roomKey, displayName, description }
}
_key (communityDid, rkey) { return `${communityDid}\x00${rkey}` }
// Register a hosted community feed. communityDid = the community's own DID (owns the feed record + is the
// service). rkey = the generator record rkey (the community's feed short name). roomKey = the room to
// read the roster from. Idempotent per (communityDid, rkey).
addFeed ({ communityDid, rkey, roomKey, displayName = '', description = '' } = {}) {
if (!communityDid || !rkey) throw new Error('addFeed requires communityDid + rkey')
this._algos.set(this._key(communityDid, String(rkey)), { communityDid, rkey: String(rkey), roomKey: roomKey || null, displayName, description })
return this
}
// Deregister a hosted feed (a remove-feed reaction from the FeedRegistry). Idempotent — removing an
// unknown feed is a no-op. Returns true iff a feed was actually removed. In-memory only: the durable
// feed record lives in the FeedRegistry (owned elsewhere); this just stops SERVING a de-listed feed.
removeFeed (communityDid, rkey) { return this._algos.delete(this._key(communityDid, String(rkey))) }
get feeds () { return [...this._algos.values()] }
has (communityDid, rkey) { return this._algos.has(this._key(communityDid, String(rkey))) }
feed (communityDid, rkey) { return this._algos.get(this._key(communityDid, String(rkey))) || null }
// app.bsky.feed.describeFeedGenerator. Ambiguous at a multi-tenant front (many service DIDs, one
// endpoint), so: given a communityDid, describe THAT community's feeds; without one, list every hosted
// feed uri (a best-effort superset — describe is optional; the load-bearing path is getFeedSkeleton).
describe (communityDid = null) {
const feeds = communityDid ? this.feeds.filter((f) => f.communityDid === communityDid) : this.feeds
const out = { feeds: feeds.map((f) => ({ uri: this.feedUri(f.communityDid, f.rkey) })) }
if (communityDid) out.did = communityDid
return out
}
// The at:// uri of a hosted feed — under the COMMUNITY DID (its own repo holds the record).
feedUri (communityDid, rkey) { return `at://${communityDid}/${GENERATOR_COLLECTION}/${rkey}` }
// Validate a getFeedSkeleton `feed` param (atproto contract). The uri's hostname is the community DID;
// accept iff it's the generator collection and (communityDid, rkey) is a hosted feed. Returns the feed
// entry, or throws UnsupportedAlgorithm ({ status:400, error:'UnsupportedAlgorithm' }).
resolve (feedUri) {
const parsed = parseFeedUri(feedUri)
if (!parsed || parsed.collection !== GENERATOR_COLLECTION || !this.has(parsed.hostname, parsed.rkey)) {
const e = new Error('Unsupported algorithm'); e.status = 400; e.error = 'UnsupportedAlgorithm'; throw e
}
return this.feed(parsed.hostname, parsed.rkey)
}
// Build the skeleton for one hosted community feed by running the community's CURATION CONTRACT
// ({ curator, params }) over the appview (architecture.md "Feeds are curation functions"). Injected:
// rosterOf(roomKey) => member DIDs (array/Set) — the roster substrate (WHO)
// curationOf(roomKey) => { curator, params } — the curation contract (HOW)
// getTimeline({authors,limit,cursor}) => { posts, cursor } — appview, author-scoped
// getTagFeed(tag,{limit,cursor}) => { posts, cursor } — appview, tag-scoped
// makeCurator resolves the contract to a PAGER; we page it, keeping ONLY bsky posts and over-fetching to
// backfill locals dropped by isBskyPost, so a page still tends to yield `limit` bsky entries. curationOf
// is OPTIONAL — omitted (or absent contract) => roster curation, favela-web's historical default, so
// callers that don't pass it (and pre-curation rooms) behave exactly as before. bsky-neutral: the output
// is always { feed:[{post}], cursor } of app.bsky.feed.post URIs.
async skeleton (algo, { rosterOf, curationOf = null, getTimeline, getTagFeed = null, limit = 50, cursor = null } = {}) {
const roster = new Set(await rosterOf(algo.roomKey))
const curation = curationOf ? await curationOf(algo.roomKey) : { curator: 'roster', params: {} }
const pager = makeCurator(curation, { roster, queries: { getTimeline, getTagFeed } })
const feed = []
let cur = cursor
// Page through the curator's stream until we have `limit` bsky posts or it is exhausted.
while (feed.length < limit) {
const page = await pager({ limit, cursor: cur })
for (const p of page.posts) { if (isBskyPost(p) && isValidPostRkey(p.rkey)) feed.push({ post: postUri(p.did, p.rkey) }) }
cur = page.cursor
if (!cur || !page.posts.length) break
}
// atproto: `cursor` is an OPTIONAL string — it must be a string or ABSENT, never JSON null. An exhausted
// pager leaves `cur === null`; emitting `{cursor: null}` fails bsky's XRPC response-schema validation
// (cursor is typed `string`) → the AppView rejects the ENTIRE feed with InvalidFeedResponse (a feed that
// reads as "broken" on bsky). So OMIT the field when there is no next page: present string = more pages,
// absent = end of feed. (This is the actual InvalidFeedResponse cause, distinct from the TID filter above.)
const out = { feed: feed.slice(0, limit) }
if (cur) out.cursor = cur
return out
}
}
export default FeedGenerator