From e50c91892beeca7e39b90465ca0ff253ca4cc587 Mon Sep 17 00:00:00 2001 From: scanash00 Date: Fri, 12 Jun 2026 09:09:51 -0800 Subject: [PATCH] microcosm: custom/algorithmic feeds without the AppView Custom feeds are NOT locked behind the bsky AppView (corrected earlier claim). A feed is published by a feed-generator service; getFeed just proxies its skeleton + hydrates. We do both ourselves: - slingshot.resolveService: DID + #bsky_fg -> http endpoint - feed.ts buildCustomFeed: feed record -> generator endpoint -> getFeedSkeleton directly -> hydratePost the URIs Verified end-to-end on the Discover feed (resolved discover.bsky.app, hydrated real posts) in ~2.3s, zero AppView. --- MICROCOSM_PLAN.md | 27 ++++++++----- src/lib/microcosm/feed.ts | 74 +++++++++++++++++++++++++++++++++- src/lib/microcosm/slingshot.ts | 18 +++++++++ 3 files changed, 108 insertions(+), 11 deletions(-) diff --git a/MICROCOSM_PLAN.md b/MICROCOSM_PLAN.md index 975b51a..dd97b82 100644 --- a/MICROCOSM_PLAN.md +++ b/MICROCOSM_PLAN.md @@ -167,10 +167,16 @@ Original Phase 3 scope notes: - **Following timeline (original notes):** read my `app.bsky.graph.follow` records → fan-out fetch recent posts per follow → merge client-side. Expensive; viable for modest follow counts, needs caching/windowing. -- **Algorithmic feeds (`getFeed`)**: require a feed generator producing a - skeleton; cannot be reproduced from microcosm primitives alone. Either keep - using third-party feed generators (not bsky AppView — just the FG service) + - Slingshot `hydrateQueryResponse` to hydrate the skeleton, or drop algo feeds. +- **Algorithmic / custom feeds (`getFeed`): DONE, and NOT locked behind the + AppView** (earlier assessment was WRONG). A custom feed is produced by a + *feed generator* service, not Bluesky. `getFeed` is just: call the generator's + `getFeedSkeleton` (post URIs) + hydrate. We do both ourselves: + `buildCustomFeed(feedUri)` reads the generator record → resolves its + `#bsky_fg` endpoint via Slingshot → calls `getFeedSkeleton` directly on the + generator → hydrates the URIs with `hydratePost`. Verified end-to-end on the + Discover feed (resolved discover.bsky.app, hydrated real posts) in ~2.3s, no + AppView. This is faster + better than the fan-out following feed since the + generator does the algorithm. NOT yet wired into `usePostFeedQuery`. - **Search (`searchActors`/`searchPosts`)**: no microcosm equivalent → keep, or integrate a separate search index later. @@ -196,12 +202,13 @@ Original Phase 3 scope notes: - **Achievable for:** record reads (Slingshot), identity (Slingshot), interaction counts/lists (Constellation), live notifications (Spacedust), - writes/auth (PDS, already independent), the following-feed and single-post/ - profile/thread views (hydration layer). -- **Hard / maybe-not:** algorithmic feeds (need a feed generator) and full-text - search (need a search index). These are genuinely separate services in - atproto; microcosm doesn't provide them. A fully-independent app would either - drop them or add another service. + writes/auth (PDS, already independent), single-post/profile/thread views + (hydration layer), the following-feed (PDS fan-out), and **custom/algorithmic + feeds (feed-generator skeleton + hydration — NOT AppView-locked).** +- **The actual remaining gaps:** full-text search (`searchActors`/`searchPosts` + need a search index — genuinely a separate service microcosm doesn't provide), + video embeds (need the video service), and `followsCount` (listRecords + aggregation). Feeds are NOT a gap. - **Effort:** Phases 0–2 are a few focused sessions and give big, demoable wins. Phase 3 is the real build. Phases 4–5 are research-heavy. diff --git a/src/lib/microcosm/feed.ts b/src/lib/microcosm/feed.ts index 5ed8586..3509094 100644 --- a/src/lib/microcosm/feed.ts +++ b/src/lib/microcosm/feed.ts @@ -15,7 +15,12 @@ import {type AppBskyFeedDefs, type AppBskyFeedPost} from '@atproto/api' import {hydratePost} from '#/lib/microcosm/hydrate' -import {listRecords} from '#/lib/microcosm/slingshot' +import { + getRecordByUri, + listRecords, + resolveService, +} from '#/lib/microcosm/slingshot' +import {MICROCOSM_USER_AGENT} from '#/env' export type FollowingFeedItem = { post: AppBskyFeedDefs.PostView @@ -121,3 +126,70 @@ export async function buildFollowingFeed( .map(post => ({post})), } } + +// --- Custom (algorithmic) feeds, independent of the AppView --- +// +// A custom feed is published by a *feed generator* service (not Bluesky). The +// AppView's getFeed just proxies to it and hydrates. We do the same ourselves: +// 1. read the feed generator record -> its service DID +// 2. resolve that DID's #bsky_fg endpoint (Slingshot) +// 3. call getFeedSkeleton on the generator directly -> post URIs +// 4. hydrate the URIs (our own hydratePost) + +type SkeletonItem = {post: string; reason?: unknown; feedContext?: string} +type SkeletonResponse = {feed: SkeletonItem[]; cursor?: string} + +/** Resolve a feed-generator at-uri to its HTTP service endpoint. */ +async function resolveFeedGenEndpoint( + feedUri: string, + signal?: AbortSignal, +): Promise { + const rec = await getRecordByUri(feedUri, undefined, signal).catch( + () => undefined, + ) + const serviceDid = (rec?.value as {did?: string} | undefined)?.did + if (!serviceDid) return undefined + return resolveService(serviceDid, '#bsky_fg', signal) +} + +/** + * Fetch + hydrate one page of a custom (algorithmic) feed by calling its feed + * generator directly, with no AppView. The generator runs the algorithm; we + * just resolve its endpoint and hydrate the post URIs it returns. + */ +export async function buildCustomFeed( + feedUri: string, + opts: {viewerDid?: string; limit?: number; cursor?: string} = {}, + signal?: AbortSignal, +): Promise { + const endpoint = await resolveFeedGenEndpoint(feedUri, signal) + if (!endpoint) return {feed: []} + + const url = new URL('/xrpc/app.bsky.feed.getFeedSkeleton', endpoint) + url.searchParams.set('feed', feedUri) + url.searchParams.set('limit', String(opts.limit ?? 30)) + if (opts.cursor) url.searchParams.set('cursor', opts.cursor) + + const res = await fetch(url.toString(), { + headers: {Accept: 'application/json', 'User-Agent': MICROCOSM_USER_AGENT}, + signal, + }) + if (!res.ok) return {feed: []} + const skeleton = (await res.json()) as SkeletonResponse + + const posts = await Promise.all( + skeleton.feed.map(item => + // Skip malformed entries. + item.post?.startsWith('at://') + ? hydratePost(item.post, opts.viewerDid, signal).catch(() => undefined) + : Promise.resolve(undefined), + ), + ) + + return { + cursor: skeleton.cursor, + feed: posts + .filter((p): p is AppBskyFeedDefs.PostView => !!p) + .map(post => ({post})), + } +} diff --git a/src/lib/microcosm/slingshot.ts b/src/lib/microcosm/slingshot.ts index f4a8a01..b1eaf6c 100644 --- a/src/lib/microcosm/slingshot.ts +++ b/src/lib/microcosm/slingshot.ts @@ -131,6 +131,24 @@ export function resolveMiniDoc( ) } +/** + * Resolve an atproto service DID + id fragment to its HTTP endpoint, e.g. a + * feed generator's `#bsky_fg` service. Lets us call a feed generator directly + * instead of proxying through the AppView. + */ +export async function resolveService( + did: string, + id: string, + signal?: AbortSignal, +): Promise { + const res = await get<{endpoint?: string}>( + '/xrpc/com.bad-example.identity.resolveService', + {did, id}, + signal, + ).catch(() => undefined) + return res?.endpoint +} + export type ListedRecord = {uri: string; cid: string; value: unknown} export type ListRecordsResponse = {records: ListedRecord[]; cursor?: string} -- knotmirror 0.0.1