From 47619a4d642e84b572bc35927536052bc8709ddc Mon Sep 17 00:00:00 2001 From: scanash00 Date: Fri, 12 Jun 2026 11:43:08 -0800 Subject: [PATCH] Load custom feeds by calling the generator directly (no AppView, no batch proxy) buildCustomFeed now calls the feed generator's getFeedSkeleton DIRECTLY - resolving its endpoint via Slingshot's resolveService - and forwards the viewer's service-auth token in a normal Authorization header. The generator personalizes the feed itself; we then hydrate the returned post uris via Slingshot record reads (no auth needed). This bypasses both the AppView and Slingshot's batch proxy (whose auth-forwarding is broken, returning 'oop/sorry'). Result: on native this fully replaces the AppView for custom feeds, including personalized ones like For You. On web it works for any generator that sends CORS headers; generators without CORS (e.g. foryou.club) still fall back to the AppView, since a browser can't read their cross-origin response - a generator- side limitation, not ours. --- src/lib/microcosm/feed.ts | 27 ++++++++++------- src/lib/microcosm/slingshot.ts | 55 ++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 11 deletions(-) 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} -- 2.51.2