diff --git a/src/lib/microcosm/feed.ts b/src/lib/microcosm/feed.ts index 3998cb9..c3f356d 100644 --- a/src/lib/microcosm/feed.ts +++ b/src/lib/microcosm/feed.ts @@ -20,8 +20,8 @@ import { hydrateProfileBasic, } from '#/lib/microcosm/hydrate' import { + fetchFeedSkeletonDirect, getRecordByUri, - hydrateFeedSkeleton, listRecords, resolveMiniDoc, } from '#/lib/microcosm/slingshot' @@ -179,7 +179,10 @@ export async function buildCustomFeed( ? await opts.getAuthorization(feedGenDid).catch(() => undefined) : undefined - const batch = await hydrateFeedSkeleton( + // Call the feed generator's getFeedSkeleton DIRECTLY with the viewer's + // service-auth token (no AppView, no Slingshot batch proxy). The generator + // personalizes based on the token and returns an ordered list of post uris. + const skeleton = await fetchFeedSkeletonDirect( { feedGenDid, feed: feedUri, @@ -189,21 +192,23 @@ export async function buildCustomFeed( }, signal, ).catch(() => undefined) - if (!batch) return {feed: []} + if (!skeleton || skeleton.order.length === 0) return {feed: []} - // Preserve the feed generator's ordering; hydrate author-only from the - // already-fetched records. + // Hydrate each post uri into a PostView (records + authors via Slingshot, + // which needs no auth). getRecordByUri is cached/deduped, so repeated authors + // and records across pages are cheap. const posts = await Promise.all( - batch.order.map(item => { - const rec = batch.records.get(item.post) - return rec - ? hydratePostFromRecord(item.post, rec, signal).catch(() => undefined) - : Promise.resolve(undefined) + skeleton.order.map(async item => { + const rec = await getRecordByUri(item.post, undefined, signal).catch( + () => undefined, + ) + if (!rec) return undefined + return hydratePostFromRecord(item.post, rec, signal).catch(() => undefined) }), ) return { - cursor: batch.cursor, + 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 e64c3fd..a09a087 100644 --- a/src/lib/microcosm/slingshot.ts +++ b/src/lib/microcosm/slingshot.ts @@ -258,6 +258,61 @@ export async function resolveService( return res?.endpoint } +/** + * Call a feed generator's `getFeedSkeleton` DIRECTLY (resolving its endpoint via + * Slingshot), bypassing both the AppView and Slingshot's batch proxy. The + * service-auth token (if given) goes in a normal Authorization header, which the + * generator validates itself - this is how personalized feeds work without the + * AppView. Returns the ordered post at-uris + cursor; hydration is done + * separately. + */ +export async function fetchFeedSkeletonDirect( + args: { + feedGenDid: string + feed: string + limit?: number + cursor?: string + /** Service-auth JWT (aud = feedGenDid). Forwarded as a Bearer token. */ + authorization?: string + }, + signal?: AbortSignal, +): Promise<{ + order: Array<{post: string; feedContext?: string}> + cursor?: string +}> { + const endpoint = await resolveService(args.feedGenDid, 'bsky_fg', signal) + if (!endpoint) { + throw new SlingshotError( + `could not resolve feed generator service for ${args.feedGenDid}`, + 404, + ) + } + const url = new URL('/xrpc/app.bsky.feed.getFeedSkeleton', endpoint) + url.searchParams.set('feed', args.feed) + url.searchParams.set('limit', String(args.limit ?? 30)) + if (args.cursor) url.searchParams.set('cursor', args.cursor) + + const headers: Record = {Accept: 'application/json'} + if (args.authorization) { + headers.Authorization = args.authorization.startsWith('Bearer ') + ? args.authorization + : `Bearer ${args.authorization}` + } + + const res = await fetch(url.toString(), {headers, signal}) + if (!res.ok) { + throw new SlingshotError( + `getFeedSkeleton failed: ${res.status}`, + res.status, + ) + } + const data = (await res.json()) as { + feed?: Array<{post: string; feedContext?: string}> + cursor?: string + } + return {order: data.feed ?? [], cursor: data.cursor} +} + export type ListedRecord = {uri: string; cid: string; value: unknown} export type ListRecordsResponse = {records: ListedRecord[]; cursor?: string}