diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index 4774a91..75c4a62 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -52,11 +52,14 @@ export class CustomFeedAPI implements FeedAPI { cursor: string | undefined limit: number }): Promise { - if (MICROCOSM_ENABLED) { + // Only use the microcosm batch path when there's no session. Personalized + // feeds (e.g. For You) require a service-auth JWT forwarded to the feed + // generator, which the batch path doesn't yet mint — without it the + // generator rejects the request. Logged-in always uses the AppView path + // below, which forwards auth correctly. + if (MICROCOSM_ENABLED && !this.agent.did) { try { - // Call the feed generator directly (skeleton) + hydrate, no AppView. const page = await buildCustomFeed(this.params.feed, { - viewerDid: this.agent.did, limit, cursor, }) diff --git a/src/lib/microcosm/feed.ts b/src/lib/microcosm/feed.ts index 2d58abd..7a30b8c 100644 --- a/src/lib/microcosm/feed.ts +++ b/src/lib/microcosm/feed.ts @@ -14,13 +14,12 @@ */ import {type AppBskyFeedDefs, type AppBskyFeedPost} from '@atproto/api' -import {hydratePost} from '#/lib/microcosm/hydrate' +import {hydratePost, hydratePostFromRecord} from '#/lib/microcosm/hydrate' import { getRecordByUri, + hydrateFeedSkeleton, listRecords, - resolveService, } from '#/lib/microcosm/slingshot' -import {MICROCOSM_USER_AGENT} from '#/env' export type FollowingFeedItem = { post: AppBskyFeedDefs.PostView @@ -130,64 +129,55 @@ export async function buildFollowingFeed( // --- 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) +// AppView's getFeed just proxies its skeleton + hydrates. We do the same, but +// via Slingshot's batch hydration so all post records arrive in one request. -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( +/** Resolve a feed-generator at-uri to its service DID. */ +async function resolveFeedGenDid( 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) + return (rec?.value as {did?: string} | undefined)?.did } /** - * 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. + * Fetch + hydrate one page of a custom (algorithmic) feed, with no AppView. + * + * Uses Slingshot's batch hydration: it proxies the feed generator's + * getFeedSkeleton AND fetches every post record in ONE request, so we avoid N + * per-post record fetches (which made feeds load one-by-one). Only author + * identities are resolved afterward (cached + parallel). */ 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 feedGenDid = await resolveFeedGenDid(feedUri, signal) + if (!feedGenDid) 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}, + const batch = await hydrateFeedSkeleton( + {feedGenDid, feed: feedUri, limit: opts.limit ?? 30, cursor: opts.cursor}, signal, - }) - if (!res.ok) return {feed: []} - const skeleton = (await res.json()) as SkeletonResponse + ).catch(() => undefined) + if (!batch) return {feed: []} + // Preserve the feed generator's ordering; hydrate author-only from the + // already-fetched records. const posts = await Promise.all( - skeleton.feed.map(item => - // Skip malformed entries. - item.post?.startsWith('at://') - ? hydratePost(item.post, opts.viewerDid, signal, 1, {lite: true}).catch(() => undefined) - : Promise.resolve(undefined), - ), + batch.order.map(item => { + const rec = batch.records.get(item.post) + return rec + ? hydratePostFromRecord(item.post, rec, signal).catch(() => undefined) + : Promise.resolve(undefined) + }), ) return { - cursor: skeleton.cursor, + cursor: batch.cursor, feed: posts .filter((p): p is AppBskyFeedDefs.PostView => !!p) .map(post => ({post})), diff --git a/src/lib/microcosm/hydrate.ts b/src/lib/microcosm/hydrate.ts index e3fcd87..6a2ed4a 100644 --- a/src/lib/microcosm/hydrate.ts +++ b/src/lib/microcosm/hydrate.ts @@ -382,6 +382,41 @@ export async function hydratePost( } } +/** + * Build a `PostView` from an ALREADY-FETCHED record (e.g. from Slingshot's + * batch feed hydration), resolving only the author. Lite by design: no counts / + * viewer state. This is what makes batched feeds fast — the post records come + * in one request and only author identities remain. + */ +export async function hydratePostFromRecord( + postUri: string, + recordRes: {cid?: string; value: unknown}, + signal?: AbortSignal, +): Promise { + const parsed = parseAtUri(postUri) + if (!parsed) return undefined + const author = await hydrateProfileBasic(parsed.did, signal).catch( + () => undefined, + ) + if (!author) return undefined + const record = recordRes.value as AppBskyFeedPost.Record + const embed = (await hydrateEmbed( + parsed.did, + record?.embed, + MAX_QUOTE_DEPTH, + signal, + ).catch(() => undefined)) as AppBskyFeedDefs.PostView['embed'] + return { + $type: 'app.bsky.feed.defs#postView', + uri: postUri, + cid: recordRes.cid ?? '', + author, + record, + embed, + indexedAt: record?.createdAt ?? new Date().toISOString(), + } +} + /** A lightweight `ProfileViewBasic` (no counts) for embedding as a post author. */ export async function hydrateProfileBasic( did: string, diff --git a/src/lib/microcosm/slingshot.ts b/src/lib/microcosm/slingshot.ts index eca27e8..816873f 100644 --- a/src/lib/microcosm/slingshot.ts +++ b/src/lib/microcosm/slingshot.ts @@ -104,6 +104,69 @@ async function get( return (await res.json()) as T } +/** + * Batch-hydrate a feed: Slingshot proxies the feed generator's getFeedSkeleton + * and fetches every referenced post record in ONE request. This replaces N + * per-post record fetches (the cause of feeds loading one-by-one). + * + * Returns a map of at-uri -> {cid, value} for the posts that were found. + */ +export async function hydrateFeedSkeleton( + args: { + feedGenDid: string + feed: string + limit?: number + cursor?: string + }, + signal?: AbortSignal, +): Promise<{ + order: Array<{post: string; feedContext?: string}> + records: Map + cursor?: string +}> { + const payload = { + xrpc: 'app.bsky.feed.getFeedSkeleton', + atproto_proxy: `${args.feedGenDid}#bsky_fg`, + params: { + feed: args.feed, + limit: args.limit ?? 30, + ...(args.cursor ? {cursor: args.cursor} : {}), + }, + hydration_sources: [{path: 'feed[].post', shape: 'at-uri'}], + } + const res = await fetch( + `${SLINGSHOT_URL}/xrpc/com.bad-example.proxy.hydrateQueryResponse`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': MICROCOSM_USER_AGENT, + }, + body: JSON.stringify(payload), + signal, + }, + ) + if (!res.ok) { + throw new SlingshotError( + `hydrateFeedSkeleton failed: ${res.status}`, + res.status, + ) + } + const data = (await res.json()) as { + output?: {feed?: Array<{post: string; feedContext?: string}>; cursor?: string} + records?: Record + } + const order = data.output?.feed ?? [] + const records = new Map() + for (const rec of Object.values(data.records ?? {})) { + if (rec.status === 'found' && rec.uri) { + records.set(rec.uri, {uri: rec.uri, cid: rec.cid, value: rec.value}) + } + } + return {order, records, cursor: data.output?.cursor} +} + /** * `com.atproto.repo.getRecord` — fetch a single record by repo/collection/rkey. * Compatible with the canonical atproto lexicon.