diff --git a/src/lib/microcosm/feed.ts b/src/lib/microcosm/feed.ts index 88d714a..69b8ffb 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' @@ -149,23 +149,13 @@ async function resolveFeedGenDid( return (rec?.value as {did?: string} | undefined)?.did } -/** - * Feed generators we've seen reject a direct browser call (almost always a CORS - * block: the generator doesn't send Access-Control-Allow-Origin, so the browser - * refuses to read the response). We remember them to skip straight to the - * AppView next time instead of repeating a guaranteed-failing request. On native - * there is no CORS, so this set stays empty. - */ -const directFetchUnsupported = new Set() - /** * Fetch + hydrate one page of a custom (algorithmic) feed, with no AppView. * - * Calls the feed generator's getFeedSkeleton DIRECTLY (endpoint resolved via - * Slingshot), forwarding the viewer's service-auth token so personalized feeds - * work. The returned post uris are hydrated via Slingshot record reads. If the - * direct call can't be made (e.g. the generator lacks CORS headers in a - * browser), returns an empty page so the caller falls back to the AppView. + * Uses Slingshot's batch proxy: it forwards the (personalized) getFeedSkeleton + * to the generator AND fetches every referenced post record in ONE server-side + * request. The viewer's service-auth token is forwarded so personalized feeds + * work. Returns an empty page on failure so the caller can fall back. */ export async function buildCustomFeed( feedUri: string, @@ -185,18 +175,15 @@ export async function buildCustomFeed( const feedGenDid = await resolveFeedGenDid(feedUri, signal) if (!feedGenDid) return {feed: []} - // Known to fail a direct browser call (CORS) - go straight to the AppView. - if (directFetchUnsupported.has(feedGenDid)) return {feed: []} - const authorization = opts.getAuthorization ? await opts.getAuthorization(feedGenDid).catch(() => undefined) : undefined - // 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. - let corsBlocked = false - const skeleton = await fetchFeedSkeletonDirect( + // Slingshot's batch proxy fetches the (personalized) skeleton AND every post + // record in one request. It runs server-side, so it works on web too (no CORS + // wall like a direct generator call). The viewer's service-auth token is + // forwarded to the generator with the required `Bearer` scheme. + const batch = await hydrateFeedSkeleton( { feedGenDid, feed: feedUri, @@ -205,30 +192,22 @@ export async function buildCustomFeed( authorization, }, signal, - ).catch((e: unknown) => { - // A browser CORS block surfaces as a TypeError ("Failed to fetch") with no - // status, distinct from an HTTP error. Remember it so we don't retry. - if (e instanceof TypeError) corsBlocked = true - return undefined - }) - if (corsBlocked) directFetchUnsupported.add(feedGenDid) - if (!skeleton || skeleton.order.length === 0) return {feed: []} - - // 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. + ).catch(() => undefined) + if (!batch || batch.order.length === 0) return {feed: []} + + // Preserve the generator's ordering; hydrate author-only from the records + // already fetched in the batch. const posts = await Promise.all( - 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) + 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/slingshot.ts b/src/lib/microcosm/slingshot.ts index a09a087..ea958c0 100644 --- a/src/lib/microcosm/slingshot.ts +++ b/src/lib/microcosm/slingshot.ts @@ -130,10 +130,19 @@ export async function hydrateFeedSkeleton( records: Map cursor?: string }> { + // Slingshot puts the `authorization` value into the upstream Authorization + // header verbatim, so it MUST include the `Bearer ` scheme prefix. Sending a + // bare token makes the proxy fail (400 "oop/sorry"). + const authorization = args.authorization + ? args.authorization.startsWith('Bearer ') + ? args.authorization + : `Bearer ${args.authorization}` + : undefined + const payload = { xrpc: 'app.bsky.feed.getFeedSkeleton', atproto_proxy: `${args.feedGenDid}#bsky_fg`, - ...(args.authorization ? {authorization: args.authorization} : {}), + ...(authorization ? {authorization} : {}), params: { feed: args.feed, limit: args.limit ?? 30,