OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / feedgen.js

139 lines · javascript ref:
// 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